mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-22 12:13:43 +08:00
feat: Add video card component, desktop player controls, and introduce API utilities for search and data parsing.
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { DesktopProgressBar } from './DesktopProgressBar';
|
||||
import { DesktopVolumeControl } from './DesktopVolumeControl';
|
||||
import { DesktopSpeedMenu } from './DesktopSpeedMenu';
|
||||
import { DesktopMoreMenu } from './DesktopMoreMenu';
|
||||
import { DesktopLeftControls } from './DesktopLeftControls';
|
||||
import { DesktopRightControls } from './DesktopRightControls';
|
||||
|
||||
interface DesktopControlsProps {
|
||||
showControls: boolean;
|
||||
@@ -44,44 +42,17 @@ interface DesktopControlsProps {
|
||||
speeds: number[];
|
||||
}
|
||||
|
||||
export function DesktopControls({
|
||||
showControls,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
isFullscreen,
|
||||
playbackRate,
|
||||
showSpeedMenu,
|
||||
showMoreMenu,
|
||||
showVolumeBar,
|
||||
isPiPSupported,
|
||||
isAirPlaySupported,
|
||||
progressBarRef,
|
||||
volumeBarRef,
|
||||
onTogglePlay,
|
||||
onSkipForward,
|
||||
onSkipBackward,
|
||||
onToggleMute,
|
||||
onVolumeChange,
|
||||
onVolumeMouseDown,
|
||||
onToggleFullscreen,
|
||||
onTogglePictureInPicture,
|
||||
onShowAirPlayMenu,
|
||||
onToggleSpeedMenu,
|
||||
onToggleMoreMenu,
|
||||
onSpeedChange,
|
||||
onCopyLink,
|
||||
onProgressClick,
|
||||
onProgressMouseDown,
|
||||
onSpeedMenuMouseEnter,
|
||||
onSpeedMenuMouseLeave,
|
||||
onMoreMenuMouseEnter,
|
||||
onMoreMenuMouseLeave,
|
||||
formatTime,
|
||||
speeds
|
||||
}: DesktopControlsProps) {
|
||||
export function DesktopControls(props: DesktopControlsProps) {
|
||||
const {
|
||||
showControls,
|
||||
currentTime,
|
||||
duration,
|
||||
progressBarRef,
|
||||
onProgressClick,
|
||||
onProgressMouseDown,
|
||||
formatTime,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 transition-all duration-300 ${showControls ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
|
||||
@@ -100,109 +71,8 @@ export function DesktopControls({
|
||||
{/* Controls Bar */}
|
||||
<div className="bg-gradient-to-t from-black/90 via-black/70 to-transparent px-4 pb-4 pt-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Left Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="btn-icon"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Icons.Pause size={20} /> : <Icons.Play size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Skip Backward 10s */}
|
||||
<button
|
||||
onClick={onSkipBackward}
|
||||
className="btn-icon"
|
||||
aria-label="Skip backward 10 seconds"
|
||||
title="后退 10 秒"
|
||||
>
|
||||
<Icons.SkipBack size={20} />
|
||||
</button>
|
||||
|
||||
{/* Skip Forward 10s */}
|
||||
<button
|
||||
onClick={onSkipForward}
|
||||
className="btn-icon"
|
||||
aria-label="Skip forward 10 seconds"
|
||||
title="快进 10 秒"
|
||||
>
|
||||
<Icons.SkipForward size={20} />
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<DesktopVolumeControl
|
||||
volumeBarRef={volumeBarRef}
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
showVolumeBar={showVolumeBar}
|
||||
onToggleMute={onToggleMute}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onVolumeMouseDown={onVolumeMouseDown}
|
||||
/>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-white text-sm font-medium tabular-nums">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Playback Speed */}
|
||||
<DesktopSpeedMenu
|
||||
showSpeedMenu={showSpeedMenu}
|
||||
playbackRate={playbackRate}
|
||||
speeds={speeds}
|
||||
onSpeedChange={onSpeedChange}
|
||||
onToggleSpeedMenu={onToggleSpeedMenu}
|
||||
onMouseEnter={onSpeedMenuMouseEnter}
|
||||
onMouseLeave={onSpeedMenuMouseLeave}
|
||||
/>
|
||||
|
||||
{/* Picture-in-Picture */}
|
||||
{isPiPSupported && (
|
||||
<button
|
||||
onClick={onTogglePictureInPicture}
|
||||
className="btn-icon"
|
||||
aria-label="Picture-in-Picture"
|
||||
title="画中画"
|
||||
>
|
||||
<Icons.PictureInPicture size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* AirPlay */}
|
||||
{isAirPlaySupported && (
|
||||
<button
|
||||
onClick={onShowAirPlayMenu}
|
||||
className="btn-icon"
|
||||
aria-label="AirPlay"
|
||||
title="AirPlay"
|
||||
>
|
||||
<Icons.Airplay size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* More Menu */}
|
||||
<DesktopMoreMenu
|
||||
showMoreMenu={showMoreMenu}
|
||||
onToggleMoreMenu={onToggleMoreMenu}
|
||||
onMouseEnter={onMoreMenuMouseEnter}
|
||||
onMouseLeave={onMoreMenuMouseLeave}
|
||||
onCopyLink={onCopyLink}
|
||||
/>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
onClick={onToggleFullscreen}
|
||||
className="btn-icon"
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Icons.Minimize size={20} /> : <Icons.Maximize size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
<DesktopLeftControls {...props} formatTime={formatTime} />
|
||||
<DesktopRightControls {...props} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { DesktopVolumeControl } from './DesktopVolumeControl';
|
||||
|
||||
interface DesktopLeftControlsProps {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
showVolumeBar: boolean;
|
||||
volumeBarRef: React.RefObject<HTMLDivElement | null>;
|
||||
onTogglePlay: () => void;
|
||||
onSkipForward: () => void;
|
||||
onSkipBackward: () => void;
|
||||
onToggleMute: () => void;
|
||||
onVolumeChange: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onVolumeMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
formatTime: (seconds: number) => string;
|
||||
}
|
||||
|
||||
export function DesktopLeftControls({
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
showVolumeBar,
|
||||
volumeBarRef,
|
||||
onTogglePlay,
|
||||
onSkipForward,
|
||||
onSkipBackward,
|
||||
onToggleMute,
|
||||
onVolumeChange,
|
||||
onVolumeMouseDown,
|
||||
formatTime
|
||||
}: DesktopLeftControlsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="btn-icon"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Icons.Pause size={20} /> : <Icons.Play size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Skip Backward 10s */}
|
||||
<button
|
||||
onClick={onSkipBackward}
|
||||
className="btn-icon"
|
||||
aria-label="Skip backward 10 seconds"
|
||||
title="后退 10 秒"
|
||||
>
|
||||
<Icons.SkipBack size={20} />
|
||||
</button>
|
||||
|
||||
{/* Skip Forward 10s */}
|
||||
<button
|
||||
onClick={onSkipForward}
|
||||
className="btn-icon"
|
||||
aria-label="Skip forward 10 seconds"
|
||||
title="快进 10 秒"
|
||||
>
|
||||
<Icons.SkipForward size={20} />
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<DesktopVolumeControl
|
||||
volumeBarRef={volumeBarRef}
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
showVolumeBar={showVolumeBar}
|
||||
onToggleMute={onToggleMute}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onVolumeMouseDown={onVolumeMouseDown}
|
||||
/>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-white text-sm font-medium tabular-nums">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { DesktopSpeedMenu } from './DesktopSpeedMenu';
|
||||
import { DesktopMoreMenu } from './DesktopMoreMenu';
|
||||
|
||||
interface DesktopRightControlsProps {
|
||||
isFullscreen: boolean;
|
||||
playbackRate: number;
|
||||
showSpeedMenu: boolean;
|
||||
showMoreMenu: boolean;
|
||||
isPiPSupported: boolean;
|
||||
isAirPlaySupported: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
onTogglePictureInPicture: () => void;
|
||||
onShowAirPlayMenu: () => void;
|
||||
onToggleSpeedMenu: () => void;
|
||||
onToggleMoreMenu: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onCopyLink: () => void;
|
||||
onSpeedMenuMouseEnter: () => void;
|
||||
onSpeedMenuMouseLeave: () => void;
|
||||
onMoreMenuMouseEnter: () => void;
|
||||
onMoreMenuMouseLeave: () => void;
|
||||
speeds: number[];
|
||||
}
|
||||
|
||||
export function DesktopRightControls({
|
||||
isFullscreen,
|
||||
playbackRate,
|
||||
showSpeedMenu,
|
||||
showMoreMenu,
|
||||
isPiPSupported,
|
||||
isAirPlaySupported,
|
||||
onToggleFullscreen,
|
||||
onTogglePictureInPicture,
|
||||
onShowAirPlayMenu,
|
||||
onToggleSpeedMenu,
|
||||
onToggleMoreMenu,
|
||||
onSpeedChange,
|
||||
onCopyLink,
|
||||
onSpeedMenuMouseEnter,
|
||||
onSpeedMenuMouseLeave,
|
||||
onMoreMenuMouseEnter,
|
||||
onMoreMenuMouseLeave,
|
||||
speeds
|
||||
}: DesktopRightControlsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Playback Speed */}
|
||||
<DesktopSpeedMenu
|
||||
showSpeedMenu={showSpeedMenu}
|
||||
playbackRate={playbackRate}
|
||||
speeds={speeds}
|
||||
onSpeedChange={onSpeedChange}
|
||||
onToggleSpeedMenu={onToggleSpeedMenu}
|
||||
onMouseEnter={onSpeedMenuMouseEnter}
|
||||
onMouseLeave={onSpeedMenuMouseLeave}
|
||||
/>
|
||||
|
||||
{/* Picture-in-Picture */}
|
||||
{isPiPSupported && (
|
||||
<button
|
||||
onClick={onTogglePictureInPicture}
|
||||
className="btn-icon"
|
||||
aria-label="Picture-in-Picture"
|
||||
title="画中画"
|
||||
>
|
||||
<Icons.PictureInPicture size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* AirPlay */}
|
||||
{isAirPlaySupported && (
|
||||
<button
|
||||
onClick={onShowAirPlayMenu}
|
||||
className="btn-icon"
|
||||
aria-label="AirPlay"
|
||||
title="AirPlay"
|
||||
>
|
||||
<Icons.Airplay size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* More Menu */}
|
||||
<DesktopMoreMenu
|
||||
showMoreMenu={showMoreMenu}
|
||||
onToggleMoreMenu={onToggleMoreMenu}
|
||||
onMouseEnter={onMoreMenuMouseEnter}
|
||||
onMouseLeave={onMoreMenuMouseLeave}
|
||||
onCopyLink={onCopyLink}
|
||||
/>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
onClick={onToggleFullscreen}
|
||||
className="btn-icon"
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Icons.Minimize size={20} /> : <Icons.Maximize size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { LatencyBadge } from '@/components/ui/LatencyBadge';
|
||||
|
||||
interface Video {
|
||||
vod_id: string;
|
||||
vod_name: string;
|
||||
vod_pic?: string;
|
||||
vod_remarks?: string;
|
||||
vod_year?: string;
|
||||
type_name?: string;
|
||||
source: string;
|
||||
sourceName?: string;
|
||||
isNew?: boolean;
|
||||
latency?: number;
|
||||
}
|
||||
|
||||
interface VideoCardProps {
|
||||
video: Video;
|
||||
videoUrl: string;
|
||||
cardId: string;
|
||||
isActive: boolean;
|
||||
onCardClick: (e: React.MouseEvent, cardId: string, videoUrl: string) => void;
|
||||
}
|
||||
|
||||
export const VideoCard = memo<VideoCardProps>(({
|
||||
video,
|
||||
videoUrl,
|
||||
cardId,
|
||||
isActive,
|
||||
onCardClick
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
contain: 'layout style paint',
|
||||
contentVisibility: 'auto',
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
key={cardId}
|
||||
href={videoUrl}
|
||||
onClick={(e) => onCardClick(e, cardId, videoUrl)}
|
||||
role="listitem"
|
||||
aria-label={`${video.vod_name}${video.vod_remarks ? ` - ${video.vod_remarks}` : ''}`}
|
||||
prefetch={false}
|
||||
>
|
||||
<Card
|
||||
className="p-0 overflow-hidden group cursor-pointer flex flex-col h-full bg-[var(--bg-color)]/50 backdrop-blur-none saturate-100 shadow-sm border-[var(--glass-border)] hover:shadow-lg transition-shadow"
|
||||
hover={false}
|
||||
blur={false}
|
||||
style={{
|
||||
willChange: 'transform',
|
||||
transform: 'translate3d(0,0,0)',
|
||||
backfaceVisibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Poster */}
|
||||
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden rounded-[var(--radius-2xl)]">
|
||||
{video.vod_pic ? (
|
||||
<Image
|
||||
src={video.vod_pic}
|
||||
alt={video.vod_name}
|
||||
fill
|
||||
className="object-cover rounded-[var(--radius-2xl)]"
|
||||
sizes="(max-width: 640px) 33vw, (max-width: 1024px) 20vw, 16vw"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
target.style.opacity = '0';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback Icon */}
|
||||
<div className="absolute inset-0 flex items-center justify-center -z-10">
|
||||
<Icons.Film size={64} className="text-[var(--text-color-secondary)] opacity-20" />
|
||||
</div>
|
||||
|
||||
{/* Badge Container */}
|
||||
<div className="absolute top-2 left-2 right-2 z-10 flex items-center justify-between gap-1">
|
||||
{video.sourceName && (
|
||||
<Badge variant="primary" className="bg-[var(--accent-color)] flex-shrink-0 max-w-[50%] truncate">
|
||||
{video.sourceName}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{video.latency !== undefined && (
|
||||
<LatencyBadge latency={video.latency} className="flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/60 transition-opacity duration-300 ${isActive ? 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100' : 'opacity-0 lg:group-hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
willChange: 'opacity',
|
||||
}}
|
||||
>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
{isActive && (
|
||||
<div className="lg:hidden text-white/90 text-xs mb-2 font-medium">
|
||||
再次点击播放 →
|
||||
</div>
|
||||
)}
|
||||
{video.type_name && (
|
||||
<Badge variant="secondary" className="text-xs mb-2">
|
||||
{video.type_name}
|
||||
</Badge>
|
||||
)}
|
||||
{video.vod_year && (
|
||||
<div className="flex items-center gap-1 text-white/80 text-xs">
|
||||
<Icons.Calendar size={12} />
|
||||
<span>{video.vod_year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3 flex-1 flex flex-col">
|
||||
<h4 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 min-h-[2.5rem]">
|
||||
{video.vod_name}
|
||||
</h4>
|
||||
{video.vod_remarks && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-1">
|
||||
{video.vod_remarks}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
VideoCard.displayName = 'VideoCard';
|
||||
@@ -1,12 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useMemo, memo, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { LatencyBadge } from '@/components/ui/LatencyBadge';
|
||||
import { useState, useRef, useCallback, useMemo, memo } from 'react';
|
||||
import { VideoCard } from './VideoCard';
|
||||
|
||||
interface Video {
|
||||
vod_id: string;
|
||||
@@ -18,7 +13,7 @@ interface Video {
|
||||
source: string;
|
||||
sourceName?: string;
|
||||
isNew?: boolean;
|
||||
latency?: number; // Response time in milliseconds
|
||||
latency?: number;
|
||||
}
|
||||
|
||||
interface VideoGridProps {
|
||||
@@ -26,146 +21,6 @@ interface VideoGridProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Memoized VideoCard component to prevent unnecessary re-renders
|
||||
const VideoCard = memo(({
|
||||
video,
|
||||
videoUrl,
|
||||
cardId,
|
||||
isActive,
|
||||
onCardClick
|
||||
}: {
|
||||
video: Video;
|
||||
videoUrl: string;
|
||||
cardId: string;
|
||||
isActive: boolean;
|
||||
onCardClick: (e: React.MouseEvent, cardId: string, videoUrl: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
// CSS containment for better performance
|
||||
contain: 'layout style paint',
|
||||
contentVisibility: 'auto',
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
key={cardId}
|
||||
href={videoUrl}
|
||||
onClick={(e) => onCardClick(e, cardId, videoUrl)}
|
||||
role="listitem"
|
||||
aria-label={`${video.vod_name}${video.vod_remarks ? ` - ${video.vod_remarks}` : ''}`}
|
||||
prefetch={false}
|
||||
>
|
||||
<Card
|
||||
className="p-0 overflow-hidden group cursor-pointer flex flex-col h-full bg-[var(--bg-color)]/50 backdrop-blur-none saturate-100 shadow-sm border-[var(--glass-border)] hover:shadow-lg transition-shadow"
|
||||
hover={false} // Disable default hover scale to improve performance
|
||||
blur={false}
|
||||
style={{
|
||||
willChange: 'transform',
|
||||
transform: 'translate3d(0,0,0)',
|
||||
backfaceVisibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Poster */}
|
||||
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden rounded-[var(--radius-2xl)]">
|
||||
{video.vod_pic ? (
|
||||
<Image
|
||||
src={video.vod_pic}
|
||||
alt={video.vod_name}
|
||||
fill
|
||||
className="object-cover rounded-[var(--radius-2xl)]"
|
||||
sizes="(max-width: 640px) 33vw, (max-width: 1024px) 20vw, 16vw"
|
||||
loading="lazy"
|
||||
// unoptimized={true} // Removed for performance - let Next.js optimize images
|
||||
onError={(e) => {
|
||||
// Fallback for next/image error is tricky because it doesn't expose the img element directly in the same way
|
||||
// But we can try to hide it or show a placeholder
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
// Since next/image manages the src, we might need a state or a different approach for fallback
|
||||
// For simplicity in this performance fix, we'll rely on the parent div background or a separate placeholder component
|
||||
// But actually, we can just use a simple img tag for fallback if next/image fails,
|
||||
// or better: use a state to switch to fallback.
|
||||
// However, inside a memoized component, adding state might be heavy.
|
||||
// Let's stick to a simple CSS hide for now or use the unoptimized prop if it fails? No.
|
||||
// Let's just hide it and let the background icon show.
|
||||
target.style.opacity = '0';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback Icon (always rendered behind image, visible if image fails/loads) */}
|
||||
<div className="absolute inset-0 flex items-center justify-center -z-10">
|
||||
<Icons.Film size={64} className="text-[var(--text-color-secondary)] opacity-20" />
|
||||
</div>
|
||||
|
||||
{/* Badge Container - Top, spans full width with proper spacing */}
|
||||
<div className="absolute top-2 left-2 right-2 z-10 flex items-center justify-between gap-1">
|
||||
{/* Source Badge - Left */}
|
||||
{video.sourceName && (
|
||||
<Badge variant="primary" className="bg-[var(--accent-color)] flex-shrink-0 max-w-[50%] truncate">
|
||||
{video.sourceName}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Latency Badge - Right */}
|
||||
{video.latency !== undefined && (
|
||||
<LatencyBadge latency={video.latency} className="flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overlay - Show on hover (desktop) or when active (mobile) */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/60 transition-opacity duration-300 ${isActive ? 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100' : 'opacity-0 lg:group-hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
willChange: 'opacity',
|
||||
}}
|
||||
>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
{/* Mobile indicator when active */}
|
||||
{isActive && (
|
||||
<div className="lg:hidden text-white/90 text-xs mb-2 font-medium">
|
||||
再次点击播放 →
|
||||
</div>
|
||||
)}
|
||||
{video.type_name && (
|
||||
<Badge variant="secondary" className="text-xs mb-2">
|
||||
{video.type_name}
|
||||
</Badge>
|
||||
)}
|
||||
{video.vod_year && (
|
||||
<div className="flex items-center gap-1 text-white/80 text-xs">
|
||||
<Icons.Calendar size={12} />
|
||||
<span>{video.vod_year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info - Fixed height section */}
|
||||
<div className="p-3 flex-1 flex flex-col">
|
||||
<h4 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 min-h-[2.5rem]">
|
||||
{video.vod_name}
|
||||
</h4>
|
||||
{video.vod_remarks && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-1">
|
||||
{video.vod_remarks}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
VideoCard.displayName = 'VideoCard';
|
||||
|
||||
export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: VideoGridProps) {
|
||||
const [activeCardId, setActiveCardId] = useState<string | null>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(24);
|
||||
|
||||
Reference in New Issue
Block a user