feat: Add custom video player component with controls, progress tracking, and volume management; enhance icon set with new playback and volume icons

This commit is contained in:
kuekhaoyang
2025-11-17 18:14:21 +08:00
parent df60e00c4e
commit bac33e5d55
4 changed files with 691 additions and 160 deletions
+89
View File
@@ -344,3 +344,92 @@ body.dark,
}
}
/* Custom Video Player Styles */
.spinner {
width: 48px;
height: 48px;
border: 5px solid color-mix(in srgb, var(--glass-bg) 50%, transparent);
border-top-color: var(--accent-color);
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
height: 2.5rem;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: var(--radius-2xl);
color: white;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-icon:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.btn-icon:active {
transform: scale(0.95);
}
.slider-track {
position: relative;
width: 100%;
height: 8px;
background: rgba(255, 255, 255, 0.3);
border-radius: var(--radius-full);
cursor: pointer;
overflow: visible;
user-select: none;
-webkit-user-select: none;
}
.slider-track.h-1 {
height: 4px;
}
.slider-range {
position: absolute;
left: 0;
top: 0;
height: 100%;
background-color: var(--accent-color);
border-radius: var(--radius-full);
pointer-events: none;
}
.slider-thumb {
position: absolute;
top: 50%;
width: 16px;
height: 16px;
background-color: white;
border-radius: var(--radius-full);
transform: translate(-50%, -50%);
cursor: grab;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
.slider-track.h-1 .slider-thumb {
width: 12px;
height: 12px;
}
.slider-track:hover .slider-thumb {
transform: translate(-50%, -50%) scale(1.2);
}
.slider-thumb:active,
.slider-track:active .slider-thumb {
transform: translate(-50%, -50%) scale(1.3);
cursor: grabbing;
}
+443
View File
@@ -0,0 +1,443 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import { Icons } from '@/components/ui/Icon';
interface CustomVideoPlayerProps {
src: string;
poster?: string;
onError?: (error: string) => void;
onTimeUpdate?: (currentTime: number, duration: number) => void;
initialTime?: number;
}
export function CustomVideoPlayer({
src,
poster,
onError,
onTimeUpdate,
initialTime = 0
}: CustomVideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const volumeBarRef = useRef<HTMLDivElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showControls, setShowControls] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [playbackRate, setPlaybackRate] = useState(1);
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isDraggingProgressRef = useRef(false);
const isDraggingVolumeRef = useRef(false);
// Auto-hide controls
useEffect(() => {
if (!isPlaying) return;
const hideControls = () => {
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
controlsTimeoutRef.current = setTimeout(() => {
if (isPlaying && !showSpeedMenu) {
setShowControls(false);
}
}, 3000);
};
hideControls();
return () => {
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
};
}, [isPlaying, showSpeedMenu]);
// Handle mouse movement to show controls
const handleMouseMove = () => {
setShowControls(true);
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
if (isPlaying && !showSpeedMenu) {
controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000);
}
};
// Play/Pause toggle
const togglePlay = () => {
if (!videoRef.current) return;
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
};
// Handle video events
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleTimeUpdateEvent = () => {
if (!videoRef.current || isDraggingProgressRef.current) return;
const current = videoRef.current.currentTime;
const total = videoRef.current.duration;
setCurrentTime(current);
setDuration(total);
if (onTimeUpdate) {
onTimeUpdate(current, total);
}
};
const handleLoadedMetadata = () => {
if (!videoRef.current) return;
setDuration(videoRef.current.duration);
setIsLoading(false);
// Set initial time if provided
if (initialTime > 0) {
videoRef.current.currentTime = initialTime;
}
};
const handleVideoError = () => {
setIsLoading(false);
if (onError) {
onError('Video failed to load');
}
};
// Progress bar seeking
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!videoRef.current || !progressBarRef.current) return;
const rect = progressBarRef.current.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
const newTime = pos * duration;
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
};
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
isDraggingProgressRef.current = true;
handleProgressClick(e);
};
useEffect(() => {
const handleProgressMouseMove = (e: MouseEvent) => {
if (!isDraggingProgressRef.current || !progressBarRef.current || !videoRef.current) return;
e.preventDefault();
const rect = progressBarRef.current.getBoundingClientRect();
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const newTime = pos * duration;
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
};
const handleMouseUp = () => {
if (isDraggingProgressRef.current) {
isDraggingProgressRef.current = false;
}
};
document.addEventListener('mousemove', handleProgressMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleProgressMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [duration]);
// Volume control
const toggleMute = () => {
if (!videoRef.current) return;
if (isMuted) {
videoRef.current.volume = volume;
setIsMuted(false);
} else {
videoRef.current.volume = 0;
setIsMuted(true);
}
};
const handleVolumeChange = (e: React.MouseEvent<HTMLDivElement>) => {
if (!videoRef.current || !volumeBarRef.current) return;
const rect = volumeBarRef.current.getBoundingClientRect();
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
setVolume(pos);
videoRef.current.volume = pos;
setIsMuted(pos === 0);
};
const handleVolumeMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
isDraggingVolumeRef.current = true;
handleVolumeChange(e);
};
useEffect(() => {
const handleVolumeMouseMove = (e: MouseEvent) => {
if (!isDraggingVolumeRef.current || !volumeBarRef.current || !videoRef.current) return;
e.preventDefault();
const rect = volumeBarRef.current.getBoundingClientRect();
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
setVolume(pos);
videoRef.current.volume = pos;
setIsMuted(pos === 0);
};
const handleMouseUp = () => {
if (isDraggingVolumeRef.current) {
isDraggingVolumeRef.current = false;
}
};
document.addEventListener('mousemove', handleVolumeMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleVolumeMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, []);
// Fullscreen
const toggleFullscreen = () => {
if (!containerRef.current) return;
if (!isFullscreen) {
if (containerRef.current.requestFullscreen) {
containerRef.current.requestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
};
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
// Playback speed
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2];
const changePlaybackSpeed = (speed: number) => {
if (!videoRef.current) return;
videoRef.current.playbackRate = speed;
setPlaybackRate(speed);
setShowSpeedMenu(false);
};
// Format time helper
const formatTime = (seconds: number) => {
if (isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<div
ref={containerRef}
className="relative aspect-video bg-black rounded-[var(--radius-2xl)] overflow-hidden group"
onMouseMove={handleMouseMove}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
{/* Video Element */}
<video
ref={videoRef}
className="w-full h-full object-contain"
src={src}
poster={poster}
onPlay={handlePlay}
onPause={handlePause}
onTimeUpdate={handleTimeUpdateEvent}
onLoadedMetadata={handleLoadedMetadata}
onError={handleVideoError}
onWaiting={() => setIsLoading(true)}
onCanPlay={() => setIsLoading(false)}
onClick={togglePlay}
/>
{/* Loading Spinner */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="spinner"></div>
</div>
)}
{/* Center Play Button (when paused) */}
{!isPlaying && !isLoading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<button
onClick={togglePlay}
className="pointer-events-auto w-20 h-20 rounded-full bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] flex items-center justify-center transition-all duration-300 hover:scale-110 hover:bg-[var(--accent-color)] shadow-[var(--shadow-md)]"
aria-label="Play"
>
<Icons.Play size={32} className="text-white ml-1" />
</button>
</div>
)}
{/* Custom Controls */}
<div
className={`absolute bottom-0 left-0 right-0 transition-all duration-300 ${
showControls ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
}`}
style={{ pointerEvents: showControls ? 'auto' : 'none' }}
>
{/* Progress Bar */}
<div className="px-4 pb-2">
<div
ref={progressBarRef}
className="slider-track cursor-pointer"
onClick={handleProgressClick}
onMouseDown={handleProgressMouseDown}
style={{ pointerEvents: 'auto' }}
>
<div
className="slider-range"
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
/>
<div
className="slider-thumb"
style={{ left: `${(currentTime / duration) * 100 || 0}%` }}
/>
</div>
</div>
{/* Controls Bar */}
<div className="bg-gradient-to-t from-black/90 via-black/70 to-transparent px-4 pb-4 pt-6">
<div className="flex items-center justify-between gap-4">
{/* Left Controls */}
<div className="flex items-center gap-3">
{/* Play/Pause */}
<button
onClick={togglePlay}
className="btn-icon"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Icons.Pause size={20} /> : <Icons.Play size={20} />}
</button>
{/* Volume */}
<div className="flex items-center gap-2 group/volume">
<button
onClick={toggleMute}
className="btn-icon"
aria-label={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted || volume === 0 ? (
<Icons.VolumeX size={20} />
) : volume < 0.5 ? (
<Icons.Volume1 size={20} />
) : (
<Icons.Volume2 size={20} />
)}
</button>
{/* Volume Bar */}
<div className="flex items-center gap-2 opacity-0 w-0 group-hover/volume:opacity-100 group-hover/volume:w-32 overflow-hidden transition-all duration-300">
<div
ref={volumeBarRef}
className="slider-track h-1 cursor-pointer flex-1"
onClick={handleVolumeChange}
onMouseDown={handleVolumeMouseDown}
>
<div
className="slider-range h-full"
style={{ width: `${isMuted ? 0 : volume * 100}%` }}
/>
<div
className="slider-thumb"
style={{ left: `${isMuted ? 0 : volume * 100}%` }}
/>
</div>
<span className="text-white text-xs font-medium tabular-nums min-w-[2rem]">
{Math.round((isMuted ? 0 : volume) * 100)}
</span>
</div>
</div>
{/* 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 */}
<div className="relative">
<button
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
className="btn-icon text-xs font-semibold min-w-[2.5rem]"
aria-label="Playback speed"
>
{playbackRate}x
</button>
{/* Speed Menu */}
{showSpeedMenu && (
<div className="absolute bottom-full right-0 mb-2 bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] p-2 min-w-[5rem]">
{speeds.map((speed) => (
<button
key={speed}
onClick={() => changePlaybackSpeed(speed)}
className={`w-full px-3 py-2 rounded-[var(--radius-2xl)] text-sm font-medium transition-colors ${
playbackRate === speed
? 'bg-[var(--accent-color)] text-white'
: 'text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)]'
}`}
>
{speed}x
</button>
))}
</div>
)}
</div>
{/* Fullscreen */}
<button
onClick={toggleFullscreen}
className="btn-icon"
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Icons.Minimize size={20} /> : <Icons.Maximize size={20} />}
</button>
</div>
</div>
</div>
</div>
</div>
);
}
+57 -160
View File
@@ -1,11 +1,12 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import { useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Icons } from '@/components/ui/Icon';
import { useHistoryStore } from '@/lib/store/history-store';
import { CustomVideoPlayer } from './CustomVideoPlayer';
interface VideoPlayerProps {
playUrl: string;
@@ -15,9 +16,7 @@ interface VideoPlayerProps {
}
export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [videoError, setVideoError] = useState<string>('');
const [isVideoLoading, setIsVideoLoading] = useState(false);
const searchParams = useSearchParams();
const { addToHistory } = useHistoryStore();
@@ -25,116 +24,37 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack }: VideoP
const source = searchParams.get('source') || '';
const title = searchParams.get('title') || '未知视频';
// Save progress to history periodically
useEffect(() => {
if (!videoRef.current || !videoId || !playUrl) return;
// Get saved progress for this video
const getSavedProgress = () => {
if (!videoId) return 0;
const savedTime = localStorage.getItem(`video_progress_${videoId}_${currentEpisode}`);
return savedTime ? parseFloat(savedTime) : 0;
};
const video = videoRef.current;
let lastSavedTime = 0;
// Handle time updates and save progress
const handleTimeUpdate = (currentTime: number, duration: number) => {
if (!videoId || !playUrl || duration === 0) return;
const updateProgress = () => {
if (video && video.duration > 0) {
const position = video.currentTime;
const duration = video.duration;
// Only save if we have meaningful progress (more than 1 second)
// and if at least 5 seconds have passed since last save
if (position > 1 && Math.abs(position - lastSavedTime) >= 5) {
lastSavedTime = position;
console.log(`[Watch History] Saving progress: ${position.toFixed(1)}s / ${duration.toFixed(1)}s`);
addToHistory(
videoId,
title,
playUrl,
currentEpisode,
source,
position,
duration,
undefined, // poster - updated from player page
[] // episodes - updated from player page
);
}
}
};
// Update progress on time update (throttled by the 5 second check)
const handleTimeUpdate = () => updateProgress();
// Also update on pause
const handlePause = () => {
if (video && video.duration > 0) {
console.log('[Watch History] Saving on pause');
updateProgress();
}
};
// Update when leaving the page
const handleBeforeUnload = () => {
if (video && video.duration > 0) {
console.log('[Watch History] Saving before unload');
updateProgress();
}
};
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('pause', handlePause);
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('pause', handlePause);
window.removeEventListener('beforeunload', handleBeforeUnload);
// Save progress one last time on unmount
if (video && video.duration > 0) {
console.log('[Watch History] Saving on unmount');
updateProgress();
}
};
}, [videoId, playUrl, currentEpisode, source, title, addToHistory]);
const handleVideoError = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
const video = e.currentTarget;
let errorMessage = 'Video playback failed';
if (video.error) {
switch (video.error.code) {
case MediaError.MEDIA_ERR_ABORTED:
errorMessage = 'Video loading was aborted';
break;
case MediaError.MEDIA_ERR_NETWORK:
errorMessage = 'Network error occurred while loading video';
break;
case MediaError.MEDIA_ERR_DECODE:
errorMessage = 'Video format is not supported or corrupted';
break;
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
errorMessage = 'Video source not supported or unavailable';
break;
default:
errorMessage = `Video error: ${video.error.message || 'Unknown error'}`;
}
// Save progress every few seconds
if (currentTime > 1) {
addToHistory(
videoId,
title,
playUrl,
currentEpisode,
source,
currentTime,
duration,
undefined,
[]
);
}
console.error('Video playback error:', errorMessage, video.error);
setVideoError(errorMessage);
setIsVideoLoading(false);
};
const handleVideoLoadStart = () => {
setIsVideoLoading(true);
setVideoError('');
};
const handleVideoCanPlay = () => {
setIsVideoLoading(false);
};
const handleRetry = () => {
setVideoError('');
if (videoRef.current) {
videoRef.current.load();
}
// Handle video errors
const handleVideoError = (error: string) => {
console.error('Video playback error:', error);
setVideoError(error);
};
if (!playUrl) {
@@ -152,63 +72,40 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack }: VideoP
return (
<Card hover={false} className="p-0 overflow-hidden">
<div className="relative aspect-video bg-black rounded-[var(--radius-2xl)] overflow-hidden">
{videoError && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-80 z-10 p-4">
<div className="text-center text-white max-w-md">
<Icons.AlertTriangle size={48} className="mx-auto mb-4 text-red-500" />
<p className="text-lg font-semibold mb-2"></p>
<p className="text-sm text-gray-300 mb-4">{videoError}</p>
<div className="flex gap-2 justify-center flex-wrap">
<Button
variant="primary"
onClick={handleRetry}
className="flex items-center gap-2"
>
<Icons.RefreshCw size={16} />
<span></span>
</Button>
<Button
variant="secondary"
onClick={onBack}
className="flex items-center gap-2"
>
<Icons.ChevronLeft size={16} />
<span></span>
</Button>
</div>
{videoError ? (
<div className="aspect-video bg-black rounded-[var(--radius-2xl)] flex items-center justify-center">
<div className="text-center text-white max-w-md px-4">
<Icons.AlertTriangle size={48} className="mx-auto mb-4 text-red-500" />
<p className="text-lg font-semibold mb-2"></p>
<p className="text-sm text-gray-300 mb-4">{videoError}</p>
<div className="flex gap-2 justify-center flex-wrap">
<Button
variant="primary"
onClick={() => setVideoError('')}
className="flex items-center gap-2"
>
<Icons.RefreshCw size={16} />
<span></span>
</Button>
<Button
variant="secondary"
onClick={onBack}
className="flex items-center gap-2"
>
<Icons.ChevronLeft size={16} />
<span></span>
</Button>
</div>
</div>
)}
{isVideoLoading && !videoError && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 z-10">
<div className="text-center text-white">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent mx-auto mb-2"></div>
<p className="text-sm">...</p>
</div>
</div>
)}
<video
ref={videoRef}
className="w-full h-full"
controls
autoPlay
</div>
) : (
<CustomVideoPlayer
src={playUrl}
onError={handleVideoError}
onLoadStart={handleVideoLoadStart}
onCanPlay={handleVideoCanPlay}
onLoadedMetadata={() => {
if (videoRef.current && videoId) {
const savedTime = localStorage.getItem(`video_progress_${videoId}_${currentEpisode}`);
if (savedTime) {
videoRef.current.currentTime = parseFloat(savedTime);
}
}
}}
onTimeUpdate={handleTimeUpdate}
initialTime={getSavedProgress()}
/>
</div>
)}
</Card>
);
}
+102
View File
@@ -44,6 +44,23 @@ export const Icons = {
</svg>
),
Pause: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect x="6" y="4" width="4" height="16"/>
<rect x="14" y="4" width="4" height="16"/>
</svg>
),
TV: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
@@ -261,6 +278,91 @@ export const Icons = {
</svg>
),
Volume2: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg>
),
Volume1: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
</svg>
),
VolumeX: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<line x1="23" y1="9" x2="17" y2="15"/>
<line x1="17" y1="9" x2="23" y2="15"/>
</svg>
),
Maximize: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
</svg>
),
Minimize: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/>
</svg>
),
Check: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}