refactor: extract player control logic into dedicated desktop and mobile hooks and introduce new mobile player control components.

This commit is contained in:
kuekhaoyang
2025-11-21 12:47:40 +08:00
parent 36488e545c
commit 6e81559d90
22 changed files with 2018 additions and 1447 deletions
-131
View File
@@ -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
+1 -191
View File
@@ -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;
}
+189
View File
@@ -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);
}
}
@@ -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<NodeJS.Timeout | null>;
speedMenuTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
mouseMoveThrottleRef: React.MutableRefObject<NodeJS.Timeout | null>;
}
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
};
}
@@ -0,0 +1,80 @@
import { useCallback, useEffect } from 'react';
interface UseFullscreenControlsProps {
containerRef: React.RefObject<HTMLDivElement>;
videoRef: React.RefObject<HTMLVideoElement>;
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
};
}
@@ -0,0 +1,112 @@
import { useEffect } from 'react';
interface UseKeyboardShortcutsProps {
videoRef: React.RefObject<HTMLVideoElement>;
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<NodeJS.Timeout | null>;
}
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]);
}
@@ -0,0 +1,98 @@
import { useCallback } from 'react';
interface UsePlaybackControlsProps {
videoRef: React.RefObject<HTMLVideoElement>;
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<boolean>;
}
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<NodeJS.Timeout | null>, 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
};
}
@@ -0,0 +1,63 @@
import { useCallback, useEffect } from 'react';
interface UseProgressControlsProps {
videoRef: React.RefObject<HTMLVideoElement>;
progressBarRef: React.RefObject<HTMLDivElement>;
duration: number;
setCurrentTime: (time: number) => void;
isDraggingProgressRef: React.MutableRefObject<boolean>;
}
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
};
}
@@ -0,0 +1,108 @@
import { useCallback } from 'react';
interface UseSkipControlsProps {
videoRef: React.RefObject<HTMLVideoElement>;
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<NodeJS.Timeout | null>;
skipBackwardTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
}
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
};
}
@@ -0,0 +1,44 @@
import { useCallback } from 'react';
interface UseUtilitiesProps {
src: string;
setToastMessage: (message: string | null) => void;
setShowToast: (show: boolean) => void;
toastTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
}
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
};
}
@@ -0,0 +1,94 @@
import { useCallback, useEffect } from 'react';
interface UseVolumeControlsProps {
videoRef: React.RefObject<HTMLVideoElement>;
volumeBarRef: React.RefObject<HTMLDivElement>;
volume: number;
isMuted: boolean;
setVolume: (volume: number) => void;
setIsMuted: (muted: boolean) => void;
setShowVolumeBar: (show: boolean) => void;
volumeBarTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
isDraggingVolumeRef: React.MutableRefObject<boolean>;
}
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
};
}
@@ -0,0 +1,97 @@
import { useCallback, useEffect } from 'react';
import { useIsIOS } from '@/lib/hooks/useMobilePlayer';
interface UseMobileFullscreenProps {
containerRef: React.RefObject<HTMLDivElement>;
videoRef: React.RefObject<HTMLVideoElement>;
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
};
}
@@ -0,0 +1,115 @@
import { useEffect } from 'react';
interface UseMobileMenuControlsProps {
videoRef: React.RefObject<HTMLVideoElement>;
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<NodeJS.Timeout | null>;
menuIdleTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
}
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]);
}
@@ -0,0 +1,116 @@
import { useCallback, useEffect } from 'react';
interface UseMobilePlaybackProps {
videoRef: React.RefObject<HTMLVideoElement>;
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<boolean>;
isTogglingRef: React.MutableRefObject<boolean>;
}
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
};
}
@@ -0,0 +1,80 @@
import { useCallback, useEffect } from 'react';
interface UseMobileProgressControlsProps {
videoRef: React.RefObject<HTMLVideoElement>;
progressBarRef: React.RefObject<HTMLDivElement>;
duration: number;
setCurrentTime: (time: number) => void;
isDraggingProgressRef: React.MutableRefObject<boolean>;
}
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
};
}
@@ -0,0 +1,55 @@
import { useCallback } from 'react';
interface UseMobileSkipControlsProps {
videoRef: React.RefObject<HTMLVideoElement>;
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<NodeJS.Timeout | null>;
}
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
};
}
@@ -0,0 +1,77 @@
import { useCallback, useEffect } from 'react';
interface UseMobileUtilitiesProps {
src: string;
volume: number;
isMuted: boolean;
videoRef: React.RefObject<HTMLVideoElement>;
setVolume: (volume: number) => void;
setIsMuted: (muted: boolean) => void;
setViewportWidth: (width: number) => void;
setToastMessage: (message: string | null) => void;
setShowToast: (show: boolean) => void;
toastTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
}
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
};
}
+133 -494
View File
@@ -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
};
}
+94 -379
View File
@@ -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
};
}
@@ -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 (
<div className={`flex items-center justify-between ${controlsGap}`}>
<div className={`flex items-center ${controlsGap} min-w-0`}>
<button
onClick={(e) => {
e.stopPropagation();
onTogglePlay();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isPlaying ? 'Pause' : 'Play'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isPlaying ? <Icons.Pause size={iconSize} /> : <Icons.Play size={iconSize} />}
</button>
<span className={`text-white ${textSize} font-medium tabular-nums whitespace-nowrap`}>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className={`flex items-center ${controlsGap} flex-shrink-0`}>
<div className="relative z-[60]">
<button
onClick={(e) => {
e.stopPropagation();
onToggleMoreMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="更多"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>
</button>
<MobileMoreMenu
showMoreMenu={showMoreMenu}
isMuted={isMuted}
volume={volume}
playbackRate={playbackRate}
isPiPSupported={isPiPSupported}
onCopyLink={() => {
onToggleMoreMenu();
onCopyLink();
}}
onToggleVolumeMenu={() => {
onToggleMoreMenu();
onToggleVolumeMenu();
}}
onToggleSpeedMenu={() => {
onToggleMoreMenu();
onToggleSpeedMenu();
}}
onTogglePiP={() => {
onToggleMoreMenu();
onTogglePiP();
}}
/>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onToggleFullscreen();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isFullscreen ? '退出全屏' : '全屏'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isFullscreen ? <Icons.Minimize size={iconSize} /> : <Icons.Maximize size={iconSize} />}
</button>
</div>
</div>
);
}
+201
View File
@@ -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 (
<div className={`flex items-center ${controlsGap}`}>
<div className={`flex items-center ${controlsGap}`}>
<button
onClick={(e) => {
e.stopPropagation();
onTogglePlay();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isPlaying ? 'Pause' : 'Play'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isPlaying ? <Icons.Pause size={iconSize} /> : <Icons.Play size={iconSize} />}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onSkipVideo(10, 'left');
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="后退 10 秒"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.SkipBack size={iconSize} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onSkipVideo(10, 'right');
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="前进 10 秒"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.SkipForward size={iconSize} />
</button>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
onToggleVolumeMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="音量"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isMuted || volume === 0 ? <Icons.VolumeX size={iconSize} /> : <Icons.Volume2 size={iconSize} />}
</button>
<MobileVolumeMenu
showVolumeMenu={showVolumeMenu}
isCompactLayout={false}
isMuted={isMuted}
volume={volume}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onClose={onToggleVolumeMenu}
/>
</div>
<span className={`text-white ${textSize} font-medium tabular-nums whitespace-nowrap`}>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className="flex-1" />
<div className={`flex items-center ${controlsGap} flex-shrink-0`}>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
onToggleSpeedMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="播放速度"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<span className={`text-white ${textSize} font-medium`}>{playbackRate}x</span>
</button>
<MobileSpeedMenu
showSpeedMenu={showSpeedMenu}
isCompactLayout={false}
playbackRate={playbackRate}
speeds={speeds}
onSpeedChange={onSpeedChange}
onClose={onToggleSpeedMenu}
/>
</div>
{isPiPSupported && (
<button
onClick={(e) => {
e.stopPropagation();
onTogglePiP();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="画中画"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.PictureInPicture size={iconSize} />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
onToggleMoreMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="更多"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onToggleFullscreen();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isFullscreen ? '退出全屏' : '全屏'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isFullscreen ? <Icons.Minimize size={iconSize} /> : <Icons.Maximize size={iconSize} />}
</button>
</div>
</div>
);
}
+40 -252
View File
@@ -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<HTMLDivElement | null>;
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 */}
<MobileProgressBar
progressBarRef={progressBarRef}
currentTime={currentTime}
@@ -96,226 +85,25 @@ export function MobileControls({
onProgressTouchEnd={onProgressTouchEnd}
/>
{/* Controls Bar */}
<div className={`bg-gradient-to-t from-black/90 via-black/70 to-transparent ${controlsPadding} pt-2`}>
{isCompactLayout ? (
// Compact Layout
<div className={`flex items-center justify-between ${controlsGap}`}>
<div className={`flex items-center ${controlsGap} min-w-0`}>
<button
onClick={(e) => {
e.stopPropagation();
onTogglePlay();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isPlaying ? 'Pause' : 'Play'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isPlaying ? <Icons.Pause size={iconSize} /> : <Icons.Play size={iconSize} />}
</button>
<span className={`text-white ${textSize} font-medium tabular-nums whitespace-nowrap`}>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className={`flex items-center ${controlsGap} flex-shrink-0`}>
<div className="relative z-[60]">
<button
onClick={(e) => {
e.stopPropagation();
onToggleMoreMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="更多"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>
</button>
<MobileMoreMenu
showMoreMenu={showMoreMenu}
isMuted={isMuted}
volume={volume}
playbackRate={playbackRate}
isPiPSupported={isPiPSupported}
onCopyLink={() => {
onToggleMoreMenu();
onCopyLink();
}}
onToggleVolumeMenu={() => {
onToggleMoreMenu();
onToggleVolumeMenu();
}}
onToggleSpeedMenu={() => {
onToggleMoreMenu();
onToggleSpeedMenu();
}}
onTogglePiP={() => {
onToggleMoreMenu();
onTogglePiP();
}}
/>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onToggleFullscreen();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isFullscreen ? '退出全屏' : '全屏'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isFullscreen ? <Icons.Minimize size={iconSize} /> : <Icons.Maximize size={iconSize} />}
</button>
</div>
</div>
<CompactControls
{...props}
iconSize={iconSize}
buttonPadding={buttonPadding}
controlsGap={controlsGap}
textSize={textSize}
/>
) : (
// Full Layout
<div className={`flex items-center ${controlsGap}`}>
<div className={`flex items-center ${controlsGap}`}>
<button
onClick={(e) => {
e.stopPropagation();
onTogglePlay();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isPlaying ? 'Pause' : 'Play'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isPlaying ? <Icons.Pause size={iconSize} /> : <Icons.Play size={iconSize} />}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onSkipVideo(10, 'left');
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="后退 10 秒"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.SkipBack size={iconSize} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onSkipVideo(10, 'right');
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="前进 10 秒"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.SkipForward size={iconSize} />
</button>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
onToggleVolumeMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="音量"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isMuted || volume === 0 ? <Icons.VolumeX size={iconSize} /> : <Icons.Volume2 size={iconSize} />}
</button>
<MobileVolumeMenu
showVolumeMenu={showVolumeMenu}
isCompactLayout={false}
isMuted={isMuted}
volume={volume}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onClose={onToggleVolumeMenu}
/>
</div>
<span className={`text-white ${textSize} font-medium tabular-nums whitespace-nowrap`}>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className="flex-1" />
<div className={`flex items-center ${controlsGap} flex-shrink-0`}>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
onToggleSpeedMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="播放速度"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<span className={`text-white ${textSize} font-medium`}>{playbackRate}x</span>
</button>
<MobileSpeedMenu
showSpeedMenu={showSpeedMenu}
isCompactLayout={false}
playbackRate={playbackRate}
speeds={speeds}
onSpeedChange={onSpeedChange}
onClose={onToggleSpeedMenu}
/>
</div>
{isPiPSupported && (
<button
onClick={(e) => {
e.stopPropagation();
onTogglePiP();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="画中画"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.PictureInPicture size={iconSize} />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
onToggleMoreMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="更多"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onToggleFullscreen();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isFullscreen ? '退出全屏' : '全屏'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isFullscreen ? <Icons.Minimize size={iconSize} /> : <Icons.Maximize size={iconSize} />}
</button>
</div>
</div>
<FullControls
{...props}
iconSize={iconSize}
buttonPadding={buttonPadding}
controlsGap={controlsGap}
textSize={textSize}
/>
)}
{/* Compact Layout Submenus */}
<MobileVolumeMenu
showVolumeMenu={showVolumeMenu}
isCompactLayout={true}