From 6e81559d90178180ea750d9ce6305adb49e89640 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Fri, 21 Nov 2025 12:47:40 +0800 Subject: [PATCH] refactor: extract player control logic into dedicated desktop and mobile hooks and introduce new mobile player control components. --- app/settings/README.md | 131 ---- app/styles/animations.css | 192 +----- app/styles/keyframes.css | 189 ++++++ .../hooks/desktop/useControlsVisibility.ts | 92 +++ .../hooks/desktop/useFullscreenControls.ts | 80 +++ .../hooks/desktop/useKeyboardShortcuts.ts | 112 ++++ .../hooks/desktop/usePlaybackControls.ts | 98 +++ .../hooks/desktop/useProgressControls.ts | 63 ++ .../player/hooks/desktop/useSkipControls.ts | 108 +++ .../player/hooks/desktop/useUtilities.ts | 44 ++ .../player/hooks/desktop/useVolumeControls.ts | 94 +++ .../mobile/useMobileFullscreenControls.ts | 97 +++ .../hooks/mobile/useMobileMenuControls.ts | 115 ++++ .../hooks/mobile/useMobilePlaybackControls.ts | 116 ++++ .../hooks/mobile/useMobileProgressControls.ts | 80 +++ .../hooks/mobile/useMobileSkipControls.ts | 55 ++ .../player/hooks/mobile/useMobileUtilities.ts | 77 +++ .../player/hooks/useDesktopPlayerLogic.ts | 627 ++++-------------- .../player/hooks/useMobilePlayerLogic.ts | 473 +++---------- components/player/mobile/CompactControls.tsx | 129 ++++ components/player/mobile/FullControls.tsx | 201 ++++++ components/player/mobile/MobileControls.tsx | 292 ++------ 22 files changed, 2018 insertions(+), 1447 deletions(-) delete mode 100644 app/settings/README.md create mode 100644 app/styles/keyframes.css create mode 100644 components/player/hooks/desktop/useControlsVisibility.ts create mode 100644 components/player/hooks/desktop/useFullscreenControls.ts create mode 100644 components/player/hooks/desktop/useKeyboardShortcuts.ts create mode 100644 components/player/hooks/desktop/usePlaybackControls.ts create mode 100644 components/player/hooks/desktop/useProgressControls.ts create mode 100644 components/player/hooks/desktop/useSkipControls.ts create mode 100644 components/player/hooks/desktop/useUtilities.ts create mode 100644 components/player/hooks/desktop/useVolumeControls.ts create mode 100644 components/player/hooks/mobile/useMobileFullscreenControls.ts create mode 100644 components/player/hooks/mobile/useMobileMenuControls.ts create mode 100644 components/player/hooks/mobile/useMobilePlaybackControls.ts create mode 100644 components/player/hooks/mobile/useMobileProgressControls.ts create mode 100644 components/player/hooks/mobile/useMobileSkipControls.ts create mode 100644 components/player/hooks/mobile/useMobileUtilities.ts create mode 100644 components/player/mobile/CompactControls.tsx create mode 100644 components/player/mobile/FullControls.tsx diff --git a/app/settings/README.md b/app/settings/README.md deleted file mode 100644 index 55f2b33..0000000 --- a/app/settings/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# Settings Feature Documentation - -## Overview -Comprehensive settings system for KVideo following the Liquid Glass design system principles. - -## Features - -### 1. Video Source Management -- **View all sources**: Display default and custom video sources with their URLs -- **Toggle sources**: Enable/disable sources with animated switches -- **Reorder sources**: Change priority using up/down arrows -- **Delete sources**: Remove custom sources (default sources can also be removed) -- **Add custom sources**: Add new video API sources with validation -- **Restore defaults**: One-click restore to default source configuration - -### 2. Search Result Sorting -Users can select how search results should be sorted: -- **默认排序** (Default): Original order -- **按相关性** (By Relevance): Most relevant results first -- **延迟低到高** (Latency: Low to High): Fastest responding sources first -- **发布时间(新到旧)** (Release Date: Newest First) -- **发布时间(旧到新)** (Release Date: Oldest First) -- **按评分(高到低)** (By Rating: High to Low) -- **按名称(A-Z)** (By Name: A-Z) -- **按名称(Z-A)** (By Name: Z-A) - -### 3. Data Management - -#### Export Settings -- Export app configuration to JSON file -- Options to include: - - Search history - - Watch history - - Source configuration (always included) -- Downloaded as timestamped JSON file - -#### Import Settings -- Import previously exported configuration -- Automatic validation -- Auto-refresh after successful import - -#### Reset All Data -- Clear all settings -- Clear search history -- Clear watch history -- Clear all cookies -- Clear all cache -- Restore to factory defaults - -## Components - -### Core Components -- `SourceManager.tsx` - Manage video sources with toggle, reorder, delete -- `AddSourceModal.tsx` - Modal dialog for adding custom sources -- `ExportModal.tsx` - Export settings with history options -- `ImportModal.tsx` - Import settings from JSON file -- `ConfirmDialog.tsx` - Reusable confirmation dialog (updated) - -### Store -- `settings-store.ts` - Settings state management and persistence - -## Design System Compliance - -All components strictly follow the Liquid Glass design system: - -### Visual Elements -- **Glass Effect**: `backdrop-blur-xl`, `saturate(180%)` -- **Border Radius**: Only `rounded-[var(--radius-2xl)]` and `rounded-[var(--radius-full)]` -- **Colors**: CSS variables for theme consistency -- **Shadows**: `shadow-[var(--shadow-sm)]` and `shadow-[var(--shadow-md)]` - -### Animations -- **Modal Entry/Exit**: Fade + scale transforms with cubic-bezier easing -- **Switch Toggle**: 0.4s fluid transition with `cubic-bezier(0.2, 0.8, 0.2, 1)` -- **Button Hover**: Smooth color transitions with brightness changes -- **List Items**: Staggered animations for visual hierarchy - -### Interactive Elements -- **Checkboxes**: Custom styled with smooth check animation -- **Switches**: Animated toggle with sliding thumb -- **Buttons**: Glass morphism with hover lift effects -- **Inputs**: Glass background with focus ring animation - -## Technical Implementation - -### State Management -```typescript -interface AppSettings { - sources: VideoSource[]; - sortBy: SortOption; - searchHistory: boolean; - watchHistory: boolean; -} -``` - -### Local Storage Keys -- `kvideo-settings` - Main settings object -- `kvideo-search-history` - Search history array -- `kvideo-watch-history` - Watch history array - -### Data Flow -1. Settings loaded from localStorage on mount -2. Changes immediately persisted to localStorage -3. Export creates downloadable JSON blob -4. Import validates and applies configuration -5. Reset clears all storage and reloads page - -## Accessibility - -- Semantic HTML structure -- ARIA labels on all interactive elements -- Keyboard navigation support -- Focus management in modals -- High contrast mode support -- Screen reader friendly - -## Browser Compatibility - -- Modern browsers with CSS backdrop-filter support -- LocalStorage API -- File API for export/import -- Cookie manipulation for reset - -## Future Enhancements - -- Cloud sync for settings across devices -- Source health monitoring -- Advanced filtering options -- Bulk source import from URL -- Settings backup scheduling -- Theme customization diff --git a/app/styles/animations.css b/app/styles/animations.css index bce3f62..3d9f472 100644 --- a/app/styles/animations.css +++ b/app/styles/animations.css @@ -1,195 +1,5 @@ -/* Keyframe Animations */ -@keyframes fade-in { - from { - opacity: 0; - transform: translateY(10px); - } +@import './keyframes.css'; - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes slide-up { - from { - opacity: 0; - transform: translateY(20px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes pulse { - - 0%, - 100% { - opacity: 1; - } - - 50% { - opacity: 0.5; - } -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -@keyframes spin-slow { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - -@keyframes spin-reverse { - from { - transform: rotate(360deg); - } - - to { - transform: rotate(0deg); - } -} - -@keyframes bounce-subtle { - - 0%, - 100% { - transform: translateY(0); - } - - 50% { - transform: translateY(-5px); - } -} - -@keyframes shimmer { - 0% { - transform: translateX(-100%); - } - - 100% { - transform: translateX(100%); - } -} - -@keyframes scale-in { - 0% { - opacity: 0; - transform: scale(0.9) translateY(10px); - } - - 100% { - opacity: 1; - transform: scale(1) translateY(0); - } -} - -@keyframes scale-out { - 0% { - opacity: 1; - transform: scale(1) translateY(0); - } - - 100% { - opacity: 0; - transform: scale(0.9) translateY(-10px); - } -} - -@keyframes float { - - 0%, - 100% { - transform: translateY(0) translateX(0); - opacity: 0.3; - } - - 50% { - transform: translateY(-20px) translateX(10px); - opacity: 0.8; - } -} - -@keyframes gradient-x { - - 0%, - 100% { - background-position: 0% 50%; - } - - 50% { - background-position: 100% 50%; - } -} - -@keyframes slideIn { - from { - opacity: 0; - transform: translateY(-10px) scale(0.95); - } - - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -@keyframes slideInRight { - from { - transform: translateX(100%); - } - - to { - transform: translateX(0); - } -} - -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(8px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes search-dropdown-appear { - from { - opacity: 0; - transform: translateY(-10px) scale(0.95); - } - - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -/* Animation Utility Classes */ .animate-fade-in { animation: fade-in 0.4s ease-out; } diff --git a/app/styles/keyframes.css b/app/styles/keyframes.css new file mode 100644 index 0000000..2b9a4aa --- /dev/null +++ b/app/styles/keyframes.css @@ -0,0 +1,189 @@ +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slide-up { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes spin-slow { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes spin-reverse { + from { + transform: rotate(360deg); + } + + to { + transform: rotate(0deg); + } +} + +@keyframes bounce-subtle { + + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-5px); + } +} + +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(100%); + } +} + +@keyframes scale-in { + 0% { + opacity: 0; + transform: scale(0.9) translateY(10px); + } + + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes scale-out { + 0% { + opacity: 1; + transform: scale(1) translateY(0); + } + + 100% { + opacity: 0; + transform: scale(0.9) translateY(-10px); + } +} + +@keyframes float { + + 0%, + 100% { + transform: translateY(0) translateX(0); + opacity: 0.3; + } + + 50% { + transform: translateY(-20px) translateX(10px); + opacity: 0.8; + } +} + +@keyframes gradient-x { + + 0%, + 100% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + } + + to { + transform: translateX(0); + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes search-dropdown-appear { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} \ No newline at end of file diff --git a/components/player/hooks/desktop/useControlsVisibility.ts b/components/player/hooks/desktop/useControlsVisibility.ts new file mode 100644 index 0000000..d97cddf --- /dev/null +++ b/components/player/hooks/desktop/useControlsVisibility.ts @@ -0,0 +1,92 @@ +import { useEffect, useCallback } from 'react'; + +interface UseControlsVisibilityProps { + isPlaying: boolean; + showControls: boolean; + showSpeedMenu: boolean; + setShowControls: (show: boolean) => void; + setShowSpeedMenu: (show: boolean) => void; + controlsTimeoutRef: React.MutableRefObject; + speedMenuTimeoutRef: React.MutableRefObject; + mouseMoveThrottleRef: React.MutableRefObject; +} + +export function useControlsVisibility({ + isPlaying, + showControls, + showSpeedMenu, + setShowControls, + setShowSpeedMenu, + controlsTimeoutRef, + speedMenuTimeoutRef, + mouseMoveThrottleRef +}: UseControlsVisibilityProps) { + 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 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]); + + return { + handleMouseMove, + startSpeedMenuTimeout, + clearSpeedMenuTimeout + }; +} diff --git a/components/player/hooks/desktop/useFullscreenControls.ts b/components/player/hooks/desktop/useFullscreenControls.ts new file mode 100644 index 0000000..56dde6a --- /dev/null +++ b/components/player/hooks/desktop/useFullscreenControls.ts @@ -0,0 +1,80 @@ +import { useCallback, useEffect } from 'react'; + +interface UseFullscreenControlsProps { + containerRef: React.RefObject; + videoRef: React.RefObject; + isFullscreen: boolean; + setIsFullscreen: (fullscreen: boolean) => void; + isPiPSupported: boolean; + isAirPlaySupported: boolean; + setIsPiPSupported: (supported: boolean) => void; + setIsAirPlaySupported: (supported: boolean) => void; +} + +export function useFullscreenControls({ + containerRef, + videoRef, + isFullscreen, + setIsFullscreen, + isPiPSupported, + isAirPlaySupported, + setIsPiPSupported, + setIsAirPlaySupported +}: UseFullscreenControlsProps) { + useEffect(() => { + if (typeof document !== 'undefined') { + setIsPiPSupported('pictureInPictureEnabled' in document); + } + if (typeof window !== 'undefined') { + setIsAirPlaySupported('WebKitPlaybackTargetAvailabilityEvent' in window); + } + }, [setIsPiPSupported, setIsAirPlaySupported]); + + 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]); + + return { + toggleFullscreen, + togglePictureInPicture, + showAirPlayMenu + }; +} diff --git a/components/player/hooks/desktop/useKeyboardShortcuts.ts b/components/player/hooks/desktop/useKeyboardShortcuts.ts new file mode 100644 index 0000000..346cf4b --- /dev/null +++ b/components/player/hooks/desktop/useKeyboardShortcuts.ts @@ -0,0 +1,112 @@ +import { useEffect } from 'react'; + +interface UseKeyboardShortcutsProps { + videoRef: React.RefObject; + isPlaying: boolean; + volume: number; + isPiPSupported: boolean; + togglePlay: () => void; + toggleMute: () => void; + toggleFullscreen: () => void; + togglePictureInPicture: () => void; + skipForward: () => void; + skipBackward: () => void; + showVolumeBarTemporarily: () => void; + setShowControls: (show: boolean) => void; + setVolume: (volume: number) => void; + setIsMuted: (muted: boolean) => void; + controlsTimeoutRef: React.MutableRefObject; +} + +export function useKeyboardShortcuts({ + videoRef, + isPlaying, + volume, + isPiPSupported, + togglePlay, + toggleMute, + toggleFullscreen, + togglePictureInPicture, + skipForward, + skipBackward, + showVolumeBarTemporarily, + setShowControls, + setVolume, + setIsMuted, + controlsTimeoutRef +}: UseKeyboardShortcutsProps) { + 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, isPiPSupported, togglePlay, toggleMute, toggleFullscreen, togglePictureInPicture, skipForward, skipBackward, showVolumeBarTemporarily, setShowControls, controlsTimeoutRef, videoRef, setVolume, setIsMuted]); +} diff --git a/components/player/hooks/desktop/usePlaybackControls.ts b/components/player/hooks/desktop/usePlaybackControls.ts new file mode 100644 index 0000000..8d0318f --- /dev/null +++ b/components/player/hooks/desktop/usePlaybackControls.ts @@ -0,0 +1,98 @@ +import { useCallback } from 'react'; + +interface UsePlaybackControlsProps { + videoRef: React.RefObject; + isPlaying: boolean; + setIsPlaying: (playing: boolean) => void; + setIsLoading: (loading: boolean) => void; + initialTime: number; + setDuration: (duration: number) => void; + setCurrentTime: (time: number) => void; + onTimeUpdate?: (currentTime: number, duration: number) => void; + onError?: (error: string) => void; + isDraggingProgressRef: React.MutableRefObject; +} + +export function usePlaybackControls({ + videoRef, + isPlaying, + setIsPlaying, + setIsLoading, + initialTime, + setDuration, + setCurrentTime, + onTimeUpdate, + onError, + isDraggingProgressRef +}: UsePlaybackControlsProps) { + 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 changePlaybackSpeed = useCallback((speed: number, speedMenuTimeoutRef: React.MutableRefObject, setPlaybackRate: (rate: number) => void, setShowSpeedMenu: (show: boolean) => void) => { + if (!videoRef.current) return; + videoRef.current.playbackRate = speed; + setPlaybackRate(speed); + setShowSpeedMenu(false); + if (speedMenuTimeoutRef.current) { + clearTimeout(speedMenuTimeoutRef.current); + } + }, [videoRef]); + + 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 { + togglePlay, + handlePlay, + handlePause, + handleTimeUpdateEvent, + handleLoadedMetadata, + handleVideoError, + changePlaybackSpeed, + formatTime + }; +} diff --git a/components/player/hooks/desktop/useProgressControls.ts b/components/player/hooks/desktop/useProgressControls.ts new file mode 100644 index 0000000..daf6a13 --- /dev/null +++ b/components/player/hooks/desktop/useProgressControls.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect } from 'react'; + +interface UseProgressControlsProps { + videoRef: React.RefObject; + progressBarRef: React.RefObject; + duration: number; + setCurrentTime: (time: number) => void; + isDraggingProgressRef: React.MutableRefObject; +} + +export function useProgressControls({ + videoRef, + progressBarRef, + duration, + setCurrentTime, + isDraggingProgressRef +}: UseProgressControlsProps) { + 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]); + + 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]); + + return { + handleProgressClick, + handleProgressMouseDown + }; +} diff --git a/components/player/hooks/desktop/useSkipControls.ts b/components/player/hooks/desktop/useSkipControls.ts new file mode 100644 index 0000000..07597cb --- /dev/null +++ b/components/player/hooks/desktop/useSkipControls.ts @@ -0,0 +1,108 @@ +import { useCallback } from 'react'; + +interface UseSkipControlsProps { + videoRef: React.RefObject; + duration: number; + setCurrentTime: (time: number) => void; + showSkipForwardIndicator: boolean; + showSkipBackwardIndicator: boolean; + skipForwardAmount: number; + skipBackwardAmount: number; + setShowSkipForwardIndicator: (show: boolean) => void; + setShowSkipBackwardIndicator: (show: boolean) => void; + setSkipForwardAmount: (amount: number) => void; + setSkipBackwardAmount: (amount: number) => void; + setIsSkipForwardAnimatingOut: (animating: boolean) => void; + setIsSkipBackwardAnimatingOut: (animating: boolean) => void; + skipForwardTimeoutRef: React.MutableRefObject; + skipBackwardTimeoutRef: React.MutableRefObject; +} + +export function useSkipControls({ + videoRef, + duration, + setCurrentTime, + showSkipForwardIndicator, + showSkipBackwardIndicator, + skipForwardAmount, + skipBackwardAmount, + setShowSkipForwardIndicator, + setShowSkipBackwardIndicator, + setSkipForwardAmount, + setSkipBackwardAmount, + setIsSkipForwardAnimatingOut, + setIsSkipBackwardAnimatingOut, + skipForwardTimeoutRef, + skipBackwardTimeoutRef +}: UseSkipControlsProps) { + 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]); + + return { + skipForward, + skipBackward + }; +} diff --git a/components/player/hooks/desktop/useUtilities.ts b/components/player/hooks/desktop/useUtilities.ts new file mode 100644 index 0000000..e7392b5 --- /dev/null +++ b/components/player/hooks/desktop/useUtilities.ts @@ -0,0 +1,44 @@ +import { useCallback } from 'react'; + +interface UseUtilitiesProps { + src: string; + setToastMessage: (message: string | null) => void; + setShowToast: (show: boolean) => void; + toastTimeoutRef: React.MutableRefObject; +} + +export function useUtilities({ + src, + setToastMessage, + setShowToast, + toastTimeoutRef +}: UseUtilitiesProps) { + 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]); + + return { + showToastNotification, + handleCopyLink + }; +} diff --git a/components/player/hooks/desktop/useVolumeControls.ts b/components/player/hooks/desktop/useVolumeControls.ts new file mode 100644 index 0000000..33146c6 --- /dev/null +++ b/components/player/hooks/desktop/useVolumeControls.ts @@ -0,0 +1,94 @@ +import { useCallback, useEffect } from 'react'; + +interface UseVolumeControlsProps { + videoRef: React.RefObject; + volumeBarRef: React.RefObject; + volume: number; + isMuted: boolean; + setVolume: (volume: number) => void; + setIsMuted: (muted: boolean) => void; + setShowVolumeBar: (show: boolean) => void; + volumeBarTimeoutRef: React.MutableRefObject; + isDraggingVolumeRef: React.MutableRefObject; +} + +export function useVolumeControls({ + videoRef, + volumeBarRef, + volume, + isMuted, + setVolume, + setIsMuted, + setShowVolumeBar, + volumeBarTimeoutRef, + isDraggingVolumeRef +}: UseVolumeControlsProps) { + 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]); + + 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]); + + return { + toggleMute, + showVolumeBarTemporarily, + handleVolumeChange, + handleVolumeMouseDown + }; +} diff --git a/components/player/hooks/mobile/useMobileFullscreenControls.ts b/components/player/hooks/mobile/useMobileFullscreenControls.ts new file mode 100644 index 0000000..b035b65 --- /dev/null +++ b/components/player/hooks/mobile/useMobileFullscreenControls.ts @@ -0,0 +1,97 @@ +import { useCallback, useEffect } from 'react'; +import { useIsIOS } from '@/lib/hooks/useMobilePlayer'; + +interface UseMobileFullscreenProps { + containerRef: React.RefObject; + videoRef: React.RefObject; + isFullscreen: boolean; + setIsFullscreen: (fullscreen: boolean) => void; + isPiPSupported: boolean; + setIsPiPSupported: (supported: boolean) => void; +} + +export function useMobileFullscreenControls({ + containerRef, + videoRef, + isFullscreen, + setIsFullscreen, + isPiPSupported, + setIsPiPSupported +}: UseMobileFullscreenProps) { + const isIOS = useIsIOS(); + + useEffect(() => { + if (typeof document !== 'undefined') { + setIsPiPSupported('pictureInPictureEnabled' in document); + } + }, [setIsPiPSupported]); + + 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]); + + 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]); + + return { + toggleFullscreen, + togglePictureInPicture + }; +} diff --git a/components/player/hooks/mobile/useMobileMenuControls.ts b/components/player/hooks/mobile/useMobileMenuControls.ts new file mode 100644 index 0000000..835a163 --- /dev/null +++ b/components/player/hooks/mobile/useMobileMenuControls.ts @@ -0,0 +1,115 @@ +import { useEffect } from 'react'; + +interface UseMobileMenuControlsProps { + videoRef: React.RefObject; + isPlaying: boolean; + showMoreMenu: boolean; + showVolumeMenu: boolean; + showSpeedMenu: boolean; + wasPlayingBeforeMenu: boolean; + setShowControls: (show: boolean) => void; + setShowMoreMenu: (show: boolean) => void; + setShowVolumeMenu: (show: boolean) => void; + setShowSpeedMenu: (show: boolean) => void; + setWasPlayingBeforeMenu: (was: boolean) => void; + controlsTimeoutRef: React.MutableRefObject; + menuIdleTimeoutRef: React.MutableRefObject; +} + +export function useMobileMenuControls({ + videoRef, + isPlaying, + showMoreMenu, + showVolumeMenu, + showSpeedMenu, + wasPlayingBeforeMenu, + setShowControls, + setShowMoreMenu, + setShowVolumeMenu, + setShowSpeedMenu, + setWasPlayingBeforeMenu, + controlsTimeoutRef, + menuIdleTimeoutRef +}: UseMobileMenuControlsProps) { + 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]); + + 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]); + + useEffect(() => { + if (showVolumeMenu || showSpeedMenu) { + if (videoRef.current && isPlaying) { + setWasPlayingBeforeMenu(true); + videoRef.current.pause(); + } + } + }, [showVolumeMenu, showSpeedMenu, isPlaying, videoRef, setWasPlayingBeforeMenu]); + + 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]); +} diff --git a/components/player/hooks/mobile/useMobilePlaybackControls.ts b/components/player/hooks/mobile/useMobilePlaybackControls.ts new file mode 100644 index 0000000..df29421 --- /dev/null +++ b/components/player/hooks/mobile/useMobilePlaybackControls.ts @@ -0,0 +1,116 @@ +import { useCallback, useEffect } from 'react'; + +interface UseMobilePlaybackProps { + videoRef: React.RefObject; + isPlaying: boolean; + setIsPlaying: (playing: boolean) => void; + setIsLoading: (loading: boolean) => void; + initialTime: number; + setDuration: (duration: number) => void; + setCurrentTime: (time: number) => void; + setPlaybackRate: (rate: number) => void; + setShowMoreMenu: (show: boolean) => void; + setShowVolumeMenu: (show: boolean) => void; + setShowSpeedMenu: (show: boolean) => void; + onTimeUpdate?: (currentTime: number, duration: number) => void; + onError?: (error: string) => void; + isDraggingProgressRef: React.MutableRefObject; + isTogglingRef: React.MutableRefObject; +} + +export function useMobilePlaybackControls({ + videoRef, + isPlaying, + setIsPlaying, + setIsLoading, + initialTime, + setDuration, + setCurrentTime, + setPlaybackRate, + setShowMoreMenu, + setShowVolumeMenu, + setShowSpeedMenu, + onTimeUpdate, + onError, + isDraggingProgressRef, + isTogglingRef +}: UseMobilePlaybackProps) { + 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 changePlaybackSpeed = useCallback((speed: number) => { + if (!videoRef.current) return; + videoRef.current.playbackRate = speed; + setPlaybackRate(speed); + setShowSpeedMenu(false); + }, [videoRef, setPlaybackRate, setShowSpeedMenu]); + + 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 { + togglePlay, + handlePlay, + handlePause, + handleTimeUpdateEvent, + handleLoadedMetadata, + handleVideoError, + changePlaybackSpeed, + formatTime + }; +} diff --git a/components/player/hooks/mobile/useMobileProgressControls.ts b/components/player/hooks/mobile/useMobileProgressControls.ts new file mode 100644 index 0000000..3935f1f --- /dev/null +++ b/components/player/hooks/mobile/useMobileProgressControls.ts @@ -0,0 +1,80 @@ +import { useCallback, useEffect } from 'react'; + +interface UseMobileProgressControlsProps { + videoRef: React.RefObject; + progressBarRef: React.RefObject; + duration: number; + setCurrentTime: (time: number) => void; + isDraggingProgressRef: React.MutableRefObject; +} + +export function useMobileProgressControls({ + videoRef, + progressBarRef, + duration, + setCurrentTime, + isDraggingProgressRef +}: UseMobileProgressControlsProps) { + 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]); + + return { + handleProgressTouchStart, + handleProgressTouchMove, + handleProgressTouchEnd, + handleProgressClick + }; +} diff --git a/components/player/hooks/mobile/useMobileSkipControls.ts b/components/player/hooks/mobile/useMobileSkipControls.ts new file mode 100644 index 0000000..eac1e10 --- /dev/null +++ b/components/player/hooks/mobile/useMobileSkipControls.ts @@ -0,0 +1,55 @@ +import { useCallback } from 'react'; + +interface UseMobileSkipControlsProps { + videoRef: React.RefObject; + duration: number; + setCurrentTime: (time: number) => void; + skipAmount: number; + skipSide: 'left' | 'right' | null; + setSkipAmount: (amount: number) => void; + setSkipSide: (side: 'left' | 'right' | null) => void; + setShowSkipIndicator: (show: boolean) => void; + skipTimeoutRef: React.MutableRefObject; +} + +export function useMobileSkipControls({ + videoRef, + duration, + setCurrentTime, + skipAmount, + skipSide, + setSkipAmount, + setSkipSide, + setShowSkipIndicator, + skipTimeoutRef +}: UseMobileSkipControlsProps) { + 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]); + + return { + skipVideo + }; +} diff --git a/components/player/hooks/mobile/useMobileUtilities.ts b/components/player/hooks/mobile/useMobileUtilities.ts new file mode 100644 index 0000000..fbdcc65 --- /dev/null +++ b/components/player/hooks/mobile/useMobileUtilities.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect } from 'react'; + +interface UseMobileUtilitiesProps { + src: string; + volume: number; + isMuted: boolean; + videoRef: React.RefObject; + setVolume: (volume: number) => void; + setIsMuted: (muted: boolean) => void; + setViewportWidth: (width: number) => void; + setToastMessage: (message: string | null) => void; + setShowToast: (show: boolean) => void; + toastTimeoutRef: React.MutableRefObject; +} + +export function useMobileUtilities({ + src, + volume, + isMuted, + videoRef, + setVolume, + setIsMuted, + setViewportWidth, + setToastMessage, + setShowToast, + toastTimeoutRef +}: UseMobileUtilitiesProps) { + useEffect(() => { + const updateViewportWidth = () => { + setViewportWidth(window.innerWidth); + }; + updateViewportWidth(); + window.addEventListener('resize', updateViewportWidth); + return () => window.removeEventListener('resize', updateViewportWidth); + }, [setViewportWidth]); + + 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 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]); + + return { + toggleMute, + showToastNotification, + handleCopyLink + }; +} diff --git a/components/player/hooks/useDesktopPlayerLogic.ts b/components/player/hooks/useDesktopPlayerLogic.ts index a6dabd6..2cd1a48 100644 --- a/components/player/hooks/useDesktopPlayerLogic.ts +++ b/components/player/hooks/useDesktopPlayerLogic.ts @@ -1,4 +1,11 @@ -import { useEffect, useCallback } from 'react'; +import { usePlaybackControls } from './desktop/usePlaybackControls'; +import { useVolumeControls } from './desktop/useVolumeControls'; +import { useProgressControls } from './desktop/useProgressControls'; +import { useSkipControls } from './desktop/useSkipControls'; +import { useFullscreenControls } from './desktop/useFullscreenControls'; +import { useKeyboardShortcuts } from './desktop/useKeyboardShortcuts'; +import { useControlsVisibility } from './desktop/useControlsVisibility'; +import { useUtilities } from './desktop/useUtilities'; interface UseDesktopPlayerLogicProps { src: string; @@ -30,8 +37,7 @@ export function useDesktopPlayerLogic({ isDraggingProgressRef, isDraggingVolumeRef, mouseMoveThrottleRef, - toastTimeoutRef, - moreMenuTimeoutRef + toastTimeoutRef } = refs; const { @@ -55,501 +61,134 @@ export function useDesktopPlayerLogic({ setIsSkipBackwardAnimatingOut, setShowVolumeBar, setToastMessage, - setShowToast, - showMoreMenu, setShowMoreMenu + setShowToast } = 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')}`; - }, []); + const playbackControls = usePlaybackControls({ + videoRef, + isPlaying, + setIsPlaying, + setIsLoading, + initialTime, + setDuration, + setCurrentTime, + onTimeUpdate, + onError, + isDraggingProgressRef + }); + + const volumeControls = useVolumeControls({ + videoRef, + volumeBarRef, + volume, + isMuted, + setVolume, + setIsMuted, + setShowVolumeBar, + volumeBarTimeoutRef, + isDraggingVolumeRef + }); + + const progressControls = useProgressControls({ + videoRef, + progressBarRef, + duration, + setCurrentTime, + isDraggingProgressRef + }); + + const skipControls = useSkipControls({ + videoRef, + duration, + setCurrentTime, + showSkipForwardIndicator, + showSkipBackwardIndicator, + skipForwardAmount, + skipBackwardAmount, + setShowSkipForwardIndicator, + setShowSkipBackwardIndicator, + setSkipForwardAmount, + setSkipBackwardAmount, + setIsSkipForwardAnimatingOut, + setIsSkipBackwardAnimatingOut, + skipForwardTimeoutRef, + skipBackwardTimeoutRef + }); + + const fullscreenControls = useFullscreenControls({ + containerRef, + videoRef, + isFullscreen, + setIsFullscreen, + isPiPSupported, + isAirPlaySupported, + setIsPiPSupported, + setIsAirPlaySupported + }); + + const controlsVisibility = useControlsVisibility({ + isPlaying, + showControls, + showSpeedMenu, + setShowControls, + setShowSpeedMenu, + controlsTimeoutRef, + speedMenuTimeoutRef, + mouseMoveThrottleRef + }); + + const utilities = useUtilities({ + src, + setToastMessage, + setShowToast, + toastTimeoutRef + }); + + const changePlaybackSpeed = (speed: number) => { + playbackControls.changePlaybackSpeed(speed, speedMenuTimeoutRef, setPlaybackRate, setShowSpeedMenu); + }; + + useKeyboardShortcuts({ + videoRef, + isPlaying, + volume, + isPiPSupported, + togglePlay: playbackControls.togglePlay, + toggleMute: volumeControls.toggleMute, + toggleFullscreen: fullscreenControls.toggleFullscreen, + togglePictureInPicture: fullscreenControls.togglePictureInPicture, + skipForward: skipControls.skipForward, + skipBackward: skipControls.skipBackward, + showVolumeBarTemporarily: volumeControls.showVolumeBarTemporarily, + setShowControls, + setVolume, + setIsMuted, + controlsTimeoutRef + }); return { - handleMouseMove, - togglePlay, - handlePlay, - handlePause, - handleTimeUpdateEvent, - handleLoadedMetadata, - handleVideoError, - handleProgressClick, - handleProgressMouseDown, - toggleMute, - showVolumeBarTemporarily, - handleVolumeChange, - handleVolumeMouseDown, - toggleFullscreen, - togglePictureInPicture, - showAirPlayMenu, - skipForward, - skipBackward, + handleMouseMove: controlsVisibility.handleMouseMove, + togglePlay: playbackControls.togglePlay, + handlePlay: playbackControls.handlePlay, + handlePause: playbackControls.handlePause, + handleTimeUpdateEvent: playbackControls.handleTimeUpdateEvent, + handleLoadedMetadata: playbackControls.handleLoadedMetadata, + handleVideoError: playbackControls.handleVideoError, + handleProgressClick: progressControls.handleProgressClick, + handleProgressMouseDown: progressControls.handleProgressMouseDown, + toggleMute: volumeControls.toggleMute, + showVolumeBarTemporarily: volumeControls.showVolumeBarTemporarily, + handleVolumeChange: volumeControls.handleVolumeChange, + handleVolumeMouseDown: volumeControls.handleVolumeMouseDown, + toggleFullscreen: fullscreenControls.toggleFullscreen, + togglePictureInPicture: fullscreenControls.togglePictureInPicture, + showAirPlayMenu: fullscreenControls.showAirPlayMenu, + skipForward: skipControls.skipForward, + skipBackward: skipControls.skipBackward, changePlaybackSpeed, - handleCopyLink, - startSpeedMenuTimeout, - clearSpeedMenuTimeout, - formatTime + handleCopyLink: utilities.handleCopyLink, + startSpeedMenuTimeout: controlsVisibility.startSpeedMenuTimeout, + clearSpeedMenuTimeout: controlsVisibility.clearSpeedMenuTimeout, + formatTime: playbackControls.formatTime }; } diff --git a/components/player/hooks/useMobilePlayerLogic.ts b/components/player/hooks/useMobilePlayerLogic.ts index 2260f2c..943a7c1 100644 --- a/components/player/hooks/useMobilePlayerLogic.ts +++ b/components/player/hooks/useMobilePlayerLogic.ts @@ -1,5 +1,9 @@ -import { useEffect, useCallback } from 'react'; -import { useIsIOS } from '@/lib/hooks/useMobilePlayer'; +import { useMobilePlaybackControls } from './mobile/useMobilePlaybackControls'; +import { useMobileProgressControls } from './mobile/useMobileProgressControls'; +import { useMobileSkipControls } from './mobile/useMobileSkipControls'; +import { useMobileFullscreenControls } from './mobile/useMobileFullscreenControls'; +import { useMobileMenuControls } from './mobile/useMobileMenuControls'; +import { useMobileUtilities } from './mobile/useMobileUtilities'; interface UseMobilePlayerLogicProps { src: string; @@ -54,389 +58,100 @@ export function useMobilePlayerLogic({ setViewportWidth } = state; - const isIOS = useIsIOS(); + const playbackControls = useMobilePlaybackControls({ + videoRef, + isPlaying, + setIsPlaying, + setIsLoading, + initialTime, + setDuration, + setCurrentTime, + setPlaybackRate, + setShowMoreMenu, + setShowVolumeMenu, + setShowSpeedMenu, + onTimeUpdate, + onError, + isDraggingProgressRef, + isTogglingRef + }); - // 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. + const progressControls = useMobileProgressControls({ + videoRef, + progressBarRef, + duration, + setCurrentTime, + isDraggingProgressRef + }); - // Check for PiP support - useEffect(() => { - if (typeof document !== 'undefined') { - setIsPiPSupported('pictureInPictureEnabled' in document); - } - }, [setIsPiPSupported]); + const skipControls = useMobileSkipControls({ + videoRef, + duration, + setCurrentTime, + skipAmount, + skipSide, + setSkipAmount, + setSkipSide, + setShowSkipIndicator, + skipTimeoutRef + }); - // Track viewport width - useEffect(() => { - const updateViewportWidth = () => { - setViewportWidth(window.innerWidth); - }; - updateViewportWidth(); - window.addEventListener('resize', updateViewportWidth); - return () => window.removeEventListener('resize', updateViewportWidth); - }, [setViewportWidth]); + const fullscreenControls = useMobileFullscreenControls({ + containerRef, + videoRef, + isFullscreen, + setIsFullscreen, + isPiPSupported, + setIsPiPSupported + }); - // Skip forward/backward - const skipVideo = useCallback((seconds: number, side: 'left' | 'right') => { - if (!videoRef.current) return; + const utilities = useMobileUtilities({ + src, + volume, + isMuted, + videoRef, + setVolume, + setIsMuted, + setViewportWidth, + setToastMessage, + setShowToast, + toastTimeoutRef + }); - 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]); + useMobileMenuControls({ + videoRef, + isPlaying, + showMoreMenu, + showVolumeMenu, + showSpeedMenu, + wasPlayingBeforeMenu, + setShowControls, + setShowMoreMenu, + setShowVolumeMenu, + setShowSpeedMenu, + setWasPlayingBeforeMenu, + controlsTimeoutRef, + menuIdleTimeoutRef + }); return { - skipVideo, - togglePlay, - handlePlay, - handlePause, - handleTimeUpdateEvent, - handleLoadedMetadata, - handleVideoError, - handleProgressTouchStart, - handleProgressTouchMove, - handleProgressTouchEnd, - handleProgressClick, - toggleMute, - toggleFullscreen, - togglePictureInPicture, - changePlaybackSpeed, - showToastNotification, - handleCopyLink, - formatTime + skipVideo: skipControls.skipVideo, + togglePlay: playbackControls.togglePlay, + handlePlay: playbackControls.handlePlay, + handlePause: playbackControls.handlePause, + handleTimeUpdateEvent: playbackControls.handleTimeUpdateEvent, + handleLoadedMetadata: playbackControls.handleLoadedMetadata, + handleVideoError: playbackControls.handleVideoError, + handleProgressTouchStart: progressControls.handleProgressTouchStart, + handleProgressTouchMove: progressControls.handleProgressTouchMove, + handleProgressTouchEnd: progressControls.handleProgressTouchEnd, + handleProgressClick: progressControls.handleProgressClick, + toggleMute: utilities.toggleMute, + toggleFullscreen: fullscreenControls.toggleFullscreen, + togglePictureInPicture: fullscreenControls.togglePictureInPicture, + changePlaybackSpeed: playbackControls.changePlaybackSpeed, + showToastNotification: utilities.showToastNotification, + handleCopyLink: utilities.handleCopyLink, + formatTime: playbackControls.formatTime }; } diff --git a/components/player/mobile/CompactControls.tsx b/components/player/mobile/CompactControls.tsx new file mode 100644 index 0000000..dcb7cde --- /dev/null +++ b/components/player/mobile/CompactControls.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { Icons } from '@/components/ui/Icon'; +import { MobileMoreMenu } from './MobileMoreMenu'; + +interface CompactControlsProps { + isPlaying: boolean; + isFullscreen: boolean; + showMoreMenu: boolean; + isMuted: boolean; + volume: number; + playbackRate: number; + isPiPSupported: boolean; + currentTime: number; + duration: number; + formatTime: (seconds: number) => string; + onTogglePlay: () => void; + onToggleFullscreen: () => void; + onToggleMoreMenu: () => void; + onToggleVolumeMenu: () => void; + onToggleSpeedMenu: () => void; + onTogglePiP: () => void; + onCopyLink: () => void; + iconSize: number; + buttonPadding: string; + controlsGap: string; + textSize: string; +} + +export function CompactControls({ + isPlaying, + isFullscreen, + showMoreMenu, + isMuted, + volume, + playbackRate, + isPiPSupported, + currentTime, + duration, + formatTime, + onTogglePlay, + onToggleFullscreen, + onToggleMoreMenu, + onToggleVolumeMenu, + onToggleSpeedMenu, + onTogglePiP, + onCopyLink, + iconSize, + buttonPadding, + controlsGap, + textSize +}: CompactControlsProps) { + return ( +
+
+ + + + {formatTime(currentTime)} / {formatTime(duration)} + +
+ +
+
+ + + { + onToggleMoreMenu(); + onCopyLink(); + }} + onToggleVolumeMenu={() => { + onToggleMoreMenu(); + onToggleVolumeMenu(); + }} + onToggleSpeedMenu={() => { + onToggleMoreMenu(); + onToggleSpeedMenu(); + }} + onTogglePiP={() => { + onToggleMoreMenu(); + onTogglePiP(); + }} + /> +
+ + +
+
+ ); +} diff --git a/components/player/mobile/FullControls.tsx b/components/player/mobile/FullControls.tsx new file mode 100644 index 0000000..b446fd6 --- /dev/null +++ b/components/player/mobile/FullControls.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import { Icons } from '@/components/ui/Icon'; +import { MobileVolumeMenu } from './MobileVolumeMenu'; +import { MobileSpeedMenu } from './MobileSpeedMenu'; + +interface FullControlsProps { + isPlaying: boolean; + isFullscreen: boolean; + showVolumeMenu: boolean; + showSpeedMenu: boolean; + isMuted: boolean; + volume: number; + playbackRate: number; + isPiPSupported: boolean; + currentTime: number; + duration: number; + speeds: number[]; + formatTime: (seconds: number) => string; + onTogglePlay: () => void; + onSkipVideo: (seconds: number, side: 'left' | 'right') => void; + onToggleFullscreen: () => void; + onToggleVolumeMenu: () => void; + onToggleSpeedMenu: () => void; + onTogglePiP: () => void; + onToggleMoreMenu: () => void; + onToggleMute: () => void; + onVolumeChange: (volume: number) => void; + onSpeedChange: (speed: number) => void; + iconSize: number; + buttonPadding: string; + controlsGap: string; + textSize: string; +} + +export function FullControls({ + isPlaying, + isFullscreen, + showVolumeMenu, + showSpeedMenu, + isMuted, + volume, + playbackRate, + isPiPSupported, + currentTime, + duration, + speeds, + formatTime, + onTogglePlay, + onSkipVideo, + onToggleFullscreen, + onToggleVolumeMenu, + onToggleSpeedMenu, + onTogglePiP, + onToggleMoreMenu, + onToggleMute, + onVolumeChange, + onSpeedChange, + iconSize, + buttonPadding, + controlsGap, + textSize +}: FullControlsProps) { + return ( +
+
+ + + + + + +
+ + + +
+ + + {formatTime(currentTime)} / {formatTime(duration)} + +
+ +
+ +
+
+ + + +
+ + {isPiPSupported && ( + + )} + + + + +
+
+ ); +} diff --git a/components/player/mobile/MobileControls.tsx b/components/player/mobile/MobileControls.tsx index 4a2ede5..32a9e6e 100644 --- a/components/player/mobile/MobileControls.tsx +++ b/components/player/mobile/MobileControls.tsx @@ -1,9 +1,9 @@ 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'; +import { CompactControls } from './CompactControls'; +import { FullControls } from './FullControls'; interface MobileControlsProps { showControls: boolean; @@ -20,7 +20,6 @@ interface MobileControlsProps { showSpeedMenu: boolean; isPiPSupported: boolean; progressBarRef: React.RefObject; - onTogglePlay: () => void; onSkipVideo: (seconds: number, side: 'left' | 'right') => void; onToggleMute: () => void; @@ -40,39 +39,30 @@ interface MobileControlsProps { 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) { +export function MobileControls(props: MobileControlsProps) { + const { + showControls, + isCompactLayout, + progressBarRef, + currentTime, + duration, + onProgressClick, + onProgressTouchStart, + onProgressTouchMove, + onProgressTouchEnd, + showVolumeMenu, + showSpeedMenu, + isMuted, + volume, + playbackRate, + speeds, + onToggleMute, + onVolumeChange, + onSpeedChange, + onToggleVolumeMenu, + onToggleSpeedMenu + } = props; + const iconSize = isCompactLayout ? 20 : 22; const buttonPadding = isCompactLayout ? 'p-2' : 'p-2.5'; const controlsGap = isCompactLayout ? 'gap-2' : 'gap-3'; @@ -85,7 +75,6 @@ export function MobileControls({ }`} style={{ pointerEvents: showControls ? 'auto' : 'none' }} > - {/* Progress Bar */} - {/* Controls Bar */}
{isCompactLayout ? ( - // Compact Layout -
-
- - - - {formatTime(currentTime)} / {formatTime(duration)} - -
- -
-
- - - { - onToggleMoreMenu(); - onCopyLink(); - }} - onToggleVolumeMenu={() => { - onToggleMoreMenu(); - onToggleVolumeMenu(); - }} - onToggleSpeedMenu={() => { - onToggleMoreMenu(); - onToggleSpeedMenu(); - }} - onTogglePiP={() => { - onToggleMoreMenu(); - onTogglePiP(); - }} - /> -
- - -
-
+ ) : ( - // Full Layout -
-
- - - - - - -
- - - -
- - - {formatTime(currentTime)} / {formatTime(duration)} - -
- -
- -
-
- - - -
- - {isPiPSupported && ( - - )} - - - - -
-
+ )} - {/* Compact Layout Submenus */}