feat(组件): 添加视频搜索结果展示和播放组件
- SearchVideosGrid: 视频搜索结果网格展示组件 - 支持3列响应式布局 - 显示视频封面、标题、时长、作者、平台标签 - 封面图片懒加载和加载状态动画 - 点击卡片打开播放弹窗 - VideoPlayerModal: 视频播放弹窗组件 - 支持B站、YouTube视频嵌入播放 - 悬浮选集面板(仿B站原生设计) - 自动获取B站视频选集信息 - 支持选集切换和播放状态指示 - 不支持嵌入的视频显示跳转提示 - ESC关闭和键盘快捷键支持 - EpisodeList: 视频选集列表组件 - 显示选集编号、标题、时长 - 当前播放集高亮显示 - 支持滚动和加载状态
This commit is contained in:
parent
cab19672e0
commit
ecf11e6b2b
144
src/components/features/EpisodeList.tsx
Normal file
144
src/components/features/EpisodeList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
src/components/features/SearchVideosGrid.tsx
Normal file
246
src/components/features/SearchVideosGrid.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
497
src/components/features/VideoPlayerModal.tsx
Normal file
497
src/components/features/VideoPlayerModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user