mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 08:43:44 +08:00
feat: Remove mobile video player implementation and related hooks, consolidating CustomVideoPlayer to use only the desktop player.
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useIsMobile } from '@/lib/hooks/useMobilePlayer';
|
||||
import { DesktopVideoPlayer } from './DesktopVideoPlayer';
|
||||
import { MobileVideoPlayer } from './MobileVideoPlayer';
|
||||
|
||||
|
||||
interface CustomVideoPlayerProps {
|
||||
src: string;
|
||||
@@ -23,9 +22,5 @@ interface CustomVideoPlayerProps {
|
||||
* - Desktop: Full-featured player with hover interactions
|
||||
*/
|
||||
export function CustomVideoPlayer(props: CustomVideoPlayerProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return isMobile
|
||||
? <MobileVideoPlayer {...props} />
|
||||
: <DesktopVideoPlayer {...props} />;
|
||||
return <DesktopVideoPlayer {...props} />;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,33 @@ export function DesktopVideoPlayer({
|
||||
|
||||
<DesktopOverlayWrapper
|
||||
state={state}
|
||||
showControls={state.showControls}
|
||||
onTogglePlay={togglePlay}
|
||||
onSkipForward={logic.skipForward}
|
||||
onSkipBackward={logic.skipBackward}
|
||||
// More Menu Props
|
||||
showMoreMenu={state.showMoreMenu}
|
||||
isProxied={src.includes('/api/proxy')}
|
||||
onToggleMoreMenu={() => state.setShowMoreMenu(!state.showMoreMenu)}
|
||||
onMoreMenuMouseEnter={() => {
|
||||
if (refs.moreMenuTimeoutRef.current) {
|
||||
clearTimeout(refs.moreMenuTimeoutRef.current);
|
||||
}
|
||||
}}
|
||||
onMoreMenuMouseLeave={() => {
|
||||
refs.moreMenuTimeoutRef.current = setTimeout(() => {
|
||||
state.setShowMoreMenu(false);
|
||||
}, 300);
|
||||
}}
|
||||
onCopyLink={logic.handleCopyLink}
|
||||
// Speed Menu Props
|
||||
playbackRate={state.playbackRate}
|
||||
showSpeedMenu={state.showSpeedMenu}
|
||||
speeds={[0.5, 0.75, 1, 1.25, 1.5, 2]}
|
||||
onToggleSpeedMenu={() => state.setShowSpeedMenu(!state.showSpeedMenu)}
|
||||
onSpeedChange={logic.changePlaybackSpeed}
|
||||
onSpeedMenuMouseEnter={logic.clearSpeedMenuTimeout}
|
||||
onSpeedMenuMouseLeave={logic.startSpeedMenuTimeout}
|
||||
/>
|
||||
|
||||
<DesktopControlsWrapper
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useScreenOrientation } from '@/lib/hooks/useMobilePlayer';
|
||||
import { useMobilePlayerState } from './hooks/useMobilePlayerState';
|
||||
import { useMobilePlayerLogic } from './hooks/useMobilePlayerLogic';
|
||||
import { useMobileGestures } from './hooks/useMobileGestures';
|
||||
import { useAutoSkip } from './hooks/useAutoSkip';
|
||||
import { useHlsPlayer } from './hooks/useHlsPlayer';
|
||||
import { MobileControlsWrapper } from './mobile/MobileControlsWrapper';
|
||||
import { MobileOverlay } from './mobile/MobileOverlay';
|
||||
import { MobileSkipIndicator } from './mobile/MobileSkipIndicator';
|
||||
|
||||
interface MobileVideoPlayerProps {
|
||||
src: string;
|
||||
poster?: string;
|
||||
onError?: (error: string) => void;
|
||||
onTimeUpdate?: (currentTime: number, duration: number) => void;
|
||||
initialTime?: number;
|
||||
shouldAutoPlay?: boolean;
|
||||
// Episode navigation props for auto-skip/auto-next
|
||||
totalEpisodes?: number;
|
||||
currentEpisodeIndex?: number;
|
||||
onNextEpisode?: () => void;
|
||||
}
|
||||
|
||||
export function MobileVideoPlayer({
|
||||
src,
|
||||
poster,
|
||||
onError,
|
||||
onTimeUpdate,
|
||||
initialTime = 0,
|
||||
shouldAutoPlay = false,
|
||||
totalEpisodes = 1,
|
||||
currentEpisodeIndex = 0,
|
||||
onNextEpisode,
|
||||
}: MobileVideoPlayerProps) {
|
||||
const { refs, state } = useMobilePlayerState();
|
||||
|
||||
const {
|
||||
videoRef,
|
||||
containerRef,
|
||||
controlsTimeoutRef
|
||||
} = refs;
|
||||
|
||||
// Initialize HLS Player (same as Desktop - fixes Android Chrome playback)
|
||||
useHlsPlayer({
|
||||
videoRef,
|
||||
src,
|
||||
autoPlay: shouldAutoPlay,
|
||||
onError,
|
||||
});
|
||||
|
||||
const {
|
||||
isPlaying,
|
||||
isFullscreen,
|
||||
showControls,
|
||||
isLoading,
|
||||
showSkipIndicator,
|
||||
skipAmount,
|
||||
skipSide,
|
||||
toastMessage,
|
||||
showToast,
|
||||
currentTime,
|
||||
duration,
|
||||
setShowControls,
|
||||
setIsLoading
|
||||
} = state;
|
||||
|
||||
const logic = useMobilePlayerLogic({
|
||||
src,
|
||||
initialTime,
|
||||
shouldAutoPlay,
|
||||
onError,
|
||||
onTimeUpdate,
|
||||
refs,
|
||||
state
|
||||
});
|
||||
|
||||
// Auto-skip intro/outro and auto-next episode
|
||||
useAutoSkip({
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
totalEpisodes,
|
||||
currentEpisodeIndex,
|
||||
onNextEpisode,
|
||||
});
|
||||
|
||||
const {
|
||||
skipVideo,
|
||||
togglePlay,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleTimeUpdateEvent,
|
||||
handleLoadedMetadata,
|
||||
handleVideoError,
|
||||
} = logic;
|
||||
|
||||
// Screen orientation management
|
||||
useScreenOrientation(isFullscreen);
|
||||
|
||||
// Double tap handler
|
||||
const { handleTap } = useMobileGestures({
|
||||
skipVideo,
|
||||
showSkipIndicator,
|
||||
showControls,
|
||||
setShowControls,
|
||||
controlsTimeoutRef,
|
||||
isPlaying,
|
||||
togglePlay,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative aspect-video bg-black rounded-[var(--radius-2xl)] overflow-hidden"
|
||||
>
|
||||
{/* Video Element - src is set by useHlsPlayer hook */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain touch-none"
|
||||
poster={poster}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onTimeUpdate={handleTimeUpdateEvent}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onError={handleVideoError}
|
||||
onWaiting={() => setIsLoading(true)}
|
||||
onCanPlay={() => setIsLoading(false)}
|
||||
onTouchEnd={handleTap}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
x-webkit-airplay="allow"
|
||||
/>
|
||||
|
||||
<MobileOverlay
|
||||
isLoading={isLoading}
|
||||
showToast={showToast}
|
||||
toastMessage={toastMessage}
|
||||
/>
|
||||
|
||||
<MobileSkipIndicator
|
||||
showSkipIndicator={showSkipIndicator}
|
||||
skipSide={skipSide}
|
||||
skipAmount={skipAmount}
|
||||
/>
|
||||
|
||||
<MobileControlsWrapper
|
||||
src={src}
|
||||
state={state}
|
||||
logic={logic}
|
||||
refs={refs}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,9 +11,8 @@ interface DesktopControlsProps {
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isFullscreen: boolean;
|
||||
playbackRate: number;
|
||||
showSpeedMenu: boolean;
|
||||
showMoreMenu: boolean;
|
||||
|
||||
|
||||
showVolumeBar: boolean;
|
||||
isPiPSupported: boolean;
|
||||
isAirPlaySupported: boolean;
|
||||
@@ -22,8 +21,6 @@ interface DesktopControlsProps {
|
||||
progressBarRef: React.RefObject<HTMLDivElement | null>;
|
||||
volumeBarRef: React.RefObject<HTMLDivElement | null>;
|
||||
onTogglePlay: () => void;
|
||||
onSkipForward: () => void;
|
||||
onSkipBackward: () => void;
|
||||
onToggleMute: () => void;
|
||||
onVolumeChange: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onVolumeMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
@@ -31,18 +28,9 @@ interface DesktopControlsProps {
|
||||
onTogglePictureInPicture: () => void;
|
||||
onShowAirPlayMenu: () => void;
|
||||
onShowCastMenu: () => void;
|
||||
onToggleSpeedMenu: () => void;
|
||||
onToggleMoreMenu: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onCopyLink: (type?: 'original' | 'proxy') => void;
|
||||
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onProgressMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onSpeedMenuMouseEnter: () => void;
|
||||
onSpeedMenuMouseLeave: () => void;
|
||||
onMoreMenuMouseEnter: () => void;
|
||||
onMoreMenuMouseLeave: () => void;
|
||||
formatTime: (seconds: number) => string;
|
||||
speeds: number[];
|
||||
}
|
||||
|
||||
export function DesktopControls(props: DesktopControlsProps) {
|
||||
|
||||
@@ -19,21 +19,14 @@ export function DesktopControlsWrapper({ src, state, logic, refs }: DesktopContr
|
||||
isMuted,
|
||||
isFullscreen,
|
||||
showControls,
|
||||
playbackRate,
|
||||
showSpeedMenu,
|
||||
showMoreMenu,
|
||||
showVolumeBar,
|
||||
isPiPSupported,
|
||||
isAirPlaySupported,
|
||||
isCastAvailable,
|
||||
setShowSpeedMenu,
|
||||
setShowMoreMenu,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
togglePlay,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
toggleMute,
|
||||
handleVolumeChange,
|
||||
handleVolumeMouseDown,
|
||||
@@ -41,22 +34,16 @@ export function DesktopControlsWrapper({ src, state, logic, refs }: DesktopContr
|
||||
togglePictureInPicture,
|
||||
showAirPlayMenu,
|
||||
showCastMenu,
|
||||
changePlaybackSpeed,
|
||||
handleCopyLink,
|
||||
handleProgressClick,
|
||||
handleProgressMouseDown,
|
||||
startSpeedMenuTimeout,
|
||||
clearSpeedMenuTimeout,
|
||||
formatTime,
|
||||
} = logic;
|
||||
|
||||
const {
|
||||
progressBarRef,
|
||||
volumeBarRef,
|
||||
moreMenuTimeoutRef,
|
||||
} = refs;
|
||||
|
||||
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
const isProxied = src.includes('/api/proxy');
|
||||
|
||||
return (
|
||||
@@ -68,9 +55,6 @@ export function DesktopControlsWrapper({ src, state, logic, refs }: DesktopContr
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
isFullscreen={isFullscreen}
|
||||
playbackRate={playbackRate}
|
||||
showSpeedMenu={showSpeedMenu}
|
||||
showMoreMenu={showMoreMenu}
|
||||
showVolumeBar={showVolumeBar}
|
||||
isPiPSupported={isPiPSupported}
|
||||
isAirPlaySupported={isAirPlaySupported}
|
||||
@@ -79,8 +63,6 @@ export function DesktopControlsWrapper({ src, state, logic, refs }: DesktopContr
|
||||
progressBarRef={progressBarRef}
|
||||
volumeBarRef={volumeBarRef}
|
||||
onTogglePlay={togglePlay}
|
||||
onSkipForward={skipForward}
|
||||
onSkipBackward={skipBackward}
|
||||
onToggleMute={toggleMute}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
onVolumeMouseDown={handleVolumeMouseDown}
|
||||
@@ -88,26 +70,9 @@ export function DesktopControlsWrapper({ src, state, logic, refs }: DesktopContr
|
||||
onTogglePictureInPicture={togglePictureInPicture}
|
||||
onShowAirPlayMenu={showAirPlayMenu}
|
||||
onShowCastMenu={showCastMenu}
|
||||
onToggleSpeedMenu={() => setShowSpeedMenu(!showSpeedMenu)}
|
||||
onToggleMoreMenu={() => setShowMoreMenu(!showMoreMenu)}
|
||||
onSpeedChange={changePlaybackSpeed}
|
||||
onCopyLink={handleCopyLink}
|
||||
onProgressClick={handleProgressClick}
|
||||
onProgressMouseDown={handleProgressMouseDown}
|
||||
onSpeedMenuMouseEnter={clearSpeedMenuTimeout}
|
||||
onSpeedMenuMouseLeave={startSpeedMenuTimeout}
|
||||
onMoreMenuMouseEnter={() => {
|
||||
if (moreMenuTimeoutRef.current) {
|
||||
clearTimeout(moreMenuTimeoutRef.current);
|
||||
}
|
||||
}}
|
||||
onMoreMenuMouseLeave={() => {
|
||||
moreMenuTimeoutRef.current = setTimeout(() => {
|
||||
setShowMoreMenu(false);
|
||||
}, 300);
|
||||
}}
|
||||
formatTime={formatTime}
|
||||
speeds={speeds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ interface DesktopLeftControlsProps {
|
||||
showVolumeBar: boolean;
|
||||
volumeBarRef: React.RefObject<HTMLDivElement | null>;
|
||||
onTogglePlay: () => void;
|
||||
onSkipForward: () => void;
|
||||
onSkipBackward: () => void;
|
||||
onToggleMute: () => void;
|
||||
onVolumeChange: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onVolumeMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
@@ -28,8 +26,6 @@ export function DesktopLeftControls({
|
||||
showVolumeBar,
|
||||
volumeBarRef,
|
||||
onTogglePlay,
|
||||
onSkipForward,
|
||||
onSkipBackward,
|
||||
onToggleMute,
|
||||
onVolumeChange,
|
||||
onVolumeMouseDown,
|
||||
@@ -46,26 +42,6 @@ export function DesktopLeftControls({
|
||||
{isPlaying ? <Icons.Pause size={20} /> : <Icons.Play size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Skip Backward 10s */}
|
||||
<button
|
||||
onClick={onSkipBackward}
|
||||
className="btn-icon"
|
||||
aria-label="Skip backward 10 seconds"
|
||||
title="后退 10 秒"
|
||||
>
|
||||
<Icons.SkipBack size={20} />
|
||||
</button>
|
||||
|
||||
{/* Skip Forward 10s */}
|
||||
<button
|
||||
onClick={onSkipForward}
|
||||
className="btn-icon"
|
||||
aria-label="Skip forward 10 seconds"
|
||||
title="快进 10 秒"
|
||||
>
|
||||
<Icons.SkipForward size={20} />
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<DesktopVolumeControl
|
||||
volumeBarRef={volumeBarRef}
|
||||
|
||||
@@ -4,6 +4,8 @@ import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { usePlayerSettings } from '../hooks/usePlayerSettings';
|
||||
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface DesktopMoreMenuProps {
|
||||
showMoreMenu: boolean;
|
||||
isProxied?: boolean;
|
||||
@@ -36,179 +38,206 @@ export function DesktopMoreMenu({
|
||||
setShowModeIndicator,
|
||||
} = usePlayerSettings();
|
||||
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const [menuPosition, setMenuPosition] = React.useState({ top: 0, left: 0 });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showMoreMenu && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
setMenuPosition({
|
||||
top: rect.bottom + 10, // 10px spacing
|
||||
left: rect.left
|
||||
});
|
||||
}
|
||||
}, [showMoreMenu]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!showMoreMenu && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
setMenuPosition({
|
||||
top: rect.bottom + 10, // 10px spacing
|
||||
left: rect.left
|
||||
});
|
||||
}
|
||||
onToggleMoreMenu();
|
||||
};
|
||||
|
||||
const MenuContent = (
|
||||
<div
|
||||
className="fixed z-[9999] bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] p-2 min-w-[220px] animate-in fade-in zoom-in-95 duration-200"
|
||||
style={{
|
||||
top: menuPosition.top,
|
||||
left: menuPosition.left,
|
||||
}}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Copy Link Options */}
|
||||
{isProxied ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onCopyLink('original')}
|
||||
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<Icons.Link size={18} />
|
||||
<span>复制原链接</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onCopyLink('proxy')}
|
||||
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3 mt-1 cursor-pointer"
|
||||
>
|
||||
<Icons.Link size={18} />
|
||||
<span>复制代理链接</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onCopyLink('original')}
|
||||
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<Icons.Link size={18} />
|
||||
<span>复制链接</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-[var(--glass-border)] my-2" />
|
||||
|
||||
{/* Show Mode Indicator Switch */}
|
||||
<div className="px-4 py-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-sm text-[var(--text-color)]">
|
||||
<Icons.Zap size={18} />
|
||||
<span>显示模式指示器</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModeIndicator(!showModeIndicator)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${showModeIndicator ? 'bg-[var(--accent-color)]' : 'bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]'
|
||||
}`}
|
||||
aria-checked={showModeIndicator}
|
||||
role="switch"
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${showModeIndicator ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto Next Episode Switch */}
|
||||
<div className="px-4 py-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-sm text-[var(--text-color)]">
|
||||
<Icons.SkipForward size={18} />
|
||||
<span>自动下一集</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoNextEpisode(!autoNextEpisode)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${autoNextEpisode ? 'bg-[var(--accent-color)]' : 'bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]'
|
||||
}`}
|
||||
aria-checked={autoNextEpisode}
|
||||
role="switch"
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${autoNextEpisode ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Skip Intro Switch */}
|
||||
<div className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-sm text-[var(--text-color)]">
|
||||
<Icons.FastForward size={18} />
|
||||
<span>跳过片头</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoSkipIntro(!autoSkipIntro)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${autoSkipIntro ? 'bg-[var(--accent-color)]' : 'bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]'
|
||||
}`}
|
||||
aria-checked={autoSkipIntro}
|
||||
role="switch"
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${autoSkipIntro ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* Expandable Input */}
|
||||
{autoSkipIntro && (
|
||||
<div className="mt-2 ml-7 flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">时长:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="600"
|
||||
value={skipIntroSeconds}
|
||||
onChange={(e) => setSkipIntroSeconds(parseInt(e.target.value) || 0)}
|
||||
className="w-16 px-2 py-1 text-sm text-center bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)] no-spinner"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">秒</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skip Outro Switch */}
|
||||
<div className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-sm text-[var(--text-color)]">
|
||||
<Icons.Rewind size={18} />
|
||||
<span>跳过片尾</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoSkipOutro(!autoSkipOutro)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${autoSkipOutro ? 'bg-[var(--accent-color)]' : 'bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]'
|
||||
}`}
|
||||
aria-checked={autoSkipOutro}
|
||||
role="switch"
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${autoSkipOutro ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* Expandable Input */}
|
||||
{autoSkipOutro && (
|
||||
<div className="mt-2 ml-7 flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">剩余:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="600"
|
||||
value={skipOutroSeconds}
|
||||
onChange={(e) => setSkipOutroSeconds(parseInt(e.target.value) || 0)}
|
||||
className="w-16 px-2 py-1 text-sm text-center bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)] no-spinner"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">秒</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={onToggleMoreMenu}
|
||||
ref={buttonRef}
|
||||
onClick={handleToggle}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className="btn-icon"
|
||||
className="group flex items-center justify-center w-12 h-12 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 hover:scale-110 active:scale-95"
|
||||
aria-label="More options"
|
||||
title="更多选项"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="12" cy="5" r="1" />
|
||||
<circle cx="12" cy="19" r="1" />
|
||||
</svg>
|
||||
<Icons.MoreHorizontal className="text-white/80 group-hover:text-white" size={24} />
|
||||
</button>
|
||||
|
||||
{/* More Menu Dropdown */}
|
||||
{showMoreMenu && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-2 bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] p-2 min-w-[220px]"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Copy Link Options */}
|
||||
{isProxied ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onCopyLink('original')}
|
||||
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<Icons.Link size={18} />
|
||||
<span>复制原链接</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onCopyLink('proxy')}
|
||||
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3 mt-1 cursor-pointer"
|
||||
>
|
||||
<Icons.Link size={18} />
|
||||
<span>复制代理链接</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onCopyLink('original')}
|
||||
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<Icons.Link size={18} />
|
||||
<span>复制链接</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-[var(--glass-border)] my-2" />
|
||||
|
||||
{/* Show Mode Indicator Switch */}
|
||||
<div className="px-4 py-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-sm text-[var(--text-color)]">
|
||||
<Icons.Zap size={18} />
|
||||
<span>显示模式指示器</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModeIndicator(!showModeIndicator)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${showModeIndicator ? 'bg-[var(--accent-color)]' : 'bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]'
|
||||
}`}
|
||||
aria-checked={showModeIndicator}
|
||||
role="switch"
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${showModeIndicator ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto Next Episode Switch */}
|
||||
<div className="px-4 py-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-sm text-[var(--text-color)]">
|
||||
<Icons.SkipForward size={18} />
|
||||
<span>自动下一集</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoNextEpisode(!autoNextEpisode)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${autoNextEpisode ? 'bg-[var(--accent-color)]' : 'bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]'
|
||||
}`}
|
||||
aria-checked={autoNextEpisode}
|
||||
role="switch"
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${autoNextEpisode ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Skip Intro Switch */}
|
||||
<div className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-sm text-[var(--text-color)]">
|
||||
<Icons.FastForward size={18} />
|
||||
<span>跳过片头</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoSkipIntro(!autoSkipIntro)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${autoSkipIntro ? 'bg-[var(--accent-color)]' : 'bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]'
|
||||
}`}
|
||||
aria-checked={autoSkipIntro}
|
||||
role="switch"
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${autoSkipIntro ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* Expandable Input */}
|
||||
{autoSkipIntro && (
|
||||
<div className="mt-2 ml-7 flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">时长:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="600"
|
||||
value={skipIntroSeconds}
|
||||
onChange={(e) => setSkipIntroSeconds(parseInt(e.target.value) || 0)}
|
||||
className="w-16 px-2 py-1 text-sm text-center bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)] no-spinner"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">秒</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skip Outro Switch */}
|
||||
<div className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-sm text-[var(--text-color)]">
|
||||
<Icons.Rewind size={18} />
|
||||
<span>跳过片尾</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoSkipOutro(!autoSkipOutro)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${autoSkipOutro ? 'bg-[var(--accent-color)]' : 'bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]'
|
||||
}`}
|
||||
aria-checked={autoSkipOutro}
|
||||
role="switch"
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${autoSkipOutro ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* Expandable Input */}
|
||||
{autoSkipOutro && (
|
||||
<div className="mt-2 ml-7 flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">剩余:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="600"
|
||||
value={skipOutroSeconds}
|
||||
onChange={(e) => setSkipOutroSeconds(parseInt(e.target.value) || 0)}
|
||||
className="w-16 px-2 py-1 text-sm text-center bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)] no-spinner"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">秒</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* More Menu Dropdown (Portal) */}
|
||||
{showMoreMenu && typeof document !== 'undefined' && createPortal(MenuContent, document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
import { DesktopMoreMenu } from './DesktopMoreMenu';
|
||||
import { DesktopSpeedMenu } from './DesktopSpeedMenu';
|
||||
|
||||
interface DesktopOverlayProps {
|
||||
isLoading: boolean;
|
||||
isPlaying: boolean;
|
||||
@@ -12,7 +15,24 @@ interface DesktopOverlayProps {
|
||||
isSkipBackwardAnimatingOut: boolean;
|
||||
showToast: boolean;
|
||||
toastMessage: string | null;
|
||||
showControls: boolean;
|
||||
onTogglePlay: () => void;
|
||||
onSkipForward: () => void;
|
||||
onSkipBackward: () => void;
|
||||
showMoreMenu: boolean;
|
||||
isProxied: boolean;
|
||||
onToggleMoreMenu: () => void;
|
||||
onMoreMenuMouseEnter: () => void;
|
||||
onMoreMenuMouseLeave: () => void;
|
||||
onCopyLink: (type?: 'original' | 'proxy') => void;
|
||||
// Speed Menu Props
|
||||
playbackRate: number;
|
||||
showSpeedMenu: boolean;
|
||||
speeds: number[];
|
||||
onToggleSpeedMenu: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onSpeedMenuMouseEnter: () => void;
|
||||
onSpeedMenuMouseLeave: () => void;
|
||||
}
|
||||
|
||||
export function DesktopOverlay({
|
||||
@@ -26,10 +46,54 @@ export function DesktopOverlay({
|
||||
isSkipBackwardAnimatingOut,
|
||||
showToast,
|
||||
toastMessage,
|
||||
onTogglePlay
|
||||
onTogglePlay,
|
||||
onSkipForward,
|
||||
onSkipBackward,
|
||||
showControls,
|
||||
showMoreMenu,
|
||||
isProxied,
|
||||
onToggleMoreMenu,
|
||||
onMoreMenuMouseEnter,
|
||||
onMoreMenuMouseLeave,
|
||||
onCopyLink,
|
||||
playbackRate,
|
||||
showSpeedMenu,
|
||||
speeds,
|
||||
onToggleSpeedMenu,
|
||||
onSpeedChange,
|
||||
onSpeedMenuMouseEnter,
|
||||
onSpeedMenuMouseLeave
|
||||
}: DesktopOverlayProps) {
|
||||
// Show navigation buttons when controls are visible or when paused (controls usually show when paused anyway)
|
||||
const showNavButtons = showControls || !isPlaying;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* More Menu (Top Left) */}
|
||||
<div className={`absolute top-6 left-6 z-30 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`} style={{ pointerEvents: showControls ? 'auto' : 'none' }}>
|
||||
<DesktopMoreMenu
|
||||
showMoreMenu={showMoreMenu}
|
||||
isProxied={isProxied}
|
||||
onToggleMoreMenu={onToggleMoreMenu}
|
||||
onMouseEnter={onMoreMenuMouseEnter}
|
||||
onMouseLeave={onMoreMenuMouseLeave}
|
||||
onCopyLink={onCopyLink}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Speed Menu (Top Right) */}
|
||||
<div className={`absolute top-6 right-6 z-30 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`} style={{ pointerEvents: showControls ? 'auto' : 'none' }}>
|
||||
<DesktopSpeedMenu
|
||||
showSpeedMenu={showSpeedMenu}
|
||||
playbackRate={playbackRate}
|
||||
speeds={speeds}
|
||||
onSpeedChange={onSpeedChange}
|
||||
onToggleSpeedMenu={onToggleSpeedMenu}
|
||||
onMouseEnter={onSpeedMenuMouseEnter}
|
||||
onMouseLeave={onSpeedMenuMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Loading Spinner - Glass Effect */}
|
||||
{isLoading && (
|
||||
<div className="loading-overlay-glass">
|
||||
@@ -37,29 +101,65 @@ export function DesktopOverlay({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skip Forward Indicator */}
|
||||
{showSkipForwardIndicator && (
|
||||
<div className="absolute top-1/2 right-12 -translate-y-1/2 pointer-events-none transition-all duration-300">
|
||||
<div className={`text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] ${isSkipForwardAnimatingOut ? 'animate-scale-out' : 'animate-scale-in'
|
||||
{/* Skip Backward Indicator (Animation) */}
|
||||
{showSkipBackwardIndicator && (
|
||||
<div className="absolute top-1/2 left-24 -translate-y-1/2 pointer-events-none transition-all duration-300 z-20">
|
||||
<div className={`text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] ${isSkipBackwardAnimatingOut ? 'animate-scale-out' : 'animate-scale-in'
|
||||
}`}>
|
||||
+{skipForwardAmount}
|
||||
-{skipBackwardAmount}s
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skip Backward Indicator */}
|
||||
{showSkipBackwardIndicator && (
|
||||
<div className="absolute top-1/2 left-12 -translate-y-1/2 pointer-events-none transition-all duration-300">
|
||||
<div className={`text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] ${isSkipBackwardAnimatingOut ? 'animate-scale-out' : 'animate-scale-in'
|
||||
{/* Skip Forward Indicator (Animation) */}
|
||||
{showSkipForwardIndicator && (
|
||||
<div className="absolute top-1/2 right-24 -translate-y-1/2 pointer-events-none transition-all duration-300 z-20">
|
||||
<div className={`text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] ${isSkipForwardAnimatingOut ? 'animate-scale-out' : 'animate-scale-in'
|
||||
}`}>
|
||||
-{skipBackwardAmount}
|
||||
+{skipForwardAmount}s
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Previous Button (Method: Skip Backward) */}
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 flex items-center justify-center p-8 transition-opacity duration-300 z-10 ${showNavButtons ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
style={{ pointerEvents: showNavButtons ? 'auto' : 'none' }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSkipBackward();
|
||||
}}
|
||||
className="group flex items-center justify-center w-16 h-16 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 hover:scale-110 active:scale-95"
|
||||
aria-label="Skip Backward 10s"
|
||||
>
|
||||
<Icons.SkipBack size={32} className="text-white/80 group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Next Button (Method: Skip Forward) */}
|
||||
<div
|
||||
className={`absolute right-0 top-0 bottom-0 flex items-center justify-center p-8 transition-opacity duration-300 z-10 ${showNavButtons ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
style={{ pointerEvents: showNavButtons ? 'auto' : 'none' }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSkipForward();
|
||||
}}
|
||||
className="group flex items-center justify-center w-16 h-16 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 hover:scale-110 active:scale-95"
|
||||
aria-label="Skip Forward 10s"
|
||||
>
|
||||
<Icons.SkipForward size={32} className="text-white/80 group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center Play Button (when paused) */}
|
||||
{!isPlaying && !isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="pointer-events-auto w-20 h-20 rounded-full bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] flex items-center justify-center transition-all duration-300 hover:scale-110 hover:bg-[var(--accent-color)] shadow-[var(--shadow-md)] will-change-transform cursor-pointer"
|
||||
|
||||
@@ -4,10 +4,46 @@ import { useDesktopPlayerState } from '../hooks/useDesktopPlayerState';
|
||||
|
||||
interface DesktopOverlayWrapperProps {
|
||||
state: ReturnType<typeof useDesktopPlayerState>['state'];
|
||||
showControls: boolean;
|
||||
onTogglePlay: () => void;
|
||||
onSkipForward: () => void;
|
||||
onSkipBackward: () => void;
|
||||
showMoreMenu: boolean;
|
||||
isProxied: boolean;
|
||||
onToggleMoreMenu: () => void;
|
||||
onMoreMenuMouseEnter: () => void;
|
||||
onMoreMenuMouseLeave: () => void;
|
||||
onCopyLink: (type?: 'original' | 'proxy') => void;
|
||||
// Speed Menu Props
|
||||
playbackRate: number;
|
||||
showSpeedMenu: boolean;
|
||||
speeds: number[];
|
||||
onToggleSpeedMenu: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onSpeedMenuMouseEnter: () => void;
|
||||
onSpeedMenuMouseLeave: () => void;
|
||||
}
|
||||
|
||||
export function DesktopOverlayWrapper({ state, onTogglePlay }: DesktopOverlayWrapperProps) {
|
||||
export function DesktopOverlayWrapper({
|
||||
state,
|
||||
showControls,
|
||||
onTogglePlay,
|
||||
onSkipForward,
|
||||
onSkipBackward,
|
||||
showMoreMenu,
|
||||
isProxied,
|
||||
onToggleMoreMenu,
|
||||
onMoreMenuMouseEnter,
|
||||
onMoreMenuMouseLeave,
|
||||
onCopyLink,
|
||||
playbackRate,
|
||||
showSpeedMenu,
|
||||
speeds,
|
||||
onToggleSpeedMenu,
|
||||
onSpeedChange,
|
||||
onSpeedMenuMouseEnter,
|
||||
onSpeedMenuMouseLeave
|
||||
}: DesktopOverlayWrapperProps) {
|
||||
const {
|
||||
isLoading,
|
||||
isPlaying,
|
||||
@@ -33,7 +69,23 @@ export function DesktopOverlayWrapper({ state, onTogglePlay }: DesktopOverlayWra
|
||||
isSkipBackwardAnimatingOut={isSkipBackwardAnimatingOut}
|
||||
showToast={showToast}
|
||||
toastMessage={toastMessage}
|
||||
showControls={showControls}
|
||||
onTogglePlay={onTogglePlay}
|
||||
onSkipForward={onSkipForward}
|
||||
onSkipBackward={onSkipBackward}
|
||||
showMoreMenu={showMoreMenu}
|
||||
isProxied={isProxied}
|
||||
onToggleMoreMenu={onToggleMoreMenu}
|
||||
onMoreMenuMouseEnter={onMoreMenuMouseEnter}
|
||||
onMoreMenuMouseLeave={onMoreMenuMouseLeave}
|
||||
onCopyLink={onCopyLink}
|
||||
playbackRate={playbackRate}
|
||||
showSpeedMenu={showSpeedMenu}
|
||||
speeds={speeds}
|
||||
onToggleSpeedMenu={onToggleSpeedMenu}
|
||||
onSpeedChange={onSpeedChange}
|
||||
onSpeedMenuMouseEnter={onSpeedMenuMouseEnter}
|
||||
onSpeedMenuMouseLeave={onSpeedMenuMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { DesktopSpeedMenu } from './DesktopSpeedMenu';
|
||||
import { DesktopMoreMenu } from './DesktopMoreMenu';
|
||||
|
||||
|
||||
|
||||
interface DesktopRightControlsProps {
|
||||
isFullscreen: boolean;
|
||||
playbackRate: number;
|
||||
showSpeedMenu: boolean;
|
||||
showMoreMenu: boolean;
|
||||
isPiPSupported: boolean;
|
||||
isAirPlaySupported: boolean;
|
||||
isCastAvailable: boolean;
|
||||
@@ -16,22 +13,10 @@ interface DesktopRightControlsProps {
|
||||
onTogglePictureInPicture: () => void;
|
||||
onShowAirPlayMenu: () => void;
|
||||
onShowCastMenu: () => void;
|
||||
onToggleSpeedMenu: () => void;
|
||||
onToggleMoreMenu: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onCopyLink: (type?: 'original' | 'proxy') => void;
|
||||
onSpeedMenuMouseEnter: () => void;
|
||||
onSpeedMenuMouseLeave: () => void;
|
||||
onMoreMenuMouseEnter: () => void;
|
||||
onMoreMenuMouseLeave: () => void;
|
||||
speeds: number[];
|
||||
}
|
||||
|
||||
export function DesktopRightControls({
|
||||
isFullscreen,
|
||||
playbackRate,
|
||||
showSpeedMenu,
|
||||
showMoreMenu,
|
||||
isPiPSupported,
|
||||
isAirPlaySupported,
|
||||
isCastAvailable,
|
||||
@@ -39,75 +24,51 @@ export function DesktopRightControls({
|
||||
onToggleFullscreen,
|
||||
onTogglePictureInPicture,
|
||||
onShowAirPlayMenu,
|
||||
onShowCastMenu,
|
||||
onToggleSpeedMenu,
|
||||
onToggleMoreMenu,
|
||||
onSpeedChange,
|
||||
onCopyLink,
|
||||
onSpeedMenuMouseEnter,
|
||||
onSpeedMenuMouseLeave,
|
||||
onMoreMenuMouseEnter,
|
||||
onMoreMenuMouseLeave,
|
||||
speeds
|
||||
onShowCastMenu
|
||||
}: DesktopRightControlsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Playback Speed */}
|
||||
<DesktopSpeedMenu
|
||||
showSpeedMenu={showSpeedMenu}
|
||||
playbackRate={playbackRate}
|
||||
speeds={speeds}
|
||||
onSpeedChange={onSpeedChange}
|
||||
onToggleSpeedMenu={onToggleSpeedMenu}
|
||||
onMouseEnter={onSpeedMenuMouseEnter}
|
||||
onMouseLeave={onSpeedMenuMouseLeave}
|
||||
/>
|
||||
|
||||
{/* Picture-in-Picture */}
|
||||
{isPiPSupported && (
|
||||
<button
|
||||
onClick={onTogglePictureInPicture}
|
||||
className="btn-icon"
|
||||
aria-label="Picture-in-Picture"
|
||||
title="画中画"
|
||||
>
|
||||
<Icons.PictureInPicture size={20} />
|
||||
</button>
|
||||
)}
|
||||
{
|
||||
isPiPSupported && (
|
||||
<button
|
||||
onClick={onTogglePictureInPicture}
|
||||
className="btn-icon"
|
||||
aria-label="Picture-in-Picture"
|
||||
title="画中画"
|
||||
>
|
||||
<Icons.PictureInPicture size={20} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
{/* AirPlay */}
|
||||
{isAirPlaySupported && (
|
||||
<button
|
||||
onClick={onShowAirPlayMenu}
|
||||
className="btn-icon"
|
||||
aria-label="AirPlay"
|
||||
title="AirPlay"
|
||||
>
|
||||
<Icons.Airplay size={20} />
|
||||
</button>
|
||||
)}
|
||||
{
|
||||
isAirPlaySupported && (
|
||||
<button
|
||||
onClick={onShowAirPlayMenu}
|
||||
className="btn-icon"
|
||||
aria-label="AirPlay"
|
||||
title="AirPlay"
|
||||
>
|
||||
<Icons.Airplay size={20} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Google Cast */}
|
||||
{isCastAvailable && (
|
||||
<button
|
||||
onClick={onShowCastMenu}
|
||||
className="btn-icon"
|
||||
aria-label="Google Cast"
|
||||
title="Google Cast"
|
||||
>
|
||||
<Icons.Cast size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* More Menu */}
|
||||
<DesktopMoreMenu
|
||||
showMoreMenu={showMoreMenu}
|
||||
isProxied={isProxied}
|
||||
onToggleMoreMenu={onToggleMoreMenu}
|
||||
onMouseEnter={onMoreMenuMouseEnter}
|
||||
onMouseLeave={onMoreMenuMouseLeave}
|
||||
onCopyLink={onCopyLink}
|
||||
/>
|
||||
{
|
||||
isCastAvailable && (
|
||||
<button
|
||||
onClick={onShowCastMenu}
|
||||
className="btn-icon"
|
||||
aria-label="Google Cast"
|
||||
title="Google Cast"
|
||||
>
|
||||
<Icons.Cast size={20} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
@@ -117,6 +78,6 @@ export function DesktopRightControls({
|
||||
>
|
||||
{isFullscreen ? <Icons.Minimize size={20} /> : <Icons.Maximize size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface DesktopSpeedMenuProps {
|
||||
showSpeedMenu: boolean;
|
||||
@@ -19,39 +20,72 @@ export function DesktopSpeedMenu({
|
||||
onMouseEnter,
|
||||
onMouseLeave
|
||||
}: DesktopSpeedMenuProps) {
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const [menuPosition, setMenuPosition] = React.useState({ top: 0, left: 0 });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showSpeedMenu && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
setMenuPosition({
|
||||
top: rect.bottom + 10,
|
||||
left: rect.right // Align with right edge
|
||||
});
|
||||
}
|
||||
}, [showSpeedMenu]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!showSpeedMenu && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
setMenuPosition({
|
||||
top: rect.bottom + 10,
|
||||
left: rect.right // Align with right edge
|
||||
});
|
||||
}
|
||||
onToggleSpeedMenu();
|
||||
};
|
||||
|
||||
|
||||
const MenuContent = (
|
||||
<div
|
||||
className="fixed z-[9999] bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] p-2 min-w-[5rem]"
|
||||
style={{
|
||||
top: menuPosition.top,
|
||||
left: menuPosition.left,
|
||||
transform: 'translateX(-100%)', // Align right edge
|
||||
}}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{speeds.map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
onClick={() => onSpeedChange(speed)}
|
||||
className={`w-full px-3 py-2 rounded-[var(--radius-2xl)] text-sm font-medium transition-colors ${playbackRate === speed
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={onToggleSpeedMenu}
|
||||
ref={buttonRef}
|
||||
onClick={handleToggle}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className="btn-icon text-xs font-semibold min-w-[2.5rem]"
|
||||
className="group flex items-center justify-center w-12 h-12 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 hover:scale-110 active:scale-95 text-white/90 font-medium text-sm"
|
||||
aria-label="Playback speed"
|
||||
>
|
||||
{playbackRate}x
|
||||
</button>
|
||||
|
||||
{/* Speed Menu */}
|
||||
{showSpeedMenu && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-2 bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] p-2 min-w-[5rem]"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{speeds.map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
onClick={() => onSpeedChange(speed)}
|
||||
className={`w-full px-3 py-2 rounded-[var(--radius-2xl)] text-sm font-medium transition-colors ${playbackRate === speed
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Speed Menu (Portal) */}
|
||||
{showSpeedMenu && typeof document !== 'undefined' && createPortal(MenuContent, document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* Parameter builder utilities for mobile player hooks
|
||||
*/
|
||||
|
||||
export function buildPlaybackParams(props: any) {
|
||||
const {
|
||||
videoRef,
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
setIsLoading,
|
||||
initialTime,
|
||||
shouldAutoPlay,
|
||||
setDuration,
|
||||
setCurrentTime,
|
||||
setPlaybackRate,
|
||||
setShowMoreMenu,
|
||||
setShowVolumeMenu,
|
||||
setShowSpeedMenu,
|
||||
onTimeUpdate,
|
||||
onError,
|
||||
isDraggingProgressRef,
|
||||
isTogglingRef,
|
||||
} = props;
|
||||
|
||||
return {
|
||||
videoRef,
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
setIsLoading,
|
||||
initialTime,
|
||||
shouldAutoPlay,
|
||||
setDuration,
|
||||
setCurrentTime,
|
||||
setPlaybackRate,
|
||||
setShowMoreMenu,
|
||||
setShowVolumeMenu,
|
||||
setShowSpeedMenu,
|
||||
onTimeUpdate,
|
||||
onError,
|
||||
isDraggingProgressRef,
|
||||
isTogglingRef,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProgressParams(props: any) {
|
||||
const { videoRef, progressBarRef, duration, setCurrentTime, isDraggingProgressRef } = props;
|
||||
return { videoRef, progressBarRef, duration, setCurrentTime, isDraggingProgressRef };
|
||||
}
|
||||
|
||||
export function buildSkipParams(props: any) {
|
||||
const {
|
||||
videoRef,
|
||||
duration,
|
||||
setCurrentTime,
|
||||
skipAmount,
|
||||
skipSide,
|
||||
setSkipAmount,
|
||||
setSkipSide,
|
||||
setShowSkipIndicator,
|
||||
skipTimeoutRef,
|
||||
} = props;
|
||||
|
||||
return {
|
||||
videoRef,
|
||||
duration,
|
||||
setCurrentTime,
|
||||
skipAmount,
|
||||
skipSide,
|
||||
setSkipAmount,
|
||||
setSkipSide,
|
||||
setShowSkipIndicator,
|
||||
skipTimeoutRef,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFullscreenParams(props: any) {
|
||||
const { containerRef, videoRef, isFullscreen, setIsFullscreen, isPiPSupported, setIsPiPSupported } = props;
|
||||
return { containerRef, videoRef, isFullscreen, setIsFullscreen, isPiPSupported, setIsPiPSupported };
|
||||
}
|
||||
|
||||
export function buildUtilitiesParams(props: any) {
|
||||
const {
|
||||
src,
|
||||
volume,
|
||||
isMuted,
|
||||
videoRef,
|
||||
setVolume,
|
||||
setIsMuted,
|
||||
setViewportWidth,
|
||||
setToastMessage,
|
||||
setShowToast,
|
||||
toastTimeoutRef,
|
||||
} = props;
|
||||
|
||||
return {
|
||||
src,
|
||||
volume,
|
||||
isMuted,
|
||||
videoRef,
|
||||
setVolume,
|
||||
setIsMuted,
|
||||
setViewportWidth,
|
||||
setToastMessage,
|
||||
setShowToast,
|
||||
toastTimeoutRef,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMenuParams(props: any) {
|
||||
const {
|
||||
videoRef,
|
||||
isPlaying,
|
||||
showMoreMenu,
|
||||
showVolumeMenu,
|
||||
showSpeedMenu,
|
||||
wasPlayingBeforeMenu,
|
||||
setShowControls,
|
||||
setShowMoreMenu,
|
||||
setShowVolumeMenu,
|
||||
setShowSpeedMenu,
|
||||
setWasPlayingBeforeMenu,
|
||||
controlsTimeoutRef,
|
||||
menuIdleTimeoutRef,
|
||||
} = props;
|
||||
|
||||
return {
|
||||
videoRef,
|
||||
isPlaying,
|
||||
showMoreMenu,
|
||||
showVolumeMenu,
|
||||
showSpeedMenu,
|
||||
wasPlayingBeforeMenu,
|
||||
setShowControls,
|
||||
setShowMoreMenu,
|
||||
setShowVolumeMenu,
|
||||
setShowSpeedMenu,
|
||||
setWasPlayingBeforeMenu,
|
||||
controlsTimeoutRef,
|
||||
menuIdleTimeoutRef,
|
||||
};
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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]);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { formatTime } from '@/lib/utils/format-utils';
|
||||
import { usePlaybackPolling } from '../usePlaybackPolling';
|
||||
import { useMobileTogglePlay } from './useMobileTogglePlay';
|
||||
|
||||
interface UseMobilePlaybackProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
isPlaying: boolean;
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
initialTime: number;
|
||||
shouldAutoPlay: boolean;
|
||||
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,
|
||||
shouldAutoPlay,
|
||||
setDuration,
|
||||
setCurrentTime,
|
||||
setPlaybackRate,
|
||||
setShowMoreMenu,
|
||||
setShowVolumeMenu,
|
||||
setShowSpeedMenu,
|
||||
onTimeUpdate,
|
||||
onError,
|
||||
isDraggingProgressRef,
|
||||
isTogglingRef
|
||||
}: UseMobilePlaybackProps) {
|
||||
const togglePlay = useMobileTogglePlay({
|
||||
videoRef,
|
||||
isPlaying,
|
||||
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);
|
||||
|
||||
// Fix for stuck at 00:00:00:
|
||||
// Only seek if we are at the very start (to avoid overwriting a previous seek)
|
||||
if (videoRef.current.currentTime < 0.5) {
|
||||
// If initialTime is 0, we seek to a tiny offset to help the browser/HLS buffer start.
|
||||
const startPosition = initialTime > 0 ? initialTime : 0.1;
|
||||
videoRef.current.currentTime = startPosition;
|
||||
}
|
||||
|
||||
videoRef.current.play().catch((err: Error) => {
|
||||
console.warn('Autoplay was prevented:', err);
|
||||
});
|
||||
}, [videoRef, setDuration, setIsLoading, initialTime]);
|
||||
|
||||
const hasInitialSeekHappened = useRef(false);
|
||||
|
||||
// Handle late initialization of initialTime (e.g. from async storage hydration)
|
||||
useEffect(() => {
|
||||
if (initialTime > 0 && videoRef.current && !hasInitialSeekHappened.current) {
|
||||
// Only seek if we haven't progressed far (e.g. still near start)
|
||||
// AND if the target time is significantly different from current time (> 0.5s)
|
||||
// This prevents jumping if the user has already started watching and initialTime updates
|
||||
if (videoRef.current.currentTime < 2 && Math.abs(videoRef.current.currentTime - initialTime) > 0.5) {
|
||||
videoRef.current.currentTime = initialTime;
|
||||
hasInitialSeekHappened.current = true;
|
||||
} else if (videoRef.current.currentTime >= 2) {
|
||||
// If user already watched past 2s, assume they don't want to be reset
|
||||
hasInitialSeekHappened.current = true;
|
||||
}
|
||||
}
|
||||
}, [initialTime, videoRef]);
|
||||
|
||||
// Force autoplay when shouldAutoPlay is true (for proxy retry)
|
||||
useEffect(() => {
|
||||
if (shouldAutoPlay && videoRef.current) {
|
||||
const playPromise = videoRef.current.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch((err: Error) => {
|
||||
console.warn('Force autoplay was prevented:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [shouldAutoPlay, videoRef]);
|
||||
|
||||
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]);
|
||||
|
||||
// Polling fallback for AirPlay and throttled events
|
||||
usePlaybackPolling({
|
||||
isPlaying,
|
||||
videoRef,
|
||||
isDraggingProgressRef,
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
setIsPlaying
|
||||
});
|
||||
|
||||
return {
|
||||
togglePlay,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleTimeUpdateEvent,
|
||||
handleLoadedMetadata,
|
||||
handleVideoError,
|
||||
changePlaybackSpeed,
|
||||
formatTime
|
||||
};
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}, [isDraggingProgressRef, updateProgressFromEvent, setCurrentTime]);
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
interface UseMobileTogglePlayProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
isPlaying: boolean;
|
||||
isTogglingRef: React.MutableRefObject<boolean>;
|
||||
setShowMoreMenu: (show: boolean) => void;
|
||||
setShowVolumeMenu: (show: boolean) => void;
|
||||
setShowSpeedMenu: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export function useMobileTogglePlay({
|
||||
videoRef,
|
||||
isPlaying,
|
||||
isTogglingRef,
|
||||
setShowMoreMenu,
|
||||
setShowVolumeMenu,
|
||||
setShowSpeedMenu
|
||||
}: UseMobileTogglePlayProps) {
|
||||
return 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]);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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 (url?: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url || src);
|
||||
showToastNotification('链接已复制到剪贴板');
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
showToastNotification('复制失败,请重试');
|
||||
}
|
||||
}, [src, showToastNotification]);
|
||||
|
||||
return {
|
||||
toggleMute,
|
||||
showToastNotification,
|
||||
handleCopyLink
|
||||
};
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import { useDoubleTap } from '@/lib/hooks/useMobilePlayer';
|
||||
|
||||
interface UseMobileGesturesProps {
|
||||
skipVideo: (seconds: number, side: 'left' | 'right') => void;
|
||||
showSkipIndicator: boolean;
|
||||
showControls: boolean;
|
||||
setShowControls: (show: boolean) => void;
|
||||
controlsTimeoutRef: MutableRefObject<NodeJS.Timeout | null>;
|
||||
isPlaying: boolean;
|
||||
togglePlay: () => void;
|
||||
}
|
||||
|
||||
export function useMobileGestures({
|
||||
skipVideo,
|
||||
showSkipIndicator,
|
||||
showControls,
|
||||
setShowControls,
|
||||
controlsTimeoutRef,
|
||||
isPlaying,
|
||||
togglePlay,
|
||||
}: UseMobileGesturesProps) {
|
||||
const { handleTap } = useDoubleTap({
|
||||
onDoubleTapLeft: () => skipVideo(10, 'left'),
|
||||
onDoubleTapRight: () => skipVideo(10, 'right'),
|
||||
onSkipContinueLeft: () => skipVideo(10, 'left'),
|
||||
onSkipContinueRight: () => skipVideo(10, 'right'),
|
||||
isSkipModeActive: showSkipIndicator,
|
||||
onSingleTap: () => {
|
||||
if (!showControls) {
|
||||
setShowControls(true);
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
if (isPlaying) {
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
setShowControls(false);
|
||||
}, 3000);
|
||||
}
|
||||
} else {
|
||||
togglePlay();
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
if (isPlaying) {
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
setShowControls(false);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return { handleTap };
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
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';
|
||||
import {
|
||||
buildPlaybackParams,
|
||||
buildProgressParams,
|
||||
buildSkipParams,
|
||||
buildFullscreenParams,
|
||||
buildUtilitiesParams,
|
||||
buildMenuParams,
|
||||
} from './mobile/mobile-player-params';
|
||||
|
||||
interface UseMobilePlayerLogicProps {
|
||||
src: string;
|
||||
poster?: string;
|
||||
initialTime: number;
|
||||
shouldAutoPlay: boolean;
|
||||
onError?: (error: string) => void;
|
||||
onTimeUpdate?: (currentTime: number, duration: number) => void;
|
||||
refs: any;
|
||||
state: any;
|
||||
}
|
||||
|
||||
export function useMobilePlayerLogic({
|
||||
src,
|
||||
initialTime,
|
||||
shouldAutoPlay,
|
||||
onError,
|
||||
onTimeUpdate,
|
||||
refs,
|
||||
state
|
||||
}: UseMobilePlayerLogicProps) {
|
||||
const {
|
||||
videoRef,
|
||||
containerRef,
|
||||
progressBarRef,
|
||||
controlsTimeoutRef,
|
||||
skipTimeoutRef,
|
||||
isDraggingProgressRef,
|
||||
menuIdleTimeoutRef,
|
||||
isTogglingRef,
|
||||
toastTimeoutRef
|
||||
} = refs;
|
||||
|
||||
const {
|
||||
isPlaying, setIsPlaying,
|
||||
currentTime, setCurrentTime,
|
||||
duration, setDuration,
|
||||
volume, setVolume,
|
||||
isMuted, setIsMuted,
|
||||
isFullscreen, setIsFullscreen,
|
||||
setShowControls,
|
||||
setIsLoading,
|
||||
setPlaybackRate,
|
||||
showSpeedMenu, setShowSpeedMenu,
|
||||
showVolumeMenu, setShowVolumeMenu,
|
||||
showMoreMenu, setShowMoreMenu,
|
||||
isPiPSupported, setIsPiPSupported,
|
||||
skipAmount, setSkipAmount,
|
||||
skipSide, setSkipSide,
|
||||
setShowSkipIndicator,
|
||||
wasPlayingBeforeMenu, setWasPlayingBeforeMenu,
|
||||
setToastMessage,
|
||||
setShowToast,
|
||||
setViewportWidth
|
||||
} = state;
|
||||
|
||||
const playbackControls = useMobilePlaybackControls(buildPlaybackParams({
|
||||
videoRef, isPlaying, setIsPlaying, setIsLoading, initialTime, shouldAutoPlay, setDuration,
|
||||
setCurrentTime, setPlaybackRate, setShowMoreMenu, setShowVolumeMenu,
|
||||
setShowSpeedMenu, onTimeUpdate, onError, isDraggingProgressRef, isTogglingRef
|
||||
}));
|
||||
|
||||
const progressControls = useMobileProgressControls(buildProgressParams({
|
||||
videoRef, progressBarRef, duration, setCurrentTime, isDraggingProgressRef
|
||||
}));
|
||||
|
||||
const skipControls = useMobileSkipControls(buildSkipParams({
|
||||
videoRef, duration, setCurrentTime, skipAmount, skipSide, setSkipAmount,
|
||||
setSkipSide, setShowSkipIndicator, skipTimeoutRef
|
||||
}));
|
||||
|
||||
const fullscreenControls = useMobileFullscreenControls(buildFullscreenParams({
|
||||
containerRef, videoRef, isFullscreen, setIsFullscreen, isPiPSupported, setIsPiPSupported
|
||||
}));
|
||||
|
||||
const utilities = useMobileUtilities(buildUtilitiesParams({
|
||||
src, volume, isMuted, videoRef, setVolume, setIsMuted, setViewportWidth,
|
||||
setToastMessage, setShowToast, toastTimeoutRef
|
||||
}));
|
||||
|
||||
useMobileMenuControls(buildMenuParams({
|
||||
videoRef, isPlaying, showMoreMenu, showVolumeMenu, showSpeedMenu,
|
||||
wasPlayingBeforeMenu, setShowControls, setShowMoreMenu, setShowVolumeMenu,
|
||||
setShowSpeedMenu, setWasPlayingBeforeMenu, controlsTimeoutRef, menuIdleTimeoutRef
|
||||
}));
|
||||
|
||||
return {
|
||||
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: (type: 'original' | 'proxy' = 'original') => {
|
||||
let urlToCopy = src;
|
||||
|
||||
// If user wants original link, strip proxy prefix if present
|
||||
if (type === 'original') {
|
||||
if (urlToCopy.includes('/api/proxy?url=')) {
|
||||
const match = urlToCopy.match(/url=([^&]*)/);
|
||||
if (match && match[1]) {
|
||||
urlToCopy = decodeURIComponent(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// If user wants proxy link, ensure it has proxy prefix
|
||||
else if (type === 'proxy') {
|
||||
if (!urlToCopy.includes('/api/proxy?url=')) {
|
||||
urlToCopy = `${window.location.origin}/api/proxy?url=${encodeURIComponent(urlToCopy)}`;
|
||||
} else if (urlToCopy.startsWith('/')) {
|
||||
// Ensure absolute URL for copy
|
||||
urlToCopy = `${window.location.origin}${urlToCopy}`;
|
||||
}
|
||||
}
|
||||
|
||||
utilities.handleCopyLink(urlToCopy);
|
||||
},
|
||||
formatTime: playbackControls.formatTime
|
||||
};
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useState, useRef } from 'react';
|
||||
|
||||
export function useMobilePlayerState() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Refs for timeouts and tracking
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const skipTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isDraggingProgressRef = useRef(false);
|
||||
const menuIdleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const submenuIdleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isTogglingRef = useRef(false);
|
||||
const toastTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// State
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [playbackRate, setPlaybackRate] = useState(1);
|
||||
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
||||
const [showVolumeMenu, setShowVolumeMenu] = useState(false);
|
||||
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
||||
const [isPiPSupported, setIsPiPSupported] = useState(false);
|
||||
const [skipAmount, setSkipAmount] = useState(0);
|
||||
const [skipSide, setSkipSide] = useState<'left' | 'right' | null>(null);
|
||||
const [showSkipIndicator, setShowSkipIndicator] = useState(false);
|
||||
const [wasPlayingBeforeMenu, setWasPlayingBeforeMenu] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [viewportWidth, setViewportWidth] = useState(0);
|
||||
|
||||
return {
|
||||
refs: {
|
||||
videoRef,
|
||||
containerRef,
|
||||
progressBarRef,
|
||||
controlsTimeoutRef,
|
||||
skipTimeoutRef,
|
||||
isDraggingProgressRef,
|
||||
menuIdleTimeoutRef,
|
||||
submenuIdleTimeoutRef,
|
||||
isTogglingRef,
|
||||
toastTimeoutRef
|
||||
},
|
||||
state: {
|
||||
isPlaying, setIsPlaying,
|
||||
currentTime, setCurrentTime,
|
||||
duration, setDuration,
|
||||
volume, setVolume,
|
||||
isMuted, setIsMuted,
|
||||
isFullscreen, setIsFullscreen,
|
||||
showControls, setShowControls,
|
||||
isLoading, setIsLoading,
|
||||
playbackRate, setPlaybackRate,
|
||||
showSpeedMenu, setShowSpeedMenu,
|
||||
showVolumeMenu, setShowVolumeMenu,
|
||||
showMoreMenu, setShowMoreMenu,
|
||||
isPiPSupported, setIsPiPSupported,
|
||||
skipAmount, setSkipAmount,
|
||||
skipSide, setSkipSide,
|
||||
showSkipIndicator, setShowSkipIndicator,
|
||||
wasPlayingBeforeMenu, setWasPlayingBeforeMenu,
|
||||
toastMessage, setToastMessage,
|
||||
showToast, setShowToast,
|
||||
viewportWidth, setViewportWidth
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import React from 'react';
|
||||
import { LeftControls } from './controls/LeftControls';
|
||||
import { RightControls } from './controls/RightControls';
|
||||
|
||||
interface FullControlsProps {
|
||||
isPlaying: boolean;
|
||||
isFullscreen: boolean;
|
||||
showVolumeMenu: boolean;
|
||||
showSpeedMenu: boolean;
|
||||
showMoreMenu: boolean;
|
||||
isMuted: boolean;
|
||||
volume: number;
|
||||
playbackRate: number;
|
||||
isPiPSupported: boolean;
|
||||
isProxied?: 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;
|
||||
onCopyLink: (type?: 'original' | 'proxy') => void;
|
||||
iconSize: number;
|
||||
buttonPadding: string;
|
||||
controlsGap: string;
|
||||
textSize: string;
|
||||
}
|
||||
|
||||
export function FullControls({
|
||||
isPlaying,
|
||||
isFullscreen,
|
||||
showVolumeMenu,
|
||||
showSpeedMenu,
|
||||
showMoreMenu,
|
||||
isMuted,
|
||||
volume,
|
||||
playbackRate,
|
||||
isPiPSupported,
|
||||
isProxied,
|
||||
currentTime,
|
||||
duration,
|
||||
speeds,
|
||||
formatTime,
|
||||
onTogglePlay,
|
||||
onSkipVideo,
|
||||
onToggleFullscreen,
|
||||
onToggleVolumeMenu,
|
||||
onToggleSpeedMenu,
|
||||
onTogglePiP,
|
||||
onToggleMoreMenu,
|
||||
onToggleMute,
|
||||
onVolumeChange,
|
||||
onSpeedChange,
|
||||
onCopyLink,
|
||||
iconSize,
|
||||
buttonPadding,
|
||||
controlsGap,
|
||||
textSize
|
||||
}: FullControlsProps) {
|
||||
return (
|
||||
<div className={`flex items-center ${controlsGap}`}>
|
||||
<LeftControls
|
||||
isPlaying={isPlaying}
|
||||
onTogglePlay={onTogglePlay}
|
||||
onSkipVideo={onSkipVideo}
|
||||
isMuted={isMuted}
|
||||
volume={volume}
|
||||
showVolumeMenu={showVolumeMenu}
|
||||
onToggleVolumeMenu={onToggleVolumeMenu}
|
||||
onToggleMute={onToggleMute}
|
||||
onVolumeChange={onVolumeChange}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
formatTime={formatTime}
|
||||
iconSize={iconSize}
|
||||
buttonPadding={buttonPadding}
|
||||
textSize={textSize}
|
||||
controlsGap={controlsGap}
|
||||
/>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<RightControls
|
||||
playbackRate={playbackRate}
|
||||
showSpeedMenu={showSpeedMenu}
|
||||
showMoreMenu={showMoreMenu}
|
||||
onToggleSpeedMenu={onToggleSpeedMenu}
|
||||
speeds={speeds}
|
||||
onSpeedChange={onSpeedChange}
|
||||
isPiPSupported={isPiPSupported}
|
||||
isProxied={isProxied}
|
||||
onTogglePiP={onTogglePiP}
|
||||
onToggleMoreMenu={onToggleMoreMenu}
|
||||
onToggleVolumeMenu={onToggleVolumeMenu}
|
||||
onCopyLink={onCopyLink}
|
||||
isMuted={isMuted}
|
||||
volume={volume}
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={onToggleFullscreen}
|
||||
iconSize={iconSize}
|
||||
buttonPadding={buttonPadding}
|
||||
textSize={textSize}
|
||||
controlsGap={controlsGap}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import React from 'react';
|
||||
import { MobileProgressBar } from './MobileProgressBar';
|
||||
import { CompactControls } from './CompactControls';
|
||||
import { FullControls } from './FullControls';
|
||||
|
||||
interface MobileControlsProps {
|
||||
showControls: boolean;
|
||||
isCompactLayout: boolean;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isFullscreen: boolean;
|
||||
playbackRate: number;
|
||||
showMoreMenu: boolean;
|
||||
showVolumeMenu: boolean;
|
||||
showSpeedMenu: boolean;
|
||||
isPiPSupported: boolean;
|
||||
isProxied?: boolean;
|
||||
progressBarRef: React.RefObject<HTMLDivElement | null>;
|
||||
onTogglePlay: () => void;
|
||||
onSkipVideo: (seconds: number, side: 'left' | 'right') => void;
|
||||
onToggleMute: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
onToggleMoreMenu: () => void;
|
||||
onToggleVolumeMenu: () => void;
|
||||
onToggleSpeedMenu: () => void;
|
||||
onTogglePiP: () => void;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onCopyLink: (type?: 'original' | 'proxy') => void;
|
||||
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onProgressTouchStart: (e: React.TouchEvent<HTMLDivElement>) => void;
|
||||
onProgressTouchMove: (e: React.TouchEvent<HTMLDivElement>) => void;
|
||||
onProgressTouchEnd: (e: React.TouchEvent<HTMLDivElement>) => void;
|
||||
formatTime: (seconds: number) => string;
|
||||
speeds: number[];
|
||||
}
|
||||
|
||||
export function MobileControls(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';
|
||||
const textSize = isCompactLayout ? 'text-xs' : 'text-sm';
|
||||
const controlsPadding = isCompactLayout ? 'px-3 pb-3' : 'px-4 pb-4';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 z-50 transition-all duration-300 ${showControls ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
|
||||
}`}
|
||||
style={{ pointerEvents: showControls ? 'auto' : 'none' }}
|
||||
>
|
||||
<MobileProgressBar
|
||||
progressBarRef={progressBarRef}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onProgressClick={onProgressClick}
|
||||
onProgressTouchStart={onProgressTouchStart}
|
||||
onProgressTouchMove={onProgressTouchMove}
|
||||
onProgressTouchEnd={onProgressTouchEnd}
|
||||
/>
|
||||
|
||||
<div className={`bg-gradient-to-t from-black/90 via-black/70 to-transparent ${controlsPadding} pt-2`}>
|
||||
{isCompactLayout ? (
|
||||
<CompactControls
|
||||
{...props}
|
||||
iconSize={iconSize}
|
||||
buttonPadding={buttonPadding}
|
||||
controlsGap={controlsGap}
|
||||
textSize={textSize}
|
||||
/>
|
||||
) : (
|
||||
<FullControls
|
||||
{...props}
|
||||
iconSize={iconSize}
|
||||
buttonPadding={buttonPadding}
|
||||
controlsGap={controlsGap}
|
||||
textSize={textSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { MobileControls } from './MobileControls';
|
||||
import { useMobilePlayerState } from '../hooks/useMobilePlayerState';
|
||||
import { useMobilePlayerLogic } from '../hooks/useMobilePlayerLogic';
|
||||
|
||||
interface MobileControlsWrapperProps {
|
||||
src: string;
|
||||
state: ReturnType<typeof useMobilePlayerState>['state'];
|
||||
logic: ReturnType<typeof useMobilePlayerLogic>;
|
||||
refs: ReturnType<typeof useMobilePlayerState>['refs'];
|
||||
}
|
||||
|
||||
export function MobileControlsWrapper({ src, state, logic, refs }: MobileControlsWrapperProps) {
|
||||
const {
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
isFullscreen,
|
||||
showControls,
|
||||
playbackRate,
|
||||
showSpeedMenu,
|
||||
showVolumeMenu,
|
||||
showMoreMenu,
|
||||
isPiPSupported,
|
||||
viewportWidth,
|
||||
setShowMoreMenu,
|
||||
setShowVolumeMenu,
|
||||
setShowSpeedMenu,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
progressBarRef,
|
||||
videoRef,
|
||||
} = refs;
|
||||
|
||||
const {
|
||||
togglePlay,
|
||||
skipVideo,
|
||||
toggleMute,
|
||||
toggleFullscreen,
|
||||
togglePictureInPicture,
|
||||
changePlaybackSpeed,
|
||||
handleCopyLink,
|
||||
handleProgressClick,
|
||||
handleProgressTouchStart,
|
||||
handleProgressTouchMove,
|
||||
handleProgressTouchEnd,
|
||||
formatTime,
|
||||
} = logic;
|
||||
|
||||
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
const isCompactLayout = viewportWidth < 640;
|
||||
const isProxied = src.includes('/api/proxy'); // Calculated isProxied
|
||||
|
||||
return (
|
||||
<MobileControls
|
||||
showControls={showControls}
|
||||
isCompactLayout={isCompactLayout}
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
isFullscreen={isFullscreen}
|
||||
playbackRate={playbackRate}
|
||||
showMoreMenu={showMoreMenu}
|
||||
showVolumeMenu={showVolumeMenu}
|
||||
showSpeedMenu={showSpeedMenu}
|
||||
isPiPSupported={isPiPSupported}
|
||||
isProxied={isProxied} // Passed isProxied
|
||||
progressBarRef={progressBarRef}
|
||||
onTogglePlay={togglePlay}
|
||||
onSkipVideo={skipVideo}
|
||||
onToggleMute={toggleMute}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onToggleMoreMenu={() => setShowMoreMenu(!showMoreMenu)} // Updated to use destructured setter
|
||||
onToggleVolumeMenu={() => setShowVolumeMenu(!showVolumeMenu)} // Updated to use destructured setter
|
||||
onToggleSpeedMenu={() => setShowSpeedMenu(!showSpeedMenu)} // Updated to use destructured setter
|
||||
onTogglePiP={togglePictureInPicture}
|
||||
onVolumeChange={(newVolume) => {
|
||||
// Volume change logic
|
||||
}} // Updated onVolumeChange
|
||||
onSpeedChange={changePlaybackSpeed}
|
||||
onCopyLink={handleCopyLink}
|
||||
onProgressClick={handleProgressClick}
|
||||
onProgressTouchStart={handleProgressTouchStart}
|
||||
onProgressTouchMove={handleProgressTouchMove}
|
||||
onProgressTouchEnd={handleProgressTouchEnd}
|
||||
formatTime={formatTime}
|
||||
speeds={speeds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { usePlayerSettings } from '../hooks/usePlayerSettings';
|
||||
|
||||
interface MobileMoreMenuProps {
|
||||
showMoreMenu: boolean;
|
||||
isMuted: boolean;
|
||||
volume: number;
|
||||
playbackRate: number;
|
||||
isPiPSupported: boolean;
|
||||
isProxied?: boolean;
|
||||
onCopyLink: (type?: 'original' | 'proxy') => void;
|
||||
onToggleVolumeMenu: () => void;
|
||||
onToggleSpeedMenu: () => void;
|
||||
onTogglePiP: () => void;
|
||||
}
|
||||
|
||||
export function MobileMoreMenu({
|
||||
showMoreMenu,
|
||||
isMuted,
|
||||
volume,
|
||||
playbackRate,
|
||||
isPiPSupported,
|
||||
isProxied = false,
|
||||
onCopyLink,
|
||||
onToggleVolumeMenu,
|
||||
onToggleSpeedMenu,
|
||||
onTogglePiP
|
||||
}: MobileMoreMenuProps) {
|
||||
const {
|
||||
autoNextEpisode,
|
||||
autoSkipIntro,
|
||||
skipIntroSeconds,
|
||||
autoSkipOutro,
|
||||
skipOutroSeconds,
|
||||
showModeIndicator,
|
||||
setAutoNextEpisode,
|
||||
setAutoSkipIntro,
|
||||
setSkipIntroSeconds,
|
||||
setAutoSkipOutro,
|
||||
setSkipOutroSeconds,
|
||||
setShowModeIndicator,
|
||||
} = usePlayerSettings();
|
||||
|
||||
if (!showMoreMenu) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full right-0 mb-2 min-w-[200px] z-[100] menu-container">
|
||||
<div className="bg-[rgba(255,255,255,0.1)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-[rgba(255,255,255,0.2)] shadow-[0_8px_32px_rgba(0,0,0,0.4)] overflow-hidden">
|
||||
{/* Copy Link Option */}
|
||||
{isProxied ? (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopyLink('original');
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation cursor-pointer"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<Icons.Link size={18} />
|
||||
<span>复制原链接</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopyLink('proxy');
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation cursor-pointer border-t border-white/10"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<Icons.Link size={18} />
|
||||
<span>复制代理链接</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopyLink('original');
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation cursor-pointer"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<Icons.Link size={18} />
|
||||
<span>复制链接</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-white/10 my-1" />
|
||||
|
||||
{/* Show Mode Indicator Switch */}
|
||||
<div
|
||||
className="w-full px-4 py-3 flex items-center justify-between"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-sm text-white">
|
||||
<Icons.Zap size={18} />
|
||||
<span>显示模式指示器</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModeIndicator(!showModeIndicator);
|
||||
}}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${showModeIndicator ? 'bg-[var(--accent-color)]' : 'bg-white/20'
|
||||
}`}
|
||||
aria-checked={showModeIndicator}
|
||||
role="switch"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${showModeIndicator ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto Next Episode Switch */}
|
||||
<div
|
||||
className="w-full px-4 py-3 flex items-center justify-between"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-sm text-white">
|
||||
<Icons.SkipForward size={18} />
|
||||
<span>自动下一集</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAutoNextEpisode(!autoNextEpisode);
|
||||
}}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${autoNextEpisode ? 'bg-[var(--accent-color)]' : 'bg-white/20'
|
||||
}`}
|
||||
aria-checked={autoNextEpisode}
|
||||
role="switch"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${autoNextEpisode ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Skip Intro Switch */}
|
||||
<div className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-sm text-white">
|
||||
<Icons.FastForward size={18} />
|
||||
<span>跳过片头</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAutoSkipIntro(!autoSkipIntro);
|
||||
}}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${autoSkipIntro ? 'bg-[var(--accent-color)]' : 'bg-white/20'
|
||||
}`}
|
||||
aria-checked={autoSkipIntro}
|
||||
role="switch"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${autoSkipIntro ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* Expandable Input */}
|
||||
{autoSkipIntro && (
|
||||
<div className="mt-2 ml-7 flex items-center gap-2">
|
||||
<span className="text-xs text-white/70">时长:</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min="0"
|
||||
max="600"
|
||||
value={skipIntroSeconds}
|
||||
onChange={(e) => setSkipIntroSeconds(parseInt(e.target.value) || 0)}
|
||||
className="w-16 px-2 py-1 text-sm text-center bg-white/10 border border-white/20 rounded-[var(--radius-2xl)] text-white focus:outline-none focus:border-[var(--accent-color)] no-spinner"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-xs text-white/70">秒</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skip Outro Switch */}
|
||||
<div className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-sm text-white">
|
||||
<Icons.Rewind size={18} />
|
||||
<span>跳过片尾</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAutoSkipOutro(!autoSkipOutro);
|
||||
}}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${autoSkipOutro ? 'bg-[var(--accent-color)]' : 'bg-white/20'
|
||||
}`}
|
||||
aria-checked={autoSkipOutro}
|
||||
role="switch"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${autoSkipOutro ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* Expandable Input */}
|
||||
{autoSkipOutro && (
|
||||
<div className="mt-2 ml-7 flex items-center gap-2">
|
||||
<span className="text-xs text-white/70">剩余:</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min="0"
|
||||
max="600"
|
||||
value={skipOutroSeconds}
|
||||
onChange={(e) => setSkipOutroSeconds(parseInt(e.target.value) || 0)}
|
||||
className="w-16 px-2 py-1 text-sm text-center bg-white/10 border border-white/20 rounded-[var(--radius-2xl)] text-white focus:outline-none focus:border-[var(--accent-color)] no-spinner"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-xs text-white/70">秒</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-white/10 my-1" />
|
||||
|
||||
{/* Volume Option */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleVolumeMenu();
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation cursor-pointer"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
{isMuted || volume === 0 ? <Icons.VolumeX size={18} /> : <Icons.Volume2 size={18} />}
|
||||
<span>音量</span>
|
||||
</button>
|
||||
|
||||
{/* Speed Option */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSpeedMenu();
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation cursor-pointer"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span>速度 {playbackRate}x</span>
|
||||
</button>
|
||||
|
||||
{/* PiP Option */}
|
||||
{isPiPSupported && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePiP();
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation cursor-pointer"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<Icons.PictureInPicture size={18} />
|
||||
<span>画中画</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface MobileOverlayProps {
|
||||
isLoading: boolean;
|
||||
showToast: boolean;
|
||||
toastMessage: string | null;
|
||||
}
|
||||
|
||||
export function MobileOverlay({
|
||||
isLoading,
|
||||
showToast,
|
||||
toastMessage
|
||||
}: MobileOverlayProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Loading Spinner - Glass Effect */}
|
||||
{isLoading && (
|
||||
<div className="loading-overlay-glass">
|
||||
<div className="spinner-glass"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast Notification */}
|
||||
{showToast && toastMessage && (
|
||||
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-[200] animate-slide-up" style={{ transform: 'translate(-50%, 0) translateZ(0)' }}>
|
||||
<div className="bg-[rgba(28,28,30,0.95)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-white/20 shadow-[0_8px_32px_rgba(0,0,0,0.6)] px-6 py-3 flex items-center gap-3 min-w-[200px] max-w-[90vw]">
|
||||
<Icons.Check size={18} className="text-[#34c759] flex-shrink-0" />
|
||||
<span className="text-white text-sm font-medium">{toastMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import React, { RefObject } from 'react';
|
||||
|
||||
interface MobileProgressBarProps {
|
||||
progressBarRef: RefObject<HTMLDivElement | null>;
|
||||
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onProgressTouchStart: (e: React.TouchEvent<HTMLDivElement>) => void;
|
||||
onProgressTouchMove: (e: React.TouchEvent<HTMLDivElement>) => void;
|
||||
onProgressTouchEnd: (e: React.TouchEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export function MobileProgressBar({
|
||||
progressBarRef,
|
||||
currentTime,
|
||||
duration,
|
||||
onProgressClick,
|
||||
onProgressTouchStart,
|
||||
onProgressTouchMove,
|
||||
onProgressTouchEnd
|
||||
}: MobileProgressBarProps) {
|
||||
return (
|
||||
<div className="px-4 pb-2">
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="h-1 bg-white/30 rounded-full cursor-pointer"
|
||||
onClick={onProgressClick}
|
||||
onTouchStart={onProgressTouchStart}
|
||||
onTouchMove={onProgressTouchMove}
|
||||
onTouchEnd={onProgressTouchEnd}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[var(--accent-color)] rounded-full relative"
|
||||
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface MobileSkipIndicatorProps {
|
||||
showSkipIndicator: boolean;
|
||||
skipSide: 'left' | 'right' | null;
|
||||
skipAmount: number;
|
||||
}
|
||||
|
||||
export function MobileSkipIndicator({
|
||||
showSkipIndicator,
|
||||
skipSide,
|
||||
skipAmount
|
||||
}: MobileSkipIndicatorProps) {
|
||||
if (!showSkipIndicator || !skipSide) return null;
|
||||
|
||||
return (
|
||||
<div className={`absolute top-1/2 -translate-y-1/2 pointer-events-none ${skipSide === 'left' ? 'left-8' : 'right-8'}`}>
|
||||
<div className="text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] animate-scale-in">
|
||||
{skipSide === 'left' ? '-' : '+'}{skipAmount}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface MobileSpeedMenuProps {
|
||||
showSpeedMenu: boolean;
|
||||
isCompactLayout: boolean;
|
||||
playbackRate: number;
|
||||
speeds: number[];
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MobileSpeedMenu({
|
||||
showSpeedMenu,
|
||||
isCompactLayout,
|
||||
playbackRate,
|
||||
speeds,
|
||||
onSpeedChange,
|
||||
onClose
|
||||
}: MobileSpeedMenuProps) {
|
||||
if (!showSpeedMenu) return null;
|
||||
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-white/20 menu-container">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{speeds.map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSpeedChange(speed);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-[var(--radius-full)] text-xs font-medium transition-colors cursor-pointer ${playbackRate === speed
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'bg-white/20 text-white hover:bg-white/30'
|
||||
}`}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white text-xs mt-2 cursor-pointer"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full right-0 mb-2 z-[100] menu-container">
|
||||
<div className="bg-[rgba(255,255,255,0.1)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-[rgba(255,255,255,0.2)] shadow-[0_8px_32px_rgba(0,0,0,0.4)] p-2 flex gap-2">
|
||||
{speeds.map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSpeedChange(speed);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-[var(--radius-full)] text-xs font-medium transition-colors whitespace-nowrap cursor-pointer ${playbackRate === speed
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'bg-white/20 text-white hover:bg-white/30'
|
||||
}`}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface MobileVolumeMenuProps {
|
||||
showVolumeMenu: boolean;
|
||||
isCompactLayout: boolean;
|
||||
isMuted: boolean;
|
||||
volume: number;
|
||||
onToggleMute: () => void;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MobileVolumeMenu({
|
||||
showVolumeMenu,
|
||||
isCompactLayout,
|
||||
isMuted,
|
||||
volume,
|
||||
onToggleMute,
|
||||
onVolumeChange,
|
||||
onClose
|
||||
}: MobileVolumeMenuProps) {
|
||||
if (!showVolumeMenu) return null;
|
||||
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-white/20 menu-container">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onToggleMute} className="btn-icon p-2">
|
||||
{isMuted || volume === 0 ? <Icons.VolumeX size={18} /> : <Icons.Volume2 size={18} />}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={isMuted ? 0 : volume * 100}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value) / 100)}
|
||||
className="w-full h-1 bg-white/30 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-white text-xs font-medium tabular-nums min-w-[2rem]">
|
||||
{Math.round((isMuted ? 0 : volume) * 100)}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white text-xs cursor-pointer"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 mb-2 z-[100] menu-container">
|
||||
<div className="bg-[rgba(255,255,255,0.1)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-[rgba(255,255,255,0.2)] shadow-[0_8px_32px_rgba(0,0,0,0.4)] p-3 flex flex-col items-center gap-2 min-w-[48px]">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleMute();
|
||||
}}
|
||||
className="btn-icon p-1.5"
|
||||
>
|
||||
{isMuted || volume === 0 ? <Icons.VolumeX size={18} /> : <Icons.Volume2 size={18} />}
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onVolumeChange(parseFloat(e.target.value));
|
||||
}}
|
||||
className="h-24 w-1 bg-white/30 rounded-full appearance-none cursor-pointer [writing-mode:vertical-lr] [direction:rtl] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
|
||||
style={{ WebkitAppearance: 'slider-vertical' }}
|
||||
/>
|
||||
|
||||
<span className="text-white text-xs font-medium tabular-nums">
|
||||
{Math.round((isMuted ? 0 : volume) * 100)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { MobileVolumeMenu } from '../MobileVolumeMenu';
|
||||
|
||||
interface LeftControlsProps {
|
||||
isPlaying: boolean;
|
||||
onTogglePlay: () => void;
|
||||
onSkipVideo: (seconds: number, side: 'left' | 'right') => void;
|
||||
isMuted: boolean;
|
||||
volume: number;
|
||||
showVolumeMenu: boolean;
|
||||
onToggleVolumeMenu: () => void;
|
||||
onToggleMute: () => void;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
formatTime: (seconds: number) => string;
|
||||
iconSize: number;
|
||||
buttonPadding: string;
|
||||
textSize: string;
|
||||
controlsGap: string;
|
||||
}
|
||||
|
||||
export function LeftControls({
|
||||
isPlaying,
|
||||
onTogglePlay,
|
||||
onSkipVideo,
|
||||
isMuted,
|
||||
volume,
|
||||
showVolumeMenu,
|
||||
onToggleVolumeMenu,
|
||||
onToggleMute,
|
||||
onVolumeChange,
|
||||
currentTime,
|
||||
duration,
|
||||
formatTime,
|
||||
iconSize,
|
||||
buttonPadding,
|
||||
textSize,
|
||||
controlsGap,
|
||||
}: LeftControlsProps) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { MobileSpeedMenu } from '../MobileSpeedMenu';
|
||||
import { MobileMoreMenu } from '../MobileMoreMenu';
|
||||
|
||||
interface RightControlsProps {
|
||||
playbackRate: number;
|
||||
showSpeedMenu: boolean;
|
||||
showMoreMenu: boolean;
|
||||
onToggleSpeedMenu: () => void;
|
||||
speeds: number[];
|
||||
onSpeedChange: (speed: number) => void;
|
||||
isPiPSupported: boolean;
|
||||
isProxied?: boolean;
|
||||
onTogglePiP: () => void;
|
||||
onToggleMoreMenu: () => void;
|
||||
onToggleVolumeMenu: () => void;
|
||||
onCopyLink: (type?: 'original' | 'proxy') => void;
|
||||
isMuted: boolean;
|
||||
volume: number;
|
||||
isFullscreen: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
iconSize: number;
|
||||
buttonPadding: string;
|
||||
textSize: string;
|
||||
controlsGap: string;
|
||||
}
|
||||
|
||||
export function RightControls({
|
||||
playbackRate,
|
||||
showSpeedMenu,
|
||||
showMoreMenu,
|
||||
onToggleSpeedMenu,
|
||||
speeds,
|
||||
onSpeedChange,
|
||||
isPiPSupported,
|
||||
isProxied,
|
||||
onTogglePiP,
|
||||
onToggleMoreMenu,
|
||||
onToggleVolumeMenu,
|
||||
onCopyLink,
|
||||
isMuted,
|
||||
volume,
|
||||
isFullscreen,
|
||||
onToggleFullscreen,
|
||||
iconSize,
|
||||
buttonPadding,
|
||||
textSize,
|
||||
controlsGap,
|
||||
}: RightControlsProps) {
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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}
|
||||
isProxied={isProxied}
|
||||
onCopyLink={(type) => {
|
||||
onToggleMoreMenu();
|
||||
onCopyLink(type);
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -157,4 +157,11 @@ export const UtilityIcons = {
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
),
|
||||
MoreHorizontal: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user