feat: Introduce distinct mobile and desktop video player UI, controls, and state management, along with new styling and icon components.

This commit is contained in:
kuekhaoyang
2025-11-21 12:34:45 +08:00
parent d199c7aa6f
commit 36488e545c
31 changed files with 3634 additions and 3532 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,210 @@
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';
interface DesktopControlsProps {
showControls: boolean;
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
isMuted: boolean;
isFullscreen: boolean;
playbackRate: number;
showSpeedMenu: boolean;
showMoreMenu: boolean;
showVolumeBar: boolean;
isPiPSupported: boolean;
isAirPlaySupported: boolean;
progressBarRef: React.RefObject<HTMLDivElement | null>;
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;
onToggleFullscreen: () => void;
onTogglePictureInPicture: () => void;
onShowAirPlayMenu: () => void;
onToggleSpeedMenu: () => void;
onToggleMoreMenu: () => void;
onSpeedChange: (speed: number) => void;
onCopyLink: () => void;
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onProgressMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
onSpeedMenuMouseEnter: () => void;
onSpeedMenuMouseLeave: () => void;
onMoreMenuMouseEnter: () => void;
onMoreMenuMouseLeave: () => void;
formatTime: (seconds: number) => string;
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) {
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'
}`}
style={{ pointerEvents: showControls ? 'auto' : 'none' }}
>
{/* Progress Bar */}
<DesktopProgressBar
progressBarRef={progressBarRef}
currentTime={currentTime}
duration={duration}
onProgressClick={onProgressClick}
onProgressMouseDown={onProgressMouseDown}
/>
{/* 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>
</div>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import React from 'react';
import { Icons } from '@/components/ui/Icon';
interface DesktopMoreMenuProps {
showMoreMenu: boolean;
onToggleMoreMenu: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
onCopyLink: () => void;
}
export function DesktopMoreMenu({
showMoreMenu,
onToggleMoreMenu,
onMouseEnter,
onMouseLeave,
onCopyLink
}: DesktopMoreMenuProps) {
return (
<div className="relative">
<button
onClick={onToggleMoreMenu}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="btn-icon"
aria-label="More options"
title="更多选项"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>
</button>
{/* More Menu Dropdown */}
{showMoreMenu && (
<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-[180px]"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<button
onClick={onCopyLink}
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3"
>
<Icons.Link size={18} />
<span></span>
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,84 @@
import React from 'react';
import { Icons } from '@/components/ui/Icon';
interface DesktopOverlayProps {
isLoading: boolean;
isPlaying: boolean;
showSkipForwardIndicator: boolean;
showSkipBackwardIndicator: boolean;
skipForwardAmount: number;
skipBackwardAmount: number;
isSkipForwardAnimatingOut: boolean;
isSkipBackwardAnimatingOut: boolean;
showToast: boolean;
toastMessage: string | null;
onTogglePlay: () => void;
}
export function DesktopOverlay({
isLoading,
isPlaying,
showSkipForwardIndicator,
showSkipBackwardIndicator,
skipForwardAmount,
skipBackwardAmount,
isSkipForwardAnimatingOut,
isSkipBackwardAnimatingOut,
showToast,
toastMessage,
onTogglePlay
}: DesktopOverlayProps) {
return (
<>
{/* Loading Spinner */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="spinner"></div>
</div>
)}
{/* Skip Forward Indicator */}
{showSkipForwardIndicator && (
<div className="absolute top-1/2 right-12 -translate-y-1/2 pointer-events-none transition-all duration-300">
<div className={`text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] ${isSkipForwardAnimatingOut ? 'animate-scale-out' : 'animate-scale-in'
}`}>
+{skipForwardAmount}
</div>
</div>
)}
{/* Skip Backward Indicator */}
{showSkipBackwardIndicator && (
<div className="absolute top-1/2 left-12 -translate-y-1/2 pointer-events-none transition-all duration-300">
<div className={`text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] ${isSkipBackwardAnimatingOut ? 'animate-scale-out' : 'animate-scale-in'
}`}>
-{skipBackwardAmount}
</div>
</div>
)}
{/* Center Play Button (when paused) */}
{!isPlaying && !isLoading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<button
onClick={onTogglePlay}
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)] will-change-transform"
aria-label="Play"
>
<Icons.Play size={32} className="text-white ml-1" />
</button>
</div>
)}
{/* Toast Notification */}
{showToast && toastMessage && (
<div className="absolute bottom-24 left-1/2 -translate-x-1/2 z-[200] animate-slide-up">
<div className="bg-[rgba(28,28,30,0.95)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-white/20 shadow-[0_8px_32px_rgba(0,0,0,0.6)] px-6 py-3 flex items-center gap-3 min-w-[200px]">
<Icons.Check size={18} className="text-[#34c759] flex-shrink-0" />
<span className="text-white text-sm font-medium">{toastMessage}</span>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,39 @@
import React, { RefObject } from 'react';
interface DesktopProgressBarProps {
progressBarRef: RefObject<HTMLDivElement | null>;
currentTime: number;
duration: number;
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onProgressMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
}
export function DesktopProgressBar({
progressBarRef,
currentTime,
duration,
onProgressClick,
onProgressMouseDown
}: DesktopProgressBarProps) {
return (
<div className="px-4 pb-1">
<div
ref={progressBarRef}
className="slider-track cursor-pointer"
onClick={onProgressClick}
onMouseDown={onProgressMouseDown}
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>
);
}
@@ -0,0 +1,57 @@
import React from 'react';
interface DesktopSpeedMenuProps {
showSpeedMenu: boolean;
playbackRate: number;
speeds: number[];
onSpeedChange: (speed: number) => void;
onToggleSpeedMenu: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
export function DesktopSpeedMenu({
showSpeedMenu,
playbackRate,
speeds,
onSpeedChange,
onToggleSpeedMenu,
onMouseEnter,
onMouseLeave
}: DesktopSpeedMenuProps) {
return (
<div className="relative">
<button
onClick={onToggleSpeedMenu}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
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]"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{speeds.map((speed) => (
<button
key={speed}
onClick={() => onSpeedChange(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>
);
}
@@ -0,0 +1,66 @@
import React, { RefObject } from 'react';
import { Icons } from '@/components/ui/Icon';
interface DesktopVolumeControlProps {
volumeBarRef: RefObject<HTMLDivElement | null>;
volume: number;
isMuted: boolean;
showVolumeBar: boolean;
onToggleMute: () => void;
onVolumeChange: (e: React.MouseEvent<HTMLDivElement>) => void;
onVolumeMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
}
export function DesktopVolumeControl({
volumeBarRef,
volume,
isMuted,
showVolumeBar,
onToggleMute,
onVolumeChange,
onVolumeMouseDown
}: DesktopVolumeControlProps) {
return (
<div className="flex items-center gap-2 group/volume">
<button
onClick={onToggleMute}
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 overflow-hidden transition-all duration-300 ${showVolumeBar
? 'opacity-100 w-32'
: 'opacity-0 w-0 group-hover/volume:opacity-100 group-hover/volume:w-32'
}`}>
<div
ref={volumeBarRef}
className="slider-track h-1 cursor-pointer flex-1"
onClick={onVolumeChange}
onMouseDown={onVolumeMouseDown}
>
<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>
);
}
@@ -0,0 +1,555 @@
import { useEffect, useCallback } from 'react';
interface UseDesktopPlayerLogicProps {
src: string;
initialTime: number;
onError?: (error: string) => void;
onTimeUpdate?: (currentTime: number, duration: number) => void;
refs: any;
state: any;
}
export function useDesktopPlayerLogic({
src,
initialTime,
onError,
onTimeUpdate,
refs,
state
}: UseDesktopPlayerLogicProps) {
const {
videoRef,
containerRef,
progressBarRef,
volumeBarRef,
controlsTimeoutRef,
speedMenuTimeoutRef,
skipForwardTimeoutRef,
skipBackwardTimeoutRef,
volumeBarTimeoutRef,
isDraggingProgressRef,
isDraggingVolumeRef,
mouseMoveThrottleRef,
toastTimeoutRef,
moreMenuTimeoutRef
} = refs;
const {
isPlaying, setIsPlaying,
currentTime, setCurrentTime,
duration, setDuration,
volume, setVolume,
isMuted, setIsMuted,
isFullscreen, setIsFullscreen,
showControls, setShowControls,
setIsLoading,
playbackRate, setPlaybackRate,
showSpeedMenu, setShowSpeedMenu,
isPiPSupported, setIsPiPSupported,
isAirPlaySupported, setIsAirPlaySupported,
skipForwardAmount, setSkipForwardAmount,
skipBackwardAmount, setSkipBackwardAmount,
showSkipForwardIndicator, setShowSkipForwardIndicator,
showSkipBackwardIndicator, setShowSkipBackwardIndicator,
setIsSkipForwardAnimatingOut,
setIsSkipBackwardAnimatingOut,
setShowVolumeBar,
setToastMessage,
setShowToast,
showMoreMenu, setShowMoreMenu
} = state;
// Check for PiP and AirPlay support
useEffect(() => {
if (typeof document !== 'undefined') {
setIsPiPSupported('pictureInPictureEnabled' in document);
}
if (typeof window !== 'undefined') {
setIsAirPlaySupported('WebKitPlaybackTargetAvailabilityEvent' in window);
}
}, [setIsPiPSupported, setIsAirPlaySupported]);
// 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, setShowControls, controlsTimeoutRef]);
const handleMouseMove = useCallback(() => {
if (mouseMoveThrottleRef.current) return;
mouseMoveThrottleRef.current = setTimeout(() => {
mouseMoveThrottleRef.current = null;
}, 200);
if (!showControls) {
setShowControls(true);
}
if (isPlaying && controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000);
}
}, [showControls, isPlaying, setShowControls, controlsTimeoutRef, mouseMoveThrottleRef]);
const togglePlay = useCallback(() => {
if (!videoRef.current) return;
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
}, [isPlaying, videoRef]);
const handlePlay = useCallback(() => setIsPlaying(true), [setIsPlaying]);
const handlePause = useCallback(() => setIsPlaying(false), [setIsPlaying]);
const handleTimeUpdateEvent = useCallback(() => {
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);
}
}, [videoRef, isDraggingProgressRef, setCurrentTime, setDuration, onTimeUpdate]);
const handleLoadedMetadata = useCallback(() => {
if (!videoRef.current) return;
setDuration(videoRef.current.duration);
setIsLoading(false);
if (initialTime > 0) {
videoRef.current.currentTime = initialTime;
}
videoRef.current.play().catch((err: Error) => {
console.warn('Autoplay was prevented:', err);
});
}, [videoRef, setDuration, setIsLoading, initialTime]);
const handleVideoError = useCallback(() => {
setIsLoading(false);
if (onError) {
onError('Video failed to load');
}
}, [setIsLoading, onError]);
const handleProgressClick = useCallback((e: any) => {
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);
}, [videoRef, progressBarRef, duration, setCurrentTime]);
const handleProgressMouseDown = useCallback((e: any) => {
e.preventDefault();
isDraggingProgressRef.current = true;
handleProgressClick(e);
}, [isDraggingProgressRef, handleProgressClick]);
// Mouse move/up listeners for progress bar
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, isDraggingProgressRef, progressBarRef, videoRef, setCurrentTime]);
const toggleMute = useCallback(() => {
if (!videoRef.current) return;
if (isMuted) {
videoRef.current.volume = volume;
setIsMuted(false);
} else {
videoRef.current.volume = 0;
setIsMuted(true);
}
}, [videoRef, isMuted, volume, setIsMuted]);
const showVolumeBarTemporarily = useCallback(() => {
setShowVolumeBar(true);
if (volumeBarTimeoutRef.current) {
clearTimeout(volumeBarTimeoutRef.current);
}
volumeBarTimeoutRef.current = setTimeout(() => {
setShowVolumeBar(false);
}, 1000);
}, [setShowVolumeBar, volumeBarTimeoutRef]);
const handleVolumeChange = useCallback((e: any) => {
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);
}, [videoRef, volumeBarRef, setVolume, setIsMuted]);
const handleVolumeMouseDown = useCallback((e: any) => {
e.preventDefault();
isDraggingVolumeRef.current = true;
handleVolumeChange(e);
}, [isDraggingVolumeRef, handleVolumeChange]);
// Mouse move/up listeners for volume bar
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);
};
}, [isDraggingVolumeRef, volumeBarRef, videoRef, setVolume, setIsMuted]);
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
if (!isFullscreen) {
if (containerRef.current.requestFullscreen) {
containerRef.current.requestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
}, [containerRef, isFullscreen]);
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, [setIsFullscreen]);
const togglePictureInPicture = useCallback(async () => {
if (!videoRef.current || !isPiPSupported) return;
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else {
await videoRef.current.requestPictureInPicture();
}
} catch (error) {
console.error('Failed to toggle Picture-in-Picture:', error);
}
}, [videoRef, isPiPSupported]);
const showAirPlayMenu = useCallback(() => {
if (!videoRef.current || !isAirPlaySupported) return;
const video = videoRef.current as any;
if (video.webkitShowPlaybackTargetPicker) {
video.webkitShowPlaybackTargetPicker();
}
}, [videoRef, isAirPlaySupported]);
const skipForward = useCallback(() => {
if (!videoRef.current) return;
setShowSkipBackwardIndicator(false);
setSkipBackwardAmount(0);
setIsSkipBackwardAnimatingOut(false);
if (skipBackwardTimeoutRef.current) {
clearTimeout(skipBackwardTimeoutRef.current);
}
if (skipForwardTimeoutRef.current) {
clearTimeout(skipForwardTimeoutRef.current);
}
const newSkipAmount = showSkipForwardIndicator ? skipForwardAmount + 10 : 10;
setSkipForwardAmount(newSkipAmount);
setShowSkipForwardIndicator(true);
setIsSkipForwardAnimatingOut(false);
const targetTime = Math.min(videoRef.current.currentTime + 10, duration);
videoRef.current.currentTime = targetTime;
setCurrentTime(targetTime);
skipForwardTimeoutRef.current = setTimeout(() => {
setIsSkipForwardAnimatingOut(true);
setTimeout(() => {
setShowSkipForwardIndicator(false);
setSkipForwardAmount(0);
setIsSkipForwardAnimatingOut(false);
}, 200);
}, 800);
}, [videoRef, duration, showSkipForwardIndicator, skipForwardAmount, skipBackwardTimeoutRef, skipForwardTimeoutRef, setShowSkipBackwardIndicator, setSkipBackwardAmount, setIsSkipBackwardAnimatingOut, setSkipForwardAmount, setShowSkipForwardIndicator, setIsSkipForwardAnimatingOut, setCurrentTime]);
const skipBackward = useCallback(() => {
if (!videoRef.current) return;
setShowSkipForwardIndicator(false);
setSkipForwardAmount(0);
setIsSkipForwardAnimatingOut(false);
if (skipForwardTimeoutRef.current) {
clearTimeout(skipForwardTimeoutRef.current);
}
if (skipBackwardTimeoutRef.current) {
clearTimeout(skipBackwardTimeoutRef.current);
}
const newSkipAmount = showSkipBackwardIndicator ? skipBackwardAmount + 10 : 10;
setSkipBackwardAmount(newSkipAmount);
setShowSkipBackwardIndicator(true);
setIsSkipBackwardAnimatingOut(false);
const targetTime = Math.max(videoRef.current.currentTime - 10, 0);
videoRef.current.currentTime = targetTime;
setCurrentTime(targetTime);
skipBackwardTimeoutRef.current = setTimeout(() => {
setIsSkipBackwardAnimatingOut(true);
setTimeout(() => {
setShowSkipBackwardIndicator(false);
setSkipBackwardAmount(0);
setIsSkipBackwardAnimatingOut(false);
}, 200);
}, 800);
}, [videoRef, showSkipBackwardIndicator, skipBackwardAmount, skipForwardTimeoutRef, skipBackwardTimeoutRef, setShowSkipForwardIndicator, setSkipForwardAmount, setIsSkipForwardAnimatingOut, setSkipBackwardAmount, setShowSkipBackwardIndicator, setIsSkipBackwardAnimatingOut, setCurrentTime]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
const shortcuts = [' ', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'f', 'F', 'm', 'M', 'i', 'I', '<', '>', ',', '.'];
if (shortcuts.includes(e.key)) {
e.preventDefault();
setShowControls(true);
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
if (isPlaying) {
controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000);
}
}
switch (e.key) {
case ' ':
togglePlay();
break;
case 'ArrowLeft':
case '<':
case ',':
skipBackward();
break;
case 'ArrowRight':
case '>':
case '.':
skipForward();
break;
case 'm':
case 'M':
toggleMute();
showVolumeBarTemporarily();
break;
case 'ArrowUp':
if (videoRef.current) {
const newVolume = Math.min(1, volume + 0.05);
setVolume(newVolume);
videoRef.current.volume = newVolume;
setIsMuted(newVolume === 0);
showVolumeBarTemporarily();
}
break;
case 'ArrowDown':
if (videoRef.current) {
const newVolume = Math.max(0, volume - 0.05);
setVolume(newVolume);
videoRef.current.volume = newVolume;
setIsMuted(newVolume === 0);
showVolumeBarTemporarily();
}
break;
case 'f':
case 'F':
toggleFullscreen();
break;
case 'i':
case 'I':
if (isPiPSupported) {
togglePictureInPicture();
}
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isPlaying, volume, isMuted, isPiPSupported, togglePlay, toggleMute, toggleFullscreen, togglePictureInPicture, skipForward, skipBackward, showVolumeBarTemporarily, setShowControls, controlsTimeoutRef, videoRef, setVolume, setIsMuted]);
const changePlaybackSpeed = useCallback((speed: number) => {
if (!videoRef.current) return;
videoRef.current.playbackRate = speed;
setPlaybackRate(speed);
setShowSpeedMenu(false);
if (speedMenuTimeoutRef.current) {
clearTimeout(speedMenuTimeoutRef.current);
}
}, [videoRef, setPlaybackRate, setShowSpeedMenu, speedMenuTimeoutRef]);
const showToastNotification = useCallback((message: string) => {
setToastMessage(message);
setShowToast(true);
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
toastTimeoutRef.current = setTimeout(() => {
setShowToast(false);
setTimeout(() => setToastMessage(null), 300);
}, 3000);
}, [setToastMessage, setShowToast, toastTimeoutRef]);
const handleCopyLink = useCallback(async () => {
try {
await navigator.clipboard.writeText(src);
showToastNotification('链接已复制到剪贴板');
} catch (error) {
console.error('Copy failed:', error);
showToastNotification('复制失败,请重试');
}
}, [src, showToastNotification]);
const startSpeedMenuTimeout = useCallback(() => {
if (speedMenuTimeoutRef.current) {
clearTimeout(speedMenuTimeoutRef.current);
}
speedMenuTimeoutRef.current = setTimeout(() => {
setShowSpeedMenu(false);
}, 1500);
}, [speedMenuTimeoutRef, setShowSpeedMenu]);
const clearSpeedMenuTimeout = useCallback(() => {
if (speedMenuTimeoutRef.current) {
clearTimeout(speedMenuTimeoutRef.current);
}
}, [speedMenuTimeoutRef]);
useEffect(() => {
if (showSpeedMenu) {
startSpeedMenuTimeout();
} else {
clearSpeedMenuTimeout();
}
return () => clearSpeedMenuTimeout();
}, [showSpeedMenu, startSpeedMenuTimeout, clearSpeedMenuTimeout]);
const formatTime = useCallback((seconds: number) => {
if (isNaN(seconds)) return '0:00:00';
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}, []);
return {
handleMouseMove,
togglePlay,
handlePlay,
handlePause,
handleTimeUpdateEvent,
handleLoadedMetadata,
handleVideoError,
handleProgressClick,
handleProgressMouseDown,
toggleMute,
showVolumeBarTemporarily,
handleVolumeChange,
handleVolumeMouseDown,
toggleFullscreen,
togglePictureInPicture,
showAirPlayMenu,
skipForward,
skipBackward,
changePlaybackSpeed,
handleCopyLink,
startSpeedMenuTimeout,
clearSpeedMenuTimeout,
formatTime
};
}
@@ -0,0 +1,87 @@
import { useState, useRef } from 'react';
export function useDesktopPlayerState() {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const volumeBarRef = useRef<HTMLDivElement>(null);
// Refs for timeouts and tracking
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const speedMenuTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const skipForwardTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const skipBackwardTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const volumeBarTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isDraggingProgressRef = useRef(false);
const isDraggingVolumeRef = useRef(false);
const mouseMoveThrottleRef = useRef<NodeJS.Timeout | null>(null);
const toastTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const moreMenuTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// State
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 [isPiPSupported, setIsPiPSupported] = useState(false);
const [isAirPlaySupported, setIsAirPlaySupported] = useState(false);
const [skipForwardAmount, setSkipForwardAmount] = useState(0);
const [skipBackwardAmount, setSkipBackwardAmount] = useState(0);
const [showSkipForwardIndicator, setShowSkipForwardIndicator] = useState(false);
const [showSkipBackwardIndicator, setShowSkipBackwardIndicator] = useState(false);
const [isSkipForwardAnimatingOut, setIsSkipForwardAnimatingOut] = useState(false);
const [isSkipBackwardAnimatingOut, setIsSkipBackwardAnimatingOut] = useState(false);
const [showVolumeBar, setShowVolumeBar] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [showToast, setShowToast] = useState(false);
const [showMoreMenu, setShowMoreMenu] = useState(false);
return {
refs: {
videoRef,
containerRef,
progressBarRef,
volumeBarRef,
controlsTimeoutRef,
speedMenuTimeoutRef,
skipForwardTimeoutRef,
skipBackwardTimeoutRef,
volumeBarTimeoutRef,
isDraggingProgressRef,
isDraggingVolumeRef,
mouseMoveThrottleRef,
toastTimeoutRef,
moreMenuTimeoutRef
},
state: {
isPlaying, setIsPlaying,
currentTime, setCurrentTime,
duration, setDuration,
volume, setVolume,
isMuted, setIsMuted,
isFullscreen, setIsFullscreen,
showControls, setShowControls,
isLoading, setIsLoading,
playbackRate, setPlaybackRate,
showSpeedMenu, setShowSpeedMenu,
isPiPSupported, setIsPiPSupported,
isAirPlaySupported, setIsAirPlaySupported,
skipForwardAmount, setSkipForwardAmount,
skipBackwardAmount, setSkipBackwardAmount,
showSkipForwardIndicator, setShowSkipForwardIndicator,
showSkipBackwardIndicator, setShowSkipBackwardIndicator,
isSkipForwardAnimatingOut, setIsSkipForwardAnimatingOut,
isSkipBackwardAnimatingOut, setIsSkipBackwardAnimatingOut,
showVolumeBar, setShowVolumeBar,
toastMessage, setToastMessage,
showToast, setShowToast,
showMoreMenu, setShowMoreMenu
}
};
}
@@ -0,0 +1,442 @@
import { useEffect, useCallback } from 'react';
import { useIsIOS } from '@/lib/hooks/useMobilePlayer';
interface UseMobilePlayerLogicProps {
src: string;
poster?: string;
initialTime: number;
onError?: (error: string) => void;
onTimeUpdate?: (currentTime: number, duration: number) => void;
refs: any;
state: any;
}
export function useMobilePlayerLogic({
src,
initialTime,
onError,
onTimeUpdate,
refs,
state
}: UseMobilePlayerLogicProps) {
const {
videoRef,
containerRef,
progressBarRef,
controlsTimeoutRef,
skipTimeoutRef,
isDraggingProgressRef,
menuIdleTimeoutRef,
isTogglingRef,
toastTimeoutRef
} = refs;
const {
isPlaying, setIsPlaying,
currentTime, setCurrentTime,
duration, setDuration,
volume, setVolume,
isMuted, setIsMuted,
isFullscreen, setIsFullscreen,
setShowControls,
setIsLoading,
setPlaybackRate,
showSpeedMenu, setShowSpeedMenu,
showVolumeMenu, setShowVolumeMenu,
showMoreMenu, setShowMoreMenu,
isPiPSupported, setIsPiPSupported,
skipAmount, setSkipAmount,
skipSide, setSkipSide,
setShowSkipIndicator,
wasPlayingBeforeMenu, setWasPlayingBeforeMenu,
setToastMessage,
setShowToast,
setViewportWidth
} = state;
const isIOS = useIsIOS();
// Screen orientation management
// Note: This was originally a hook call in the component.
// We can't call hooks conditionally or inside callbacks, so we assume the component calls useScreenOrientation separately if needed,
// or we move it here if it's a top-level hook.
// Since useScreenOrientation is a hook, we should call it in the component, not here if this is just a logic function.
// But this IS a hook (useMobilePlayerLogic), so we can call other hooks.
// However, useScreenOrientation takes isFullscreen as an argument.
// Check for PiP support
useEffect(() => {
if (typeof document !== 'undefined') {
setIsPiPSupported('pictureInPictureEnabled' in document);
}
}, [setIsPiPSupported]);
// Track viewport width
useEffect(() => {
const updateViewportWidth = () => {
setViewportWidth(window.innerWidth);
};
updateViewportWidth();
window.addEventListener('resize', updateViewportWidth);
return () => window.removeEventListener('resize', updateViewportWidth);
}, [setViewportWidth]);
// Skip forward/backward
const skipVideo = useCallback((seconds: number, side: 'left' | 'right') => {
if (!videoRef.current) return;
if (skipTimeoutRef.current) {
clearTimeout(skipTimeoutRef.current);
}
const newSkipAmount = skipSide === side ? skipAmount + Math.abs(seconds) : Math.abs(seconds);
setSkipAmount(newSkipAmount);
setSkipSide(side);
setShowSkipIndicator(true);
const targetTime = side === 'left'
? Math.max(videoRef.current.currentTime - Math.abs(seconds), 0)
: Math.min(videoRef.current.currentTime + Math.abs(seconds), duration);
videoRef.current.currentTime = targetTime;
setCurrentTime(targetTime);
skipTimeoutRef.current = setTimeout(() => {
setShowSkipIndicator(false);
setSkipAmount(0);
setSkipSide(null);
}, 1500);
}, [duration, skipAmount, skipSide, videoRef, skipTimeoutRef, setSkipAmount, setSkipSide, setShowSkipIndicator, setCurrentTime]);
const togglePlay = useCallback(async () => {
if (!videoRef.current || isTogglingRef.current) return;
isTogglingRef.current = true;
try {
if (isPlaying) {
videoRef.current.pause();
} else {
setShowMoreMenu(false);
setShowVolumeMenu(false);
setShowSpeedMenu(false);
await videoRef.current.play();
}
} catch (error) {
console.warn('Play/pause error:', error);
} finally {
isTogglingRef.current = false;
}
}, [isPlaying, videoRef, isTogglingRef, setShowMoreMenu, setShowVolumeMenu, setShowSpeedMenu]);
const handlePlay = useCallback(() => setIsPlaying(true), [setIsPlaying]);
const handlePause = useCallback(() => setIsPlaying(false), [setIsPlaying]);
const handleTimeUpdateEvent = useCallback(() => {
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);
}
}, [videoRef, isDraggingProgressRef, setCurrentTime, setDuration, onTimeUpdate]);
const handleLoadedMetadata = useCallback(() => {
if (!videoRef.current) return;
setDuration(videoRef.current.duration);
setIsLoading(false);
if (initialTime > 0) {
videoRef.current.currentTime = initialTime;
}
videoRef.current.play().catch((err: Error) => {
console.warn('Autoplay was prevented:', err);
});
}, [videoRef, setDuration, setIsLoading, initialTime]);
const handleVideoError = useCallback(() => {
setIsLoading(false);
if (onError) {
onError('Video failed to load');
}
}, [setIsLoading, onError]);
const updateProgressFromEvent = useCallback((e: any) => {
if (!videoRef.current || !progressBarRef.current) return;
const rect = progressBarRef.current.getBoundingClientRect();
let clientX: number;
if ('touches' in e) {
const touch = e.touches[0] || e.changedTouches?.[0];
if (!touch) return;
clientX = touch.clientX;
} else {
clientX = e.clientX;
}
const pos = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
return pos * duration;
}, [videoRef, progressBarRef, duration]);
const handleProgressTouchStart = useCallback((e: any) => {
isDraggingProgressRef.current = true;
const newTime = updateProgressFromEvent(e);
if (newTime !== undefined) {
setCurrentTime(newTime);
}
}, [isDraggingProgressRef, updateProgressFromEvent, setCurrentTime]);
const handleProgressTouchMove = useCallback((e: any) => {
if (!isDraggingProgressRef.current) return;
e.preventDefault();
const newTime = updateProgressFromEvent(e);
if (newTime !== undefined) {
setCurrentTime(newTime);
if (videoRef.current) {
videoRef.current.currentTime = newTime;
}
}
}, [isDraggingProgressRef, updateProgressFromEvent, setCurrentTime, videoRef]);
const handleProgressTouchEnd = useCallback((e: any) => {
if (!isDraggingProgressRef.current) return;
isDraggingProgressRef.current = false;
const newTime = updateProgressFromEvent(e);
if (newTime !== undefined && videoRef.current) {
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
}
}, [isDraggingProgressRef, updateProgressFromEvent, videoRef, setCurrentTime]);
const handleProgressClick = useCallback((e: any) => {
const newTime = updateProgressFromEvent(e);
if (newTime !== undefined && videoRef.current) {
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
}
}, [updateProgressFromEvent, videoRef, setCurrentTime]);
const toggleMute = useCallback(() => {
if (!videoRef.current) return;
if (isMuted) {
videoRef.current.volume = volume;
setIsMuted(false);
} else {
videoRef.current.volume = 0;
setIsMuted(true);
}
}, [videoRef, isMuted, volume, setIsMuted]);
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
if (!isFullscreen) {
if (isIOS && videoRef.current && (videoRef.current as any).webkitEnterFullscreen) {
(videoRef.current as any).webkitEnterFullscreen();
return;
}
if (containerRef.current.requestFullscreen) {
containerRef.current.requestFullscreen().catch((err: Error) => console.warn('Fullscreen request failed:', err));
} else if ((containerRef.current as any).webkitRequestFullscreen) {
(containerRef.current as any).webkitRequestFullscreen();
} else if ((containerRef.current as any).webkitRequestFullScreen) {
(containerRef.current as any).webkitRequestFullScreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen().catch((err: Error) => console.warn('Exit fullscreen failed:', err));
} else if ((document as any).webkitExitFullscreen) {
(document as any).webkitExitFullscreen();
} else if ((document as any).webkitCancelFullScreen) {
(document as any).webkitCancelFullScreen();
}
}
}, [containerRef, isFullscreen, isIOS, videoRef]);
// Fullscreen change listener
useEffect(() => {
const handleFullscreenChange = () => {
const isInFullscreen = !!(
document.fullscreenElement ||
(document as any).webkitFullscreenElement ||
(document as any).webkitCurrentFullScreenElement
);
setIsFullscreen(isInFullscreen);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
document.removeEventListener('MSFullscreenChange', handleFullscreenChange);
};
}, [setIsFullscreen]);
const togglePictureInPicture = useCallback(async () => {
if (!videoRef.current || !isPiPSupported) return;
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else {
await videoRef.current.requestPictureInPicture();
}
} catch (error) {
console.error('Failed to toggle Picture-in-Picture:', error);
}
}, [videoRef, isPiPSupported]);
const changePlaybackSpeed = useCallback((speed: number) => {
if (!videoRef.current) return;
videoRef.current.playbackRate = speed;
setPlaybackRate(speed);
setShowSpeedMenu(false);
}, [videoRef, setPlaybackRate, setShowSpeedMenu]);
const showToastNotification = useCallback((message: string) => {
setToastMessage(message);
setShowToast(true);
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
toastTimeoutRef.current = setTimeout(() => {
setShowToast(false);
setTimeout(() => setToastMessage(null), 300);
}, 3000);
}, [setToastMessage, setShowToast, toastTimeoutRef]);
const handleCopyLink = useCallback(async () => {
try {
await navigator.clipboard.writeText(src);
showToastNotification('链接已复制到剪贴板');
} catch (error) {
console.error('Copy failed:', error);
showToastNotification('复制失败,请重试');
}
}, [src, showToastNotification]);
const formatTime = useCallback((seconds: number) => {
if (isNaN(seconds)) return '0:00:00';
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}, []);
// Auto-hide controls
useEffect(() => {
if (!isPlaying) {
setShowControls(true);
if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
return;
}
const hideControls = () => {
if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
controlsTimeoutRef.current = setTimeout(() => {
if (isPlaying) {
setShowControls(false);
setShowSpeedMenu(false);
setShowVolumeMenu(false);
setShowMoreMenu(false);
}
}, 3000);
};
hideControls();
return () => {
if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
};
}, [isPlaying, setShowControls, setShowSpeedMenu, setShowVolumeMenu, setShowMoreMenu, controlsTimeoutRef]);
// Auto-close menus
useEffect(() => {
if (showMoreMenu) {
if (videoRef.current && isPlaying) {
setWasPlayingBeforeMenu(true);
videoRef.current.pause();
}
if (menuIdleTimeoutRef.current) clearTimeout(menuIdleTimeoutRef.current);
menuIdleTimeoutRef.current = setTimeout(() => {
setShowMoreMenu(false);
if (wasPlayingBeforeMenu && videoRef.current) {
videoRef.current.play().catch((err: Error) => console.warn('Resume play error:', err));
setWasPlayingBeforeMenu(false);
}
}, 2000);
}
return () => {
if (menuIdleTimeoutRef.current) clearTimeout(menuIdleTimeoutRef.current);
};
}, [showMoreMenu, isPlaying, wasPlayingBeforeMenu, videoRef, menuIdleTimeoutRef, setShowMoreMenu, setWasPlayingBeforeMenu]);
// Pause on submenu open
useEffect(() => {
if (showVolumeMenu || showSpeedMenu) {
if (videoRef.current && isPlaying) {
setWasPlayingBeforeMenu(true);
videoRef.current.pause();
}
}
}, [showVolumeMenu, showSpeedMenu, isPlaying, videoRef, setWasPlayingBeforeMenu]);
// Click outside listener
useEffect(() => {
const handleClickOutside = (e: any) => {
const target = e.target as HTMLElement;
const isMenuClick = target.closest('.menu-container') || target.closest('[aria-label="更多"]');
if (!isMenuClick && (showMoreMenu || showVolumeMenu || showSpeedMenu)) {
setShowMoreMenu(false);
setShowVolumeMenu(false);
setShowSpeedMenu(false);
if (wasPlayingBeforeMenu && videoRef.current) {
videoRef.current.play().catch((err: Error) => console.warn('Resume play error:', err));
setWasPlayingBeforeMenu(false);
}
}
};
if (showMoreMenu || showVolumeMenu || showSpeedMenu) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchstart', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleClickOutside);
};
}, [showMoreMenu, showVolumeMenu, showSpeedMenu, wasPlayingBeforeMenu, videoRef, setShowMoreMenu, setShowVolumeMenu, setShowSpeedMenu, setWasPlayingBeforeMenu]);
return {
skipVideo,
togglePlay,
handlePlay,
handlePause,
handleTimeUpdateEvent,
handleLoadedMetadata,
handleVideoError,
handleProgressTouchStart,
handleProgressTouchMove,
handleProgressTouchEnd,
handleProgressClick,
toggleMute,
toggleFullscreen,
togglePictureInPicture,
changePlaybackSpeed,
showToastNotification,
handleCopyLink,
formatTime
};
}
@@ -0,0 +1,75 @@
import { useState, useRef } from 'react';
export function useMobilePlayerState() {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
// Refs for timeouts and tracking
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const skipTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isDraggingProgressRef = useRef(false);
const menuIdleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const submenuIdleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isTogglingRef = useRef(false);
const toastTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// State
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 [showVolumeMenu, setShowVolumeMenu] = useState(false);
const [showMoreMenu, setShowMoreMenu] = useState(false);
const [isPiPSupported, setIsPiPSupported] = useState(false);
const [skipAmount, setSkipAmount] = useState(0);
const [skipSide, setSkipSide] = useState<'left' | 'right' | null>(null);
const [showSkipIndicator, setShowSkipIndicator] = useState(false);
const [wasPlayingBeforeMenu, setWasPlayingBeforeMenu] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [showToast, setShowToast] = useState(false);
const [viewportWidth, setViewportWidth] = useState(0);
return {
refs: {
videoRef,
containerRef,
progressBarRef,
controlsTimeoutRef,
skipTimeoutRef,
isDraggingProgressRef,
menuIdleTimeoutRef,
submenuIdleTimeoutRef,
isTogglingRef,
toastTimeoutRef
},
state: {
isPlaying, setIsPlaying,
currentTime, setCurrentTime,
duration, setDuration,
volume, setVolume,
isMuted, setIsMuted,
isFullscreen, setIsFullscreen,
showControls, setShowControls,
isLoading, setIsLoading,
playbackRate, setPlaybackRate,
showSpeedMenu, setShowSpeedMenu,
showVolumeMenu, setShowVolumeMenu,
showMoreMenu, setShowMoreMenu,
isPiPSupported, setIsPiPSupported,
skipAmount, setSkipAmount,
skipSide, setSkipSide,
showSkipIndicator, setShowSkipIndicator,
wasPlayingBeforeMenu, setWasPlayingBeforeMenu,
toastMessage, setToastMessage,
showToast, setShowToast,
viewportWidth, setViewportWidth
}
};
}
+340
View File
@@ -0,0 +1,340 @@
import React from 'react';
import { Icons } from '@/components/ui/Icon';
import { MobileProgressBar } from './MobileProgressBar';
import { MobileVolumeMenu } from './MobileVolumeMenu';
import { MobileSpeedMenu } from './MobileSpeedMenu';
import { MobileMoreMenu } from './MobileMoreMenu';
interface MobileControlsProps {
showControls: boolean;
isCompactLayout: boolean;
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
isMuted: boolean;
isFullscreen: boolean;
playbackRate: number;
showMoreMenu: boolean;
showVolumeMenu: boolean;
showSpeedMenu: boolean;
isPiPSupported: boolean;
progressBarRef: React.RefObject<HTMLDivElement | null>;
onTogglePlay: () => void;
onSkipVideo: (seconds: number, side: 'left' | 'right') => void;
onToggleMute: () => void;
onToggleFullscreen: () => void;
onToggleMoreMenu: () => void;
onToggleVolumeMenu: () => void;
onToggleSpeedMenu: () => void;
onTogglePiP: () => void;
onVolumeChange: (volume: number) => void;
onSpeedChange: (speed: number) => void;
onCopyLink: () => void;
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onProgressTouchStart: (e: React.TouchEvent<HTMLDivElement>) => void;
onProgressTouchMove: (e: React.TouchEvent<HTMLDivElement>) => void;
onProgressTouchEnd: (e: React.TouchEvent<HTMLDivElement>) => void;
formatTime: (seconds: number) => string;
speeds: number[];
}
export function MobileControls({
showControls,
isCompactLayout,
isPlaying,
currentTime,
duration,
volume,
isMuted,
isFullscreen,
playbackRate,
showMoreMenu,
showVolumeMenu,
showSpeedMenu,
isPiPSupported,
progressBarRef,
onTogglePlay,
onSkipVideo,
onToggleMute,
onToggleFullscreen,
onToggleMoreMenu,
onToggleVolumeMenu,
onToggleSpeedMenu,
onTogglePiP,
onVolumeChange,
onSpeedChange,
onCopyLink,
onProgressClick,
onProgressTouchStart,
onProgressTouchMove,
onProgressTouchEnd,
formatTime,
speeds
}: MobileControlsProps) {
const iconSize = isCompactLayout ? 20 : 22;
const buttonPadding = isCompactLayout ? 'p-2' : 'p-2.5';
const controlsGap = isCompactLayout ? 'gap-2' : 'gap-3';
const textSize = isCompactLayout ? 'text-xs' : 'text-sm';
const controlsPadding = isCompactLayout ? 'px-3 pb-3' : 'px-4 pb-4';
return (
<div
className={`absolute bottom-0 left-0 right-0 z-50 transition-all duration-300 ${showControls ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
}`}
style={{ pointerEvents: showControls ? 'auto' : 'none' }}
>
{/* Progress Bar */}
<MobileProgressBar
progressBarRef={progressBarRef}
currentTime={currentTime}
duration={duration}
onProgressClick={onProgressClick}
onProgressTouchStart={onProgressTouchStart}
onProgressTouchMove={onProgressTouchMove}
onProgressTouchEnd={onProgressTouchEnd}
/>
{/* Controls Bar */}
<div className={`bg-gradient-to-t from-black/90 via-black/70 to-transparent ${controlsPadding} pt-2`}>
{isCompactLayout ? (
// Compact Layout
<div className={`flex items-center justify-between ${controlsGap}`}>
<div className={`flex items-center ${controlsGap} min-w-0`}>
<button
onClick={(e) => {
e.stopPropagation();
onTogglePlay();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isPlaying ? 'Pause' : 'Play'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isPlaying ? <Icons.Pause size={iconSize} /> : <Icons.Play size={iconSize} />}
</button>
<span className={`text-white ${textSize} font-medium tabular-nums whitespace-nowrap`}>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className={`flex items-center ${controlsGap} flex-shrink-0`}>
<div className="relative z-[60]">
<button
onClick={(e) => {
e.stopPropagation();
onToggleMoreMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="更多"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>
</button>
<MobileMoreMenu
showMoreMenu={showMoreMenu}
isMuted={isMuted}
volume={volume}
playbackRate={playbackRate}
isPiPSupported={isPiPSupported}
onCopyLink={() => {
onToggleMoreMenu();
onCopyLink();
}}
onToggleVolumeMenu={() => {
onToggleMoreMenu();
onToggleVolumeMenu();
}}
onToggleSpeedMenu={() => {
onToggleMoreMenu();
onToggleSpeedMenu();
}}
onTogglePiP={() => {
onToggleMoreMenu();
onTogglePiP();
}}
/>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onToggleFullscreen();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isFullscreen ? '退出全屏' : '全屏'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isFullscreen ? <Icons.Minimize size={iconSize} /> : <Icons.Maximize size={iconSize} />}
</button>
</div>
</div>
) : (
// Full Layout
<div className={`flex items-center ${controlsGap}`}>
<div className={`flex items-center ${controlsGap}`}>
<button
onClick={(e) => {
e.stopPropagation();
onTogglePlay();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isPlaying ? 'Pause' : 'Play'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isPlaying ? <Icons.Pause size={iconSize} /> : <Icons.Play size={iconSize} />}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onSkipVideo(10, 'left');
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="后退 10 秒"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.SkipBack size={iconSize} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onSkipVideo(10, 'right');
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="前进 10 秒"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.SkipForward size={iconSize} />
</button>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
onToggleVolumeMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="音量"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isMuted || volume === 0 ? <Icons.VolumeX size={iconSize} /> : <Icons.Volume2 size={iconSize} />}
</button>
<MobileVolumeMenu
showVolumeMenu={showVolumeMenu}
isCompactLayout={false}
isMuted={isMuted}
volume={volume}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onClose={onToggleVolumeMenu}
/>
</div>
<span className={`text-white ${textSize} font-medium tabular-nums whitespace-nowrap`}>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className="flex-1" />
<div className={`flex items-center ${controlsGap} flex-shrink-0`}>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
onToggleSpeedMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="播放速度"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<span className={`text-white ${textSize} font-medium`}>{playbackRate}x</span>
</button>
<MobileSpeedMenu
showSpeedMenu={showSpeedMenu}
isCompactLayout={false}
playbackRate={playbackRate}
speeds={speeds}
onSpeedChange={onSpeedChange}
onClose={onToggleSpeedMenu}
/>
</div>
{isPiPSupported && (
<button
onClick={(e) => {
e.stopPropagation();
onTogglePiP();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="画中画"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.PictureInPicture size={iconSize} />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
onToggleMoreMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="更多"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onToggleFullscreen();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isFullscreen ? '退出全屏' : '全屏'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isFullscreen ? <Icons.Minimize size={iconSize} /> : <Icons.Maximize size={iconSize} />}
</button>
</div>
</div>
)}
{/* Compact Layout Submenus */}
<MobileVolumeMenu
showVolumeMenu={showVolumeMenu}
isCompactLayout={true}
isMuted={isMuted}
volume={volume}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onClose={onToggleVolumeMenu}
/>
<MobileSpeedMenu
showSpeedMenu={showSpeedMenu}
isCompactLayout={true}
playbackRate={playbackRate}
speeds={speeds}
onSpeedChange={onSpeedChange}
onClose={onToggleSpeedMenu}
/>
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import React from 'react';
import { Icons } from '@/components/ui/Icon';
interface MobileMoreMenuProps {
showMoreMenu: boolean;
isMuted: boolean;
volume: number;
playbackRate: number;
isPiPSupported: boolean;
onCopyLink: () => void;
onToggleVolumeMenu: () => void;
onToggleSpeedMenu: () => void;
onTogglePiP: () => void;
}
export function MobileMoreMenu({
showMoreMenu,
isMuted,
volume,
playbackRate,
isPiPSupported,
onCopyLink,
onToggleVolumeMenu,
onToggleSpeedMenu,
onTogglePiP
}: MobileMoreMenuProps) {
if (!showMoreMenu) return null;
return (
<div className="absolute bottom-full right-0 mb-2 min-w-[160px] z-[100] menu-container">
<div className="bg-[rgba(255,255,255,0.1)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-[rgba(255,255,255,0.2)] shadow-[0_8px_32px_rgba(0,0,0,0.4)] overflow-hidden">
{/* Copy Link Option */}
<button
onClick={(e) => {
e.stopPropagation();
onCopyLink();
}}
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.Link size={18} />
<span></span>
</button>
<div className="h-px bg-white/10 my-1" />
{/* Volume Option */}
<button
onClick={(e) => {
e.stopPropagation();
onToggleVolumeMenu();
}}
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isMuted || volume === 0 ? <Icons.VolumeX size={18} /> : <Icons.Volume2 size={18} />}
<span></span>
</button>
{/* Speed Option */}
<button
onClick={(e) => {
e.stopPropagation();
onToggleSpeedMenu();
}}
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span> {playbackRate}x</span>
</button>
{/* PiP Option */}
{isPiPSupported && (
<button
onClick={(e) => {
e.stopPropagation();
onTogglePiP();
}}
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.PictureInPicture size={18} />
<span></span>
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,35 @@
import React from 'react';
import { Icons } from '@/components/ui/Icon';
interface MobileOverlayProps {
isLoading: boolean;
showToast: boolean;
toastMessage: string | null;
}
export function MobileOverlay({
isLoading,
showToast,
toastMessage
}: MobileOverlayProps) {
return (
<>
{/* Loading Spinner */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="spinner"></div>
</div>
)}
{/* Toast Notification */}
{showToast && toastMessage && (
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-[200] animate-slide-up" style={{ transform: 'translate(-50%, 0) translateZ(0)' }}>
<div className="bg-[rgba(28,28,30,0.95)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-white/20 shadow-[0_8px_32px_rgba(0,0,0,0.6)] px-6 py-3 flex items-center gap-3 min-w-[200px] max-w-[90vw]">
<Icons.Check size={18} className="text-[#34c759] flex-shrink-0" />
<span className="text-white text-sm font-medium">{toastMessage}</span>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,42 @@
import React, { RefObject } from 'react';
interface MobileProgressBarProps {
progressBarRef: RefObject<HTMLDivElement | null>;
currentTime: number;
duration: number;
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onProgressTouchStart: (e: React.TouchEvent<HTMLDivElement>) => void;
onProgressTouchMove: (e: React.TouchEvent<HTMLDivElement>) => void;
onProgressTouchEnd: (e: React.TouchEvent<HTMLDivElement>) => void;
}
export function MobileProgressBar({
progressBarRef,
currentTime,
duration,
onProgressClick,
onProgressTouchStart,
onProgressTouchMove,
onProgressTouchEnd
}: MobileProgressBarProps) {
return (
<div className="px-4 pb-2">
<div
ref={progressBarRef}
className="h-1 bg-white/30 rounded-full cursor-pointer"
onClick={onProgressClick}
onTouchStart={onProgressTouchStart}
onTouchMove={onProgressTouchMove}
onTouchEnd={onProgressTouchEnd}
>
<div
className="h-full bg-[var(--accent-color)] rounded-full relative"
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
>
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-lg" />
</div>
</div>
</div>
);
}
@@ -0,0 +1,23 @@
import React from 'react';
interface MobileSkipIndicatorProps {
showSkipIndicator: boolean;
skipSide: 'left' | 'right' | null;
skipAmount: number;
}
export function MobileSkipIndicator({
showSkipIndicator,
skipSide,
skipAmount
}: MobileSkipIndicatorProps) {
if (!showSkipIndicator || !skipSide) return null;
return (
<div className={`absolute top-1/2 -translate-y-1/2 pointer-events-none ${skipSide === 'left' ? 'left-8' : 'right-8'}`}>
<div className="text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] animate-scale-in">
{skipSide === 'left' ? '-' : '+'}{skipAmount}
</div>
</div>
);
}
@@ -0,0 +1,73 @@
import React from 'react';
interface MobileSpeedMenuProps {
showSpeedMenu: boolean;
isCompactLayout: boolean;
playbackRate: number;
speeds: number[];
onSpeedChange: (speed: number) => void;
onClose: () => void;
}
export function MobileSpeedMenu({
showSpeedMenu,
isCompactLayout,
playbackRate,
speeds,
onSpeedChange,
onClose
}: MobileSpeedMenuProps) {
if (!showSpeedMenu) return null;
if (isCompactLayout) {
return (
<div className="mt-3 pt-3 border-t border-white/20 menu-container">
<div className="flex gap-2 flex-wrap">
{speeds.map((speed) => (
<button
key={speed}
onClick={(e) => {
e.stopPropagation();
onSpeedChange(speed);
}}
className={`px-3 py-1.5 rounded-[var(--radius-full)] text-xs font-medium transition-colors ${playbackRate === speed
? 'bg-[var(--accent-color)] text-white'
: 'bg-white/20 text-white hover:bg-white/30'
}`}
>
{speed}x
</button>
))}
</div>
<button
onClick={onClose}
className="text-white/60 hover:text-white text-xs mt-2"
>
</button>
</div>
);
}
return (
<div className="absolute bottom-full right-0 mb-2 z-[100] menu-container">
<div className="bg-[rgba(255,255,255,0.1)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-[rgba(255,255,255,0.2)] shadow-[0_8px_32px_rgba(0,0,0,0.4)] p-2 flex gap-2">
{speeds.map((speed) => (
<button
key={speed}
onClick={(e) => {
e.stopPropagation();
onSpeedChange(speed);
}}
className={`px-3 py-1.5 rounded-[var(--radius-full)] text-xs font-medium transition-colors whitespace-nowrap ${playbackRate === speed
? 'bg-[var(--accent-color)] text-white'
: 'bg-white/20 text-white hover:bg-white/30'
}`}
>
{speed}x
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,89 @@
import React from 'react';
import { Icons } from '@/components/ui/Icon';
interface MobileVolumeMenuProps {
showVolumeMenu: boolean;
isCompactLayout: boolean;
isMuted: boolean;
volume: number;
onToggleMute: () => void;
onVolumeChange: (volume: number) => void;
onClose: () => void;
}
export function MobileVolumeMenu({
showVolumeMenu,
isCompactLayout,
isMuted,
volume,
onToggleMute,
onVolumeChange,
onClose
}: MobileVolumeMenuProps) {
if (!showVolumeMenu) return null;
if (isCompactLayout) {
return (
<div className="mt-3 pt-3 border-t border-white/20 menu-container">
<div className="flex items-center gap-3">
<button onClick={onToggleMute} className="btn-icon p-2">
{isMuted || volume === 0 ? <Icons.VolumeX size={18} /> : <Icons.Volume2 size={18} />}
</button>
<div className="flex-1">
<input
type="range"
min="0"
max="100"
value={isMuted ? 0 : volume * 100}
onChange={(e) => onVolumeChange(parseFloat(e.target.value) / 100)}
className="w-full h-1 bg-white/30 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
/>
</div>
<span className="text-white text-xs font-medium tabular-nums min-w-[2rem]">
{Math.round((isMuted ? 0 : volume) * 100)}
</span>
<button
onClick={onClose}
className="text-white/60 hover:text-white text-xs"
>
</button>
</div>
</div>
);
}
return (
<div className="absolute bottom-full left-0 mb-2 z-[100] menu-container">
<div className="bg-[rgba(255,255,255,0.1)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-[rgba(255,255,255,0.2)] shadow-[0_8px_32px_rgba(0,0,0,0.4)] p-3 flex flex-col items-center gap-2 min-w-[48px]">
<button
onClick={(e) => {
e.stopPropagation();
onToggleMute();
}}
className="btn-icon p-1.5"
>
{isMuted || volume === 0 ? <Icons.VolumeX size={18} /> : <Icons.Volume2 size={18} />}
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={(e) => {
e.stopPropagation();
onVolumeChange(parseFloat(e.target.value));
}}
className="h-24 w-1 bg-white/30 rounded-full appearance-none cursor-pointer [writing-mode:vertical-lr] [direction:rtl] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
style={{ WebkitAppearance: 'slider-vertical' }}
/>
<span className="text-white text-xs font-medium tabular-nums">
{Math.round((isMuted ? 0 : volume) * 100)}
</span>
</div>
</div>
);
}
+14 -593
View File
@@ -1,596 +1,17 @@
export interface IconProps {
className?: string;
size?: number;
}
export type { IconProps } from './icons/types.tsx';
export { MediaIcons } from './icons/media-icons';
export { NavigationIcons } from './icons/navigation-icons';
export { UtilityIcons } from './icons/utility-icons';
// For backward compatibility, export a combined Icons object
import { MediaIcons } from './icons/media-icons';
import { NavigationIcons } from './icons/navigation-icons';
import { UtilityIcons } from './icons/utility-icons';
export const Icons = {
// Video & Media
Film: ({ 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="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
<line x1="7" y1="2" x2="7" y2="22"/>
<line x1="17" y1="2" x2="17" y2="22"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<line x1="2" y1="7" x2="7" y2="7"/>
<line x1="2" y1="17" x2="7" y2="17"/>
<line x1="17" y1="17" x2="22" y2="17"/>
<line x1="17" y1="7" x2="22" y2="7"/>
</svg>
),
Play: ({ 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="5 3 19 12 5 21 5 3"/>
</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}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect x="2" y="7" width="20" height="15" rx="2" ry="2"/>
<polyline points="17 2 12 7 7 2"/>
</svg>
),
// Search & Navigation
Search: ({ 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}
>
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
),
ChevronLeft: ({ 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}
>
<polyline points="15 18 9 12 15 6"/>
</svg>
),
ChevronDown: ({ 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}
>
<polyline points="6 9 12 15 18 9"/>
</svg>
),
// List & Organization
List: ({ 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}
>
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
),
// Features
Zap: ({ 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="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
</svg>
),
Target: ({ 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}
>
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="6"/>
<circle cx="12" cy="12" r="2"/>
</svg>
),
Sparkles: ({ 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="M12 3v18M5.2 8.2l13.6 7.6M18.8 8.2L5.2 15.8"/>
</svg>
),
// Info & Details
Calendar: ({ 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="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
),
Globe: ({ 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}
>
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
),
// Empty States
Inbox: ({ 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}
>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/>
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>
</svg>
),
// Alert & Status
AlertTriangle: ({ 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="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
),
RefreshCw: ({ 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}
>
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</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>
),
SkipForward: ({ 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="5 4 15 12 5 20 5 4"/>
<line x1="19" y1="5" x2="19" y2="19"/>
</svg>
),
SkipBack: ({ 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="19 20 9 12 19 4 19 20"/>
<line x1="5" y1="19" x2="5" y2="5"/>
</svg>
),
PictureInPicture: ({ 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="2" y="3" width="20" height="14" rx="2" ry="2"/>
<rect x="13" y="10" width="7" height="7" rx="1" ry="1"/>
</svg>
),
Airplay: ({ 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="M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1"/>
<polygon points="12 15 17 21 7 21 12 15"/>
</svg>
),
Check: ({ 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}
>
<polyline points="20 6 9 17 4 12"/>
</svg>
),
Tag: ({ 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="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/>
<line x1="7" y1="7" x2="7.01" y2="7"/>
</svg>
),
X: ({ 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}
>
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
),
Star: ({ 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="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
),
Clock: ({ 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}
>
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
),
History: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 -960 960 960"
fill="currentColor"
className={className}
>
<path d="M480-120q-138 0-240.5-91.5T122-440h82q14 104 92.5 172T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-101 88h110v80H120v-240h80v94q51-64 124.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z"/>
</svg>
),
Trash: ({ 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}
>
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
),
Download: ({ 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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
),
Link: ({ 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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
),
...MediaIcons,
...NavigationIcons,
...UtilityIcons,
};
+99
View File
@@ -0,0 +1,99 @@
import { IconProps } from './types';
export const MediaIcons = {
Film: ({ 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="2" y="2" width="20" height="20" rx="2.18" ry="2.18" />
<line x1="7" y1="2" x2="7" y2="22" />
<line x1="17" y1="2" x2="17" y2="22" />
<line x1="2" y1="12" x2="22" y2="12" />
<line x1="2" y1="7" x2="7" y2="7" />
<line x1="2" y1="17" x2="7" y2="17" />
<line x1="17" y1="17" x2="22" y2="17" />
<line x1="17" y1="7" x2="22" y2="7" />
</svg>
),
Play: ({ 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="5 3 19 12 5 21 5 3" />
</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} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<rect x="2" y="7" width="20" height="15" rx="2" ry="2" />
<polyline points="17 2 12 7 7 2" />
</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>
),
SkipForward: ({ 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="5 4 15 12 5 20 5 4" />
<line x1="19" y1="5" x2="19" y2="19" />
</svg>
),
SkipBack: ({ 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="19 20 9 12 19 4 19 20" />
<line x1="5" y1="19" x2="5" y2="5" />
</svg>
),
PictureInPicture: ({ 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="2" y="3" width="20" height="14" rx="2" ry="2" />
<rect x="13" y="10" width="7" height="7" rx="1" ry="1" />
</svg>
),
Airplay: ({ 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="M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1" />
<polygon points="12 15 17 21 7 21 12 15" />
</svg>
),
};
+33
View File
@@ -0,0 +1,33 @@
import { IconProps } from './types';
export const NavigationIcons = {
Search: ({ 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}>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
),
ChevronLeft: ({ 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}>
<polyline points="15 18 9 12 15 6" />
</svg>
),
ChevronDown: ({ 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}>
<polyline points="6 9 12 15 18 9" />
</svg>
),
List: ({ 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}>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
),
};
+4
View File
@@ -0,0 +1,4 @@
export interface IconProps {
className?: string;
size?: number;
}
+124
View File
@@ -0,0 +1,124 @@
import { IconProps } from './types';
export const UtilityIcons = {
Zap: ({ 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="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
),
Target: ({ 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}>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
),
Sparkles: ({ 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="M12 3v18M5.2 8.2l13.6 7.6M18.8 8.2L5.2 15.8" />
</svg>
),
Calendar: ({ 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="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
),
Globe: ({ 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}>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
),
Inbox: ({ 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}>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg>
),
AlertTriangle: ({ 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="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
),
RefreshCw: ({ 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}>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
),
Check: ({ 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}>
<polyline points="20 6 9 17 4 12" />
</svg>
),
Tag: ({ 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="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</svg>
),
X: ({ 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}>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
),
Star: ({ 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="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
),
Clock: ({ 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}>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
),
History: ({ className = "", size = 24 }: IconProps) => (
<svg width={size} height={size} viewBox="0 -960 960 960" fill="currentColor" className={className}>
<path d="M480-120q-138 0-240.5-91.5T122-440h82q14 104 92.5 172T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-101 88h110v80H120v-240h80v94q51-64 124.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z" />
</svg>
),
Trash: ({ 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}>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
),
Download: ({ 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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
),
Link: ({ 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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
),
};