diff --git a/src/components/ImagePlaceholder.tsx b/src/components/ImagePlaceholder.tsx
index fc4c402..6732064 100644
--- a/src/components/ImagePlaceholder.tsx
+++ b/src/components/ImagePlaceholder.tsx
@@ -1,7 +1,7 @@
// 图片占位符组件 - 实现骨架屏效果(支持暗色模式)
const ImagePlaceholder = ({ aspectRatio }: { aspectRatio: string }) => (
void;
-
- // 可选属性
- rate?: string; // douban 卡片可能有评分
- items?: SearchResult[]; // search 卡片可能有聚合数据
-}
-
-function CheckCircleCustom() {
- return (
-
-
-
- );
-}
-
-function PlayCircleSolid({
- className = '',
- fillColor = 'none',
-}: {
- className?: string;
- fillColor?: string;
-}) {
- return (
-
- );
+ rate?: string;
+ items?: SearchResult[];
}
export default function VideoCard({
id,
- title,
- poster,
+ title = '',
+ poster = '',
episodes,
source,
source_name,
- progress,
+ progress = 0,
year,
from,
currentEpisode,
@@ -95,434 +41,321 @@ export default function VideoCard({
rate,
items,
}: VideoCardProps) {
- const [playHover, setPlayHover] = useState(false);
+ const router = useRouter();
const [favorited, setFavorited] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
- const router = useRouter();
- // 判断是否为聚合卡片(只有 search 类型可能有聚合)
- const isAggregate = from === 'search' && !!items && items.length > 0;
+ const isAggregate = from === 'search' && !!items?.length;
- // 处理聚合卡片的逻辑
+ // 聚合数据(仅在 search 模式下)
const aggregateData = useMemo(() => {
- if (!isAggregate) {
- return null;
- }
+ if (!isAggregate || !items) return null;
- const first = items[0];
+ const countMap = new Map
();
+ const episodeCountMap = new Map();
+ const yearCountMap = new Map();
- // 统计出现次数最多的(非 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;
- }
- });
-
- // 统计出现次数最多的年份
- const yearCountMap = new Map();
- items.forEach((item) => {
- if (item.year && item.year.trim()) {
+ if (item.year?.trim()) {
const yearStr = item.year.trim();
yearCountMap.set(yearStr, (yearCountMap.get(yearStr) || 0) + 1);
}
});
- let mostFrequentYear: string | undefined;
- let maxYearCount = 0;
- yearCountMap.forEach((cnt, yr) => {
- if (cnt > maxYearCount) {
- maxYearCount = cnt;
- mostFrequentYear = yr;
- }
- });
+ const getMostFrequent = (
+ map: Map
+ ) => {
+ let maxCount = 0;
+ let result: T | undefined;
+ map.forEach((cnt, key) => {
+ if (cnt > maxCount) {
+ maxCount = cnt;
+ result = key;
+ }
+ });
+ return result;
+ };
return {
- first,
- mostFrequentDoubanId,
- mostFrequentEpisodes,
- mostFrequentYear,
+ first: items[0],
+ mostFrequentDoubanId: getMostFrequent(countMap),
+ mostFrequentEpisodes: getMostFrequent(episodeCountMap) || 0,
+ mostFrequentYear: getMostFrequent(yearCountMap),
};
}, [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?.toString()
- : douban_id;
- const actualEpisodes =
- isAggregate && aggregateData
- ? aggregateData.mostFrequentEpisodes
- : episodes;
- const actualYear =
- isAggregate && aggregateData ? aggregateData.mostFrequentYear : year;
+ 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 = String(
+ aggregateData?.mostFrequentDoubanId ?? douban_id
+ );
+ const actualEpisodes = aggregateData?.mostFrequentEpisodes ?? episodes;
+ const actualYear = aggregateData?.mostFrequentYear ?? year;
- // 检查初始收藏状态(需要 source 和 id 的卡片类型)
+ // 获取收藏状态
useEffect(() => {
if (from === 'douban' || !actualSource || !actualId) return;
-
- (async () => {
+ const fetchFavoriteStatus = async () => {
try {
const fav = await isFavorited(actualSource, actualId);
setFavorited(fav);
} catch (err) {
throw new Error('检查收藏状态失败');
}
- })();
+ };
+ fetchFavoriteStatus();
}, [from, actualSource, actualId]);
- // 切换收藏状态
- const handleToggleFavorite = async (
- e: React.MouseEvent
- ) => {
- e.preventDefault();
- e.stopPropagation();
+ const handleToggleFavorite = useCallback(
+ async (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
- if (from === 'douban' || !actualSource || !actualId) return;
+ if (from === 'douban' || !actualSource || !actualId) return;
- try {
- const newState = await toggleFavorite(actualSource, actualId, {
- title: actualTitle,
- source_name: source_name || '',
- year: actualYear || '',
- cover: actualPoster,
- total_episodes: actualEpisodes ?? 1,
- save_time: Date.now(),
- });
- setFavorited(newState);
- } catch (err) {
- // 如果删除失败且是收藏夹,恢复显示
- if (isDeleting) {
- setIsDeleting(false);
+ try {
+ const newState = await toggleFavorite(actualSource, actualId, {
+ title: actualTitle,
+ source_name: source_name || '',
+ year: actualYear || '',
+ cover: actualPoster,
+ total_episodes: actualEpisodes ?? 1,
+ save_time: Date.now(),
+ });
+ setFavorited(newState);
+ } catch (err) {
+ throw new Error('切换收藏状态失败');
}
- throw new Error('切换收藏状态失败');
- }
- };
+ },
+ [
+ from,
+ actualSource,
+ actualId,
+ actualTitle,
+ source_name,
+ actualYear,
+ actualPoster,
+ actualEpisodes,
+ ]
+ );
- // 删除对应播放记录
- const handleDeleteRecord = async (
- e: React.MouseEvent
- ) => {
- e.preventDefault();
- e.stopPropagation();
+ const handleDeleteRecord = useCallback(
+ async (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
- if (from !== 'playrecord' || !actualSource || !actualId) return;
+ if (from !== 'playrecord' || !actualSource || !actualId) return;
- try {
- await deletePlayRecord(actualSource, actualId);
- onDelete?.();
- } catch (err) {
- throw new Error('删除播放记录失败');
- }
- };
+ try {
+ await deletePlayRecord(actualSource, actualId);
+ setIsDeleting(true);
+ onDelete?.();
+ } catch (err) {
+ throw new Error('删除播放记录失败');
+ }
+ },
+ [from, actualSource, actualId, onDelete]
+ );
- // 点击处理逻辑
- const handleClick = () => {
+ const handleClick = useCallback(() => {
if (from === 'douban') {
- // douban 卡片使用 title 搜索
router.push(`/play?title=${encodeURIComponent(actualTitle.trim())}`);
} else if (actualSource && actualId) {
- // 其他类型使用 source 和 id
router.push(
`/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
- actualTitle.trim()
+ actualTitle
)}${actualYear ? `&year=${actualYear}` : ''}`
);
}
- };
+ }, [from, actualSource, actualId, router, actualTitle, actualYear]);
- // 播放按钮点击处理
- const handlePlayClick = (e: React.MouseEvent) => {
- e.preventDefault();
- e.stopPropagation();
- handleClick();
- };
-
- // 根据 from 类型决定显示逻辑
- const getDisplayConfig = () => {
- switch (from) {
- case 'playrecord':
- return {
- showSourceName: true,
- showProgress: true,
- showPlayButton: true,
- playButtonAlwaysVisible: true,
- playButtonOpacity: 'opacity-50 group-hover:opacity-100',
- showHeart: true,
- heartAlwaysVisible: true,
- heartOpacity: 'opacity-100',
- showCheckCircle: true,
- checkCircleAlwaysVisible: true,
- showDoubanLink: false,
- showRating: false,
- hoverLayerOpacity: 'opacity-50 group-hover:opacity-100',
- };
- case 'favorite':
- return {
- showSourceName: true,
- showProgress: false,
- showPlayButton: true,
- playButtonAlwaysVisible: false,
- playButtonOpacity: 'opacity-70 group-hover:opacity-100',
- showHeart: true,
- heartAlwaysVisible: false,
- heartOpacity: 'opacity-70 group-hover:opacity-100',
- showCheckCircle: false,
- checkCircleAlwaysVisible: false,
- showDoubanLink: false,
- showRating: false,
- hoverLayerOpacity: 'opacity-0 group-hover:opacity-100',
- };
- case 'search':
- return {
- showSourceName: true,
- showProgress: false,
- showPlayButton: true,
- playButtonAlwaysVisible: true,
- playButtonOpacity: 'opacity-50 group-hover:opacity-100',
- showHeart: !isAggregate, // 聚合卡片不显示收藏
- heartAlwaysVisible: !isAggregate,
- heartOpacity: 'opacity-50 group-hover:opacity-100',
- showCheckCircle: false,
- checkCircleAlwaysVisible: false,
- showDoubanLink: !!actualDoubanId,
- showRating: false,
- hoverLayerOpacity: isAggregate
- ? 'opacity-50 group-hover:opacity-100'
- : 'opacity-50 group-hover:opacity-100',
- };
- case 'douban':
- return {
- showSourceName: false,
- showProgress: false,
- showPlayButton: true,
- playButtonAlwaysVisible: false,
- playButtonOpacity: 'opacity-70 group-hover:opacity-100',
- showHeart: false,
- heartAlwaysVisible: false,
- heartOpacity: '',
- showCheckCircle: false,
- checkCircleAlwaysVisible: false,
- showDoubanLink: true,
- showRating: !!rate,
- hoverLayerOpacity: 'opacity-0 group-hover:opacity-100',
- };
- default:
- return {
- showSourceName: true,
- showProgress: false,
- showPlayButton: true,
- playButtonAlwaysVisible: false,
- playButtonOpacity: 'opacity-70 group-hover:opacity-100',
- showHeart: true,
- heartAlwaysVisible: false,
- heartOpacity: 'opacity-70 group-hover:opacity-100',
- showCheckCircle: false,
- checkCircleAlwaysVisible: false,
- showDoubanLink: false,
- showRating: false,
- hoverLayerOpacity: 'opacity-0 group-hover:opacity-100',
- };
- }
- };
-
- const config = getDisplayConfig();
+ const config = useMemo(() => {
+ const configs = {
+ playrecord: {
+ showSourceName: true,
+ showProgress: true,
+ showPlayButton: true,
+ showHeart: true,
+ showCheckCircle: true,
+ showDoubanLink: false,
+ showRating: false,
+ },
+ favorite: {
+ showSourceName: true,
+ showProgress: false,
+ showPlayButton: true,
+ showHeart: true,
+ showCheckCircle: false,
+ showDoubanLink: false,
+ showRating: false,
+ },
+ search: {
+ showSourceName: true,
+ showProgress: false,
+ showPlayButton: true,
+ showHeart: !isAggregate,
+ showCheckCircle: false,
+ showDoubanLink: !!actualDoubanId,
+ showRating: false,
+ },
+ douban: {
+ showSourceName: false,
+ showProgress: false,
+ showPlayButton: true,
+ showHeart: false,
+ showCheckCircle: false,
+ showDoubanLink: true,
+ showRating: !!rate,
+ },
+ };
+ return configs[from] || configs.search;
+ }, [from, isAggregate, actualDoubanId, rate]);
return (
- {/* 海报图片容器 */}
-
- {/* 图片占位符 - 骨架屏效果 */}
-
+ {/* 海报容器 */}
+
+ {/* 骨架屏 */}
+ {!isLoaded &&
}
+ {/* 图片加载动画 */}
setIsLoaded(true)}
referrerPolicy='no-referrer'
priority={false}
/>
- {/* Hover 效果层 */}
-
- {/* 播放按钮 */}
+ {/* 悬浮层 - 添加渐变动画效果 */}
+
{config.showPlayButton && (
-
-
setPlayHover(true)}
- onMouseLeave={() => setPlayHover(false)}
- >
-
-
-
+
)}
- {/* 右侧操作按钮组 */}
+ {/* 已看 / 收藏按钮 - 添加弹出动画 */}
{(config.showHeart || config.showCheckCircle) && (
-
+
{config.showCheckCircle && (
- ) =>
+ handleDeleteRecord(
+ e as unknown as React.MouseEvent
+ )
+ }
+ title='标记为已看'
+ className='p-1.5 rounded-full transition-all duration-300 transform hover:scale-110 hover:bg-white/30'
>
-
-
+
+
)}
{config.showHeart && (
- ) =>
+ handleToggleFavorite(
+ e as unknown as React.MouseEvent
+ )
+ }
+ title={favorited ? '取消收藏' : '加入收藏'}
+ className='p-1.5 rounded-full transition-all duration-300 transform hover:scale-110 hover:bg-white/30'
>
-
+
)}
)}
- {/* 评分徽章(豆瓣卡片) */}
+ {/* 集数徽章 / 标签元素 - 添加微动画 */}
{config.showRating && rate && (
-
-
- {rate}
-
+
+ {rate}
)}
- {/* 继续观看 - 集数矩形展示框 */}
- {(from === 'playrecord' || from === 'favorite') &&
+ {['playrecord', 'favorite'].includes(from) &&
actualEpisodes &&
actualEpisodes > 1 &&
currentEpisode && (
-
-
- {currentEpisode}
- /
-
-
- {actualEpisodes}
-
+
+ {currentEpisode}/{actualEpisodes}
)}
- {/* 搜索页 - 集数圆形展示框 */}
{from === 'search' &&
actualEpisodes &&
actualEpisodes > 1 &&
!currentEpisode && (
-