mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-19 10:43:43 +08:00
refactor: extract player control logic into dedicated desktop and mobile hooks and introduce new mobile player control components.
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user