feat(组件): 添加视频搜索结果展示和播放组件

- SearchVideosGrid: 视频搜索结果网格展示组件
  - 支持3列响应式布局
  - 显示视频封面、标题、时长、作者、平台标签
  - 封面图片懒加载和加载状态动画
  - 点击卡片打开播放弹窗
- VideoPlayerModal: 视频播放弹窗组件
  - 支持B站、YouTube视频嵌入播放
  - 悬浮选集面板(仿B站原生设计)
  - 自动获取B站视频选集信息
  - 支持选集切换和播放状态指示
  - 不支持嵌入的视频显示跳转提示
  - ESC关闭和键盘快捷键支持
- EpisodeList: 视频选集列表组件
  - 显示选集编号、标题、时长
  - 当前播放集高亮显示
  - 支持滚动和加载状态
This commit is contained in:
gaoziman 2025-12-22 21:59:25 +08:00
parent cab19672e0
commit ecf11e6b2b
3 changed files with 887 additions and 0 deletions

View File

@ -0,0 +1,144 @@
'use client';
import { useCallback } from 'react';
import { Play, Clock, List, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
*
*/
export interface VideoEpisode {
page: number; // 分P编号从1开始
part: string; // 分P标题
duration: number; // 时长(秒)
cid?: number; // B站视频分片ID
}
interface EpisodeListProps {
episodes: VideoEpisode[];
currentEpisode: number;
onEpisodeChange: (page: number) => void;
isLoading?: boolean;
className?: string;
}
/**
* -> MM:SS HH:MM:SS
*/
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
/**
*
*/
export function EpisodeList({
episodes,
currentEpisode,
onEpisodeChange,
isLoading = false,
className,
}: EpisodeListProps) {
const handleClick = useCallback((page: number) => {
if (page !== currentEpisode) {
onEpisodeChange(page);
}
}, [currentEpisode, onEpisodeChange]);
// 如果没有选集或只有一个,不显示
if (!isLoading && episodes.length <= 1) {
return null;
}
return (
<div className={cn('flex flex-col h-full', className)}>
{/* 标题栏 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50 bg-muted/30">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<List size={14} />
<span></span>
{!isLoading && episodes.length > 0 && (
<span className="text-muted-foreground font-normal">
({currentEpisode}/{episodes.length})
</span>
)}
</div>
</div>
{/* 选集列表 */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
// 加载状态
<div className="flex flex-col items-center justify-center h-full py-8 text-muted-foreground">
<Loader2 size={24} className="animate-spin mb-2" />
<span className="text-sm">...</span>
</div>
) : episodes.length === 0 ? (
// 无选集
<div className="flex flex-col items-center justify-center h-full py-8 text-muted-foreground">
<span className="text-sm"></span>
</div>
) : (
// 选集列表
<div className="py-1">
{episodes.map((episode) => {
const isActive = episode.page === currentEpisode;
return (
<button
key={episode.page}
onClick={() => handleClick(episode.page)}
className={cn(
'w-full flex items-start gap-2 px-3 py-2 text-left',
'transition-colors duration-150',
'hover:bg-muted/50',
isActive && 'bg-primary/10 hover:bg-primary/15'
)}
>
{/* 播放指示器 / 序号 */}
<div className={cn(
'flex-shrink-0 w-5 h-5 flex items-center justify-center',
'text-xs',
isActive ? 'text-primary' : 'text-muted-foreground'
)}>
{isActive ? (
<Play size={12} fill="currentColor" />
) : (
<span>{episode.page.toString().padStart(2, '0')}</span>
)}
</div>
{/* 标题和时长 */}
<div className="flex-1 min-w-0">
<div className={cn(
'text-sm truncate',
isActive ? 'text-primary font-medium' : 'text-foreground'
)}>
{episode.part || `${episode.page}`}
</div>
</div>
{/* 时长 */}
<div className={cn(
'flex-shrink-0 flex items-center gap-1 text-xs',
isActive ? 'text-primary/70' : 'text-muted-foreground'
)}>
<Clock size={10} />
<span>{formatDuration(episode.duration)}</span>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,246 @@
'use client';
import { useState, useMemo, useCallback } from 'react';
import { Play, Video, Clock, User, Calendar, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatDuration, detectPlatform, getPlatformName, getPlatformColor } from '@/lib/videoUtils';
import { VideoPlayerModal } from './VideoPlayerModal';
export interface SearchVideoItem {
title: string;
link: string;
snippet: string;
score: string;
position: number;
authors: string[];
date: string;
duration: string;
coverImage: string;
}
interface SearchVideosGridProps {
videos: SearchVideoItem[];
className?: string;
/** 最大显示视频数量,默认为 5 */
maxDisplay?: number;
}
/**
*
*
*
*/
export function SearchVideosGrid({
videos,
className,
maxDisplay = 5
}: SearchVideosGridProps) {
const [playerOpen, setPlayerOpen] = useState(false);
const [selectedVideo, setSelectedVideo] = useState<SearchVideoItem | null>(null);
// 封面加载状态
const [loadedCovers, setLoadedCovers] = useState<Set<number>>(new Set());
const [errorCovers, setErrorCovers] = useState<Set<number>>(new Set());
/**
*
*
*/
const displayVideos = useMemo(() => {
const result: { video: SearchVideoItem; originalIndex: number }[] = [];
for (let i = 0; i < videos.length && result.length < maxDisplay; i++) {
// 即使封面加载失败也显示,使用占位图
result.push({ video: videos[i], originalIndex: i });
}
return result;
}, [videos, maxDisplay]);
// 封面加载成功
const handleCoverLoad = useCallback((index: number) => {
setLoadedCovers((prev) => new Set(prev).add(index));
}, []);
// 封面加载失败
const handleCoverError = useCallback((index: number) => {
setErrorCovers((prev) => new Set(prev).add(index));
// 标记为已加载(显示占位图)
setLoadedCovers((prev) => new Set(prev).add(index));
}, []);
// 打开视频播放器
const openPlayer = useCallback((video: SearchVideoItem) => {
setSelectedVideo(video);
setPlayerOpen(true);
}, []);
// 关闭视频播放器
const closePlayer = useCallback(() => {
setPlayerOpen(false);
setSelectedVideo(null);
}, []);
if (!videos || videos.length === 0) return null;
if (displayVideos.length === 0) {
return null;
}
return (
<>
<div className={cn('mt-3', className)}>
{/* 标题 */}
<div className="flex items-center gap-2 mb-3 text-sm text-muted-foreground">
<Video size={14} />
<span>
{displayVideos.length}
</span>
</div>
{/* 视频网格容器 - 3列布局 */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{displayVideos.map(({ video, originalIndex }, displayIndex) => {
const isLoaded = loadedCovers.has(originalIndex);
const hasError = errorCovers.has(originalIndex);
const platform = detectPlatform(video.link);
const platformName = getPlatformName(platform);
const platformColor = getPlatformColor(platform);
return (
<div
key={`${video.link}-${originalIndex}`}
className={cn(
'group relative cursor-pointer',
'rounded overflow-hidden',
'bg-white dark:bg-zinc-900',
'ring-1 ring-black/5 dark:ring-white/10',
'shadow-[0_2px_8px_rgba(0,0,0,0.06)]',
'transition-all duration-200',
'hover:shadow-[0_6px_20px_rgba(0,0,0,0.1)]',
'hover:ring-primary/20',
!isLoaded && 'animate-pulse'
)}
onClick={() => openPlayer(video)}
>
{/* 封面容器 */}
<div className="relative aspect-video overflow-hidden bg-muted/30">
{/* 封面图片 */}
{video.coverImage && !hasError ? (
<img
src={video.coverImage}
alt={video.title || `视频 ${displayIndex + 1}`}
className={cn(
'w-full h-full object-cover',
'transition-all duration-300',
isLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-105',
'group-hover:scale-105'
)}
onLoad={() => handleCoverLoad(originalIndex)}
onError={() => handleCoverError(originalIndex)}
loading="lazy"
/>
) : (
// 占位图
<div className="w-full h-full flex items-center justify-center bg-muted/50">
<Video size={32} className="text-muted-foreground/50" />
</div>
)}
{/* 加载中指示器 */}
{!isLoaded && video.coverImage && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
)}
{/* 播放按钮遮罩 */}
<div className={cn(
'absolute inset-0 flex items-center justify-center',
'bg-black/0 group-hover:bg-black/30',
'transition-all duration-200'
)}>
<div className={cn(
'w-12 h-12 rounded-full',
'bg-white/90 dark:bg-black/80',
'flex items-center justify-center',
'opacity-0 group-hover:opacity-100',
'scale-75 group-hover:scale-100',
'transition-all duration-200',
'shadow-lg'
)}>
<Play size={20} className="text-primary ml-0.5" fill="currentColor" />
</div>
</div>
{/* 时长标签 */}
{video.duration && (
<div className={cn(
'absolute bottom-2 right-2',
'px-1.5 py-0.5 rounded',
'bg-black/75 text-white',
'text-xs font-medium',
'flex items-center gap-1'
)}>
<Clock size={10} />
<span>{formatDuration(video.duration)}</span>
</div>
)}
{/* 平台标签 */}
<div
className={cn(
'absolute top-2 left-2',
'px-1.5 py-0.5 rounded',
'text-white text-xs font-medium'
)}
style={{ backgroundColor: platformColor }}
>
{platformName}
</div>
</div>
{/* 视频信息 */}
<div className="p-2.5 space-y-1.5">
{/* 标题 */}
<h4
className="text-sm font-medium text-foreground/90 line-clamp-2 leading-snug"
title={video.title}
>
{video.title}
</h4>
{/* 元信息行 */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{/* 作者 */}
{video.authors && video.authors.length > 0 && (
<div className="flex items-center gap-1 truncate">
<User size={10} />
<span className="truncate">{video.authors[0]}</span>
</div>
)}
{/* 日期 */}
{video.date && (
<div className="flex items-center gap-1 flex-shrink-0">
<Calendar size={10} />
<span>{video.date}</span>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
{/* 视频播放弹窗 */}
<VideoPlayerModal
video={selectedVideo}
isOpen={playerOpen}
onClose={closePlayer}
/>
</>
);
}

View File

@ -0,0 +1,497 @@
'use client';
import { useEffect, useCallback, useRef, useState } from 'react';
import { X, ExternalLink, User, Calendar, Clock, AlertCircle, List, ChevronRight, Play } from 'lucide-react';
import { cn } from '@/lib/utils';
import { parseVideoUrl, formatDuration, detectPlatform, getPlatformName, getPlatformColor, supportsEmbed, getEmbedUrlWithPage, supportsEpisodes } from '@/lib/videoUtils';
import { VideoEpisode } from './EpisodeList';
interface VideoPlayerModalProps {
video: {
title: string;
link: string;
snippet?: string;
authors?: string[];
date?: string;
duration?: string;
coverImage?: string;
} | null;
isOpen: boolean;
onClose: () => void;
}
/**
*
* B站YouTube
* B站原生
*/
export function VideoPlayerModal({
video,
isOpen,
onClose,
}: VideoPlayerModalProps) {
const containerRef = useRef<HTMLDivElement>(null);
// 选集状态管理
const [episodes, setEpisodes] = useState<VideoEpisode[]>([]);
const [currentEpisode, setCurrentEpisode] = useState(1);
const [isLoadingEpisodes, setIsLoadingEpisodes] = useState(false);
const [episodesError, setEpisodesError] = useState<string | null>(null);
// 选集面板展开状态
const [isEpisodePanelOpen, setIsEpisodePanelOpen] = useState(false);
// 键盘事件处理
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (isEpisodePanelOpen) {
setIsEpisodePanelOpen(false);
} else {
onClose();
}
}
};
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [isOpen, onClose, isEpisodePanelOpen]);
// 获取视频选集
useEffect(() => {
if (!isOpen || !video?.link) {
setEpisodes([]);
setCurrentEpisode(1);
setEpisodesError(null);
setIsEpisodePanelOpen(false);
return;
}
const platform = detectPlatform(video.link);
if (!supportsEpisodes(platform)) {
return;
}
const fetchEpisodes = async () => {
setIsLoadingEpisodes(true);
setEpisodesError(null);
try {
const response = await fetch(`/api/video/episodes?url=${encodeURIComponent(video.link)}`);
if (!response.ok) {
throw new Error('获取选集失败');
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
setEpisodes(data.episodes || []);
} catch (error) {
console.error('[VideoPlayerModal] 获取选集失败:', error);
setEpisodesError(error instanceof Error ? error.message : '获取选集失败');
setEpisodes([]);
} finally {
setIsLoadingEpisodes(false);
}
};
fetchEpisodes();
}, [isOpen, video?.link]);
// 打开原链接
const openOriginalLink = useCallback(() => {
if (video?.link) {
window.open(video.link, '_blank', 'noopener,noreferrer');
}
}, [video?.link]);
// 切换选集
const handleEpisodeChange = useCallback((page: number) => {
setCurrentEpisode(page);
}, []);
// 切换选集面板
const toggleEpisodePanel = useCallback(() => {
setIsEpisodePanelOpen(prev => !prev);
}, []);
if (!isOpen || !video) return null;
const videoInfo = parseVideoUrl(video.link);
const platform = detectPlatform(video.link);
const platformName = getPlatformName(platform);
const platformColor = getPlatformColor(platform);
const canEmbed = supportsEmbed(platform);
// 计算选集相关状态
const hasEpisodes = episodes.length > 1;
const showEpisodeButton = supportsEpisodes(platform) && (isLoadingEpisodes || hasEpisodes);
// 获取当前选集的嵌入 URL
const currentEmbedUrl = hasEpisodes
? getEmbedUrlWithPage(video.link, currentEpisode)
: videoInfo?.embedUrl;
// 格式化时长
const formatEpisodeDuration = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
return (
<div
ref={containerRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-md"
onClick={onClose}
style={{ animation: 'fadeIn 0.2s ease-out' }}
>
{/* 弹窗容器 */}
<div
className={cn(
'relative w-full max-w-5xl mx-4',
'bg-zinc-900',
'rounded-md overflow-hidden',
'shadow-2xl shadow-black/50',
'border border-white/10'
)}
onClick={(e) => e.stopPropagation()}
style={{ animation: 'scaleIn 0.25s ease-out' }}
>
{/* 顶部标题栏 */}
<div className="flex items-center justify-between px-4 py-3 bg-zinc-800/80 border-b border-white/5">
<div className="flex items-center gap-3 min-w-0 flex-1">
{/* 平台标签 */}
<span
className="px-2.5 py-1 rounded-md text-xs font-semibold text-white flex-shrink-0 shadow-sm"
style={{ backgroundColor: platformColor }}
>
{platformName}
</span>
{/* 标题 */}
<h3
className="text-sm font-medium text-white/90 truncate"
title={video.title}
>
{video.title}
</h3>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1 flex-shrink-0 ml-3">
{/* 选集按钮 */}
{showEpisodeButton && (
<button
onClick={toggleEpisodePanel}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium',
'transition-all duration-200',
isEpisodePanelOpen
? 'bg-pink-500/20 text-pink-400 border border-pink-500/30'
: 'text-white/70 hover:text-white hover:bg-white/10'
)}
>
<List size={16} />
<span></span>
{hasEpisodes && (
<span className="text-xs opacity-70">
({currentEpisode}/{episodes.length})
</span>
)}
<ChevronRight
size={14}
className={cn(
'transition-transform duration-200',
isEpisodePanelOpen && 'rotate-180'
)}
/>
</button>
)}
{/* 打开原链接 */}
<button
onClick={openOriginalLink}
className={cn(
'p-2 rounded-lg',
'text-white/60 hover:text-white',
'hover:bg-white/10',
'transition-colors duration-150'
)}
title="在新标签页打开"
>
<ExternalLink size={18} />
</button>
{/* 关闭按钮 */}
<button
onClick={onClose}
className={cn(
'p-2 rounded-lg',
'text-white/60 hover:text-white',
'hover:bg-white/10',
'transition-colors duration-150'
)}
title="关闭 (ESC)"
>
<X size={18} />
</button>
</div>
</div>
{/* 视频播放区域 - 相对定位容器 */}
<div className="relative">
{/* 视频播放器 */}
<div className="aspect-video bg-black">
{canEmbed && currentEmbedUrl ? (
<iframe
src={currentEmbedUrl}
className="w-full h-full"
allowFullScreen
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
title={video.title}
/>
) : (
// 不支持嵌入的视频,显示封面和跳转提示
<div className="relative w-full h-full flex items-center justify-center">
{video.coverImage && (
<img
src={video.coverImage}
alt=""
className="absolute inset-0 w-full h-full object-cover blur-sm opacity-30"
/>
)}
<div className="relative z-10 text-center p-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-white/10 flex items-center justify-center">
<AlertCircle size={32} className="text-white/80" />
</div>
<p className="text-white/90 text-lg font-medium mb-2">
{platformName}
</p>
<p className="text-white/60 text-sm mb-6">
{platformName}
</p>
<button
onClick={openOriginalLink}
className={cn(
'px-6 py-2.5 rounded-lg',
'bg-white text-black',
'font-medium text-sm',
'hover:bg-white/90',
'transition-colors duration-150',
'flex items-center gap-2 mx-auto'
)}
>
<ExternalLink size={16} />
{platformName}
</button>
</div>
</div>
)}
</div>
{/* 悬浮选集面板 */}
{showEpisodeButton && (
<div
className={cn(
'absolute top-0 right-0 h-full w-72',
'bg-gradient-to-l from-zinc-900/98 via-zinc-900/95 to-zinc-900/90',
'backdrop-blur-xl',
'border-l border-white/10',
'transition-all duration-300 ease-out',
'flex flex-col',
isEpisodePanelOpen
? 'translate-x-0 opacity-100'
: 'translate-x-full opacity-0 pointer-events-none'
)}
style={{
boxShadow: isEpisodePanelOpen ? '-10px 0 30px rgba(0,0,0,0.5)' : 'none'
}}
>
{/* 面板标题 */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 bg-white/5">
<div className="flex items-center gap-2">
<List size={16} className="text-pink-400" />
<span className="text-sm font-semibold text-white"></span>
</div>
{hasEpisodes && (
<span className="text-xs text-white/50 bg-white/10 px-2 py-0.5 rounded-full">
{currentEpisode} / {episodes.length}
</span>
)}
</div>
{/* 选集列表 */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{isLoadingEpisodes ? (
<div className="flex flex-col items-center justify-center h-full py-8">
<div className="w-8 h-8 border-2 border-pink-500/30 border-t-pink-500 rounded-full animate-spin mb-3" />
<span className="text-sm text-white/50">...</span>
</div>
) : episodes.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full py-8 text-white/40">
<span className="text-sm"></span>
</div>
) : (
<div className="py-2">
{episodes.map((episode) => {
const isActive = episode.page === currentEpisode;
return (
<button
key={episode.page}
onClick={() => handleEpisodeChange(episode.page)}
className={cn(
'w-full flex items-start gap-3 px-4 py-2.5 text-left',
'transition-all duration-150',
'hover:bg-white/10',
'group',
isActive && 'bg-pink-500/15'
)}
>
{/* 播放指示器 / 序号 */}
<div className={cn(
'flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-md',
'text-xs font-medium',
isActive
? 'bg-pink-500 text-white'
: 'bg-white/10 text-white/60 group-hover:bg-white/20'
)}>
{isActive ? (
<Play size={12} fill="currentColor" />
) : (
<span>{episode.page}</span>
)}
</div>
{/* 标题和时长 */}
<div className="flex-1 min-w-0">
<div className={cn(
'text-sm truncate mb-0.5',
isActive ? 'text-pink-400 font-medium' : 'text-white/80 group-hover:text-white'
)}>
{episode.part || `${episode.page}`}
</div>
<div className={cn(
'flex items-center gap-1 text-xs',
isActive ? 'text-pink-400/60' : 'text-white/40'
)}>
<Clock size={10} />
<span>{formatEpisodeDuration(episode.duration)}</span>
</div>
</div>
</button>
);
})}
</div>
)}
</div>
{/* 面板底部提示 */}
<div className="px-4 py-2 border-t border-white/5 bg-white/5">
<p className="text-xs text-white/30 text-center">
</p>
</div>
</div>
)}
</div>
{/* 底部信息栏 */}
<div className="px-4 py-3 bg-zinc-800/50 border-t border-white/5">
<div className="flex items-center justify-between">
{/* 左侧:元信息 */}
<div className="flex items-center gap-4 text-sm text-white/50">
{video.authors && video.authors.length > 0 && (
<div className="flex items-center gap-1.5">
<User size={14} />
<span>{video.authors.join(', ')}</span>
</div>
)}
{video.date && (
<div className="flex items-center gap-1.5">
<Calendar size={14} />
<span>{video.date}</span>
</div>
)}
{video.duration && (
<div className="flex items-center gap-1.5">
<Clock size={14} />
<span>{formatDuration(video.duration)}</span>
</div>
)}
</div>
{/* 右侧:快捷提示 */}
<div className="text-xs text-white/30">
ESC
</div>
</div>
{video.snippet && video.snippet.length < 200 && (
<p className="mt-2 text-xs text-white/40 line-clamp-2">
{video.snippet}
</p>
)}
</div>
</div>
{/* 自定义滚动条样式 */}
<style jsx global>{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
`}</style>
</div>
);
}