mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 00:33:44 +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);
|
||||
|
||||
+3
-76
@@ -1,68 +1,17 @@
|
||||
/**
|
||||
* API Client for fetching video data from multiple sources
|
||||
* Handles parallel requests, timeouts, retries, and data normalization
|
||||
* Handles parallel requests and data normalization
|
||||
*/
|
||||
|
||||
import type {
|
||||
VideoSource,
|
||||
VideoItem,
|
||||
VideoDetail,
|
||||
Episode,
|
||||
ApiSearchResponse,
|
||||
ApiDetailResponse,
|
||||
} from '@/lib/types';
|
||||
|
||||
const REQUEST_TIMEOUT = 15000;
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000;
|
||||
|
||||
/**
|
||||
* Fetch with timeout support
|
||||
*/
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
timeout: number = REQUEST_TIMEOUT
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry logic wrapper
|
||||
*/
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
retries: number = MAX_RETRIES
|
||||
): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
if (i < retries) {
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
import { fetchWithTimeout, withRetry } from './http-utils';
|
||||
import { parseEpisodes } from './parsers';
|
||||
|
||||
/**
|
||||
* Search videos from a single source
|
||||
@@ -146,29 +95,7 @@ export async function searchVideos(
|
||||
return Promise.all(searchPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse episode URL string into structured array
|
||||
*/
|
||||
function parseEpisodes(playUrl: string): Episode[] {
|
||||
if (!playUrl) return [];
|
||||
|
||||
try {
|
||||
// Format: "Episode1$url1#Episode2$url2#..."
|
||||
const episodes = playUrl.split('#').filter(Boolean);
|
||||
|
||||
return episodes.map((episode, index) => {
|
||||
const [name, url] = episode.split('$');
|
||||
return {
|
||||
name: name || `Episode ${index + 1}`,
|
||||
url: url || '',
|
||||
index,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to parse episodes:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video detail from a single source
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* HTTP Utilities for API calls
|
||||
* Handles timeouts and retries
|
||||
*/
|
||||
|
||||
const REQUEST_TIMEOUT = 15000;
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000;
|
||||
|
||||
/**
|
||||
* Fetch with timeout support
|
||||
*/
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
timeout: number = REQUEST_TIMEOUT
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry logic wrapper
|
||||
*/
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
retries: number = MAX_RETRIES
|
||||
): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
if (i < retries) {
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* API Response Parsers
|
||||
*/
|
||||
|
||||
import type { Episode } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* Parse episode URL string into structured array
|
||||
*/
|
||||
export function parseEpisodes(playUrl: string): Episode[] {
|
||||
if (!playUrl) return [];
|
||||
|
||||
try {
|
||||
// Format: "Episode1$url1#Episode2$url2#..."
|
||||
const episodes = playUrl.split('#').filter(Boolean);
|
||||
|
||||
return episodes.map((episode, index) => {
|
||||
const [name, url] = episode.split('$');
|
||||
return {
|
||||
name: name || `Episode ${index + 1}`,
|
||||
url: url || '',
|
||||
index,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to parse episodes:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useRef, useCallback } from 'react';
|
||||
import { getSourceName, SOURCE_IDS } from '@/lib/utils/source-names';
|
||||
import { calculateRelevanceScore } from '@/lib/utils/search';
|
||||
import { sortVideos } from '@/lib/utils/sort';
|
||||
import { binaryInsertVideos } from '@/lib/utils/sorted-insert';
|
||||
import type { SortOption } from '@/lib/store/settings-store';
|
||||
|
||||
interface Video {
|
||||
@@ -119,44 +120,8 @@ export function useParallelSearch(
|
||||
}));
|
||||
|
||||
|
||||
|
||||
// Optimized: Insert new videos in sorted position instead of re-sorting entire array
|
||||
setResults((prev) => {
|
||||
if (prev.length === 0) return newVideos;
|
||||
|
||||
// Binary insert for better performance with combined sorting
|
||||
const combined = [...prev];
|
||||
for (const video of newVideos) {
|
||||
const relevanceScore = video.relevanceScore || 0;
|
||||
const latency = video.latency || 99999; // Default high latency for sorting
|
||||
|
||||
// Find insert position using binary search
|
||||
// Sort by: 1) relevance score (DESC), 2) latency (ASC)
|
||||
let left = 0;
|
||||
let right = combined.length;
|
||||
while (left < right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const midRelevance = combined[mid].relevanceScore || 0;
|
||||
const midLatency = combined[mid].latency || 99999;
|
||||
|
||||
// Compare by relevance first
|
||||
if (midRelevance > relevanceScore) {
|
||||
left = mid + 1;
|
||||
} else if (midRelevance < relevanceScore) {
|
||||
right = mid;
|
||||
} else {
|
||||
// Same relevance, compare by latency (lower is better)
|
||||
if (midLatency < latency) {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
combined.splice(left, 0, video);
|
||||
}
|
||||
return combined;
|
||||
});
|
||||
// Optimized: Insert new videos in sorted position
|
||||
setResults((prev) => binaryInsertVideos(prev, newVideos));
|
||||
|
||||
// Update source stats
|
||||
if (!sourcesMap.has(data.source)) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
import { DEFAULT_SOURCES } from '@/lib/api/default-sources';
|
||||
|
||||
export type SortOption =
|
||||
| 'default'
|
||||
@@ -36,49 +37,7 @@ export const sortOptions: Record<SortOption, string> = {
|
||||
'name-desc': '按名称(Z-A)',
|
||||
};
|
||||
|
||||
export const getDefaultSources = (): VideoSource[] => {
|
||||
// Import from video-sources to get all 38 default sources
|
||||
return [
|
||||
{ id: 'feifan', name: '非凡资源', baseUrl: 'http://ffzy5.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 1 },
|
||||
{ id: 'wolong', name: '卧龙资源', baseUrl: 'https://wolongzyw.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 2 },
|
||||
{ id: 'zuida', name: '最大资源', baseUrl: 'https://api.zuidapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 3 },
|
||||
{ id: 'baiduyun', name: '百度云资源', baseUrl: 'https://api.apibdzy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 4 },
|
||||
{ id: 'baofeng', name: '暴风资源', baseUrl: 'https://bfzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 5 },
|
||||
{ id: 'jisu', name: '极速资源', baseUrl: 'https://jszyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 6 },
|
||||
{ id: 'tianya', name: '天涯资源', baseUrl: 'https://tyyszy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 7 },
|
||||
{ id: 'wujin', name: '无尽资源', baseUrl: 'https://api.wujinapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 8 },
|
||||
{ id: 'modu', name: '魔都资源', baseUrl: 'https://www.mdzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 9 },
|
||||
{ id: 'sanliuling', name: '360资源', baseUrl: 'https://360zy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 10 },
|
||||
{ id: 'dytt', name: '电影天堂', baseUrl: 'http://caiji.dyttzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 11 },
|
||||
{ id: 'ruyi', name: '如意资源', baseUrl: 'https://cj.rycjapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 12 },
|
||||
{ id: 'wangwang', name: '旺旺资源', baseUrl: 'https://wwzy.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 13 },
|
||||
{ id: 'hongniu', name: '红牛资源', baseUrl: 'https://www.hongniuzy2.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 14 },
|
||||
{ id: 'guangsu', name: '光速资源', baseUrl: 'https://api.guangsuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 15 },
|
||||
{ id: 'ikun', name: 'iKun资源', baseUrl: 'https://ikunzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 16 },
|
||||
{ id: 'youku', name: '优酷资源', baseUrl: 'https://api.ukuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 17 },
|
||||
{ id: 'huya', name: '虎牙资源', baseUrl: 'https://www.huyaapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 18 },
|
||||
{ id: 'xinlang', name: '新浪资源', baseUrl: 'http://api.xinlangapi.com/xinlangapi.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 19 },
|
||||
{ id: 'lezi', name: '乐子资源', baseUrl: 'https://cj.lziapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 20 },
|
||||
{ id: 'haihua', name: '海豚资源', baseUrl: 'https://hhzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 21 },
|
||||
{ id: 'jiangyu', name: '鲸鱼资源', baseUrl: 'https://jyzyapi.com/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 22 },
|
||||
{ id: 'yilingba', name: '1080资源', baseUrl: 'https://api.1080zyku.com/inc/api_mac10.php', searchPath: '', detailPath: '', enabled: true, priority: 23 },
|
||||
{ id: 'aidan', name: '爱蛋资源', baseUrl: 'https://lovedan.net/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 24 },
|
||||
{ id: 'leba', name: '乐播资源', baseUrl: 'https://lbapi9.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 25 },
|
||||
{ id: 'moduzy', name: '魔都影视', baseUrl: 'https://www.moduzy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 26 },
|
||||
{ id: 'feifanapi', name: '非凡API', baseUrl: 'https://api.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 27 },
|
||||
{ id: 'feifancj', name: '非凡采集', baseUrl: 'http://cj.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 28 },
|
||||
{ id: 'feifancj2', name: '非凡采集HTTPS', baseUrl: 'https://cj.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 29 },
|
||||
{ id: 'feifan1', name: '非凡线路1', baseUrl: 'http://ffzy1.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 30 },
|
||||
{ id: 'wolong2', name: '卧龙采集', baseUrl: 'https://collect.wolongzyw.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 31 },
|
||||
{ id: 'baofeng2', name: '暴风APP', baseUrl: 'https://app.bfzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 32 },
|
||||
{ id: 'wujin2', name: '无尽ME', baseUrl: 'https://api.wujinapi.me/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 33 },
|
||||
{ id: 'tianyazy', name: '天涯海角', baseUrl: 'https://tyyszyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 34 },
|
||||
{ id: 'guangsu2', name: '光速HTTP', baseUrl: 'http://api.guangsuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 35 },
|
||||
{ id: 'xinlang2', name: '新浪HTTPS', baseUrl: 'https://api.xinlangapi.com/xinlangapi.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 36 },
|
||||
{ id: 'yilingba2', name: '1080JSON', baseUrl: 'https://api.1080zyku.com/inc/apijson.php', searchPath: '', detailPath: '', enabled: true, priority: 37 },
|
||||
{ id: 'lezi2', name: '乐子HTTP', baseUrl: 'http://cj.lziapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 38 },
|
||||
];
|
||||
};
|
||||
export const getDefaultSources = (): VideoSource[] => DEFAULT_SOURCES;
|
||||
|
||||
export const settingsStore = {
|
||||
getSettings(): AppSettings {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Binary insert utility for sorted arrays
|
||||
*/
|
||||
|
||||
interface Video {
|
||||
relevanceScore?: number;
|
||||
latency?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert videos into sorted array using binary search
|
||||
* Sorts by: 1) relevance score (DESC), 2) latency (ASC)
|
||||
*/
|
||||
export function binaryInsertVideos<T extends Video>(existing: T[], newVideos: T[]): T[] {
|
||||
if (existing.length === 0) return newVideos;
|
||||
|
||||
const combined = [...existing];
|
||||
|
||||
for (const video of newVideos) {
|
||||
const relevanceScore = video.relevanceScore || 0;
|
||||
const latency = video.latency || 99999;
|
||||
|
||||
// Find insert position using binary search
|
||||
let left = 0;
|
||||
let right = combined.length;
|
||||
|
||||
while (left < right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const midRelevance = combined[mid].relevanceScore || 0;
|
||||
const midLatency = combined[mid].latency || 99999;
|
||||
|
||||
if (midRelevance > relevanceScore) {
|
||||
left = mid + 1;
|
||||
} else if (midRelevance < relevanceScore) {
|
||||
right = mid;
|
||||
} else {
|
||||
// Same relevance, compare by latency
|
||||
if (midLatency < latency) {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
combined.splice(left, 0, video);
|
||||
}
|
||||
|
||||
return combined;
|
||||
}
|
||||
Reference in New Issue
Block a user