feat: Add video card component, desktop player controls, and introduce API utilities for search and data parsing.

This commit is contained in:
kuekhaoyang
2025-11-21 12:58:19 +08:00
parent 6e81559d90
commit a408f2270f
11 changed files with 499 additions and 450 deletions
+15 -145
View File
@@ -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>
);
}
+149
View File
@@ -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';
+3 -148
View File
@@ -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);