feat: Remove mobile video player implementation and related hooks, consolidating CustomVideoPlayer to use only the desktop player.

This commit is contained in:
kuekhaoyang
2025-12-26 20:53:14 +08:00
parent 337fcb9837
commit 3afd65ffcc
35 changed files with 491 additions and 2784 deletions
+2 -7
View File
@@ -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} />;
}
+26
View File
@@ -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
-158
View File
@@ -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>
);
}
+2 -14
View File
@@ -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}
+192 -163
View File
@@ -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>
);
}
+112 -12
View File
@@ -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 >
);
}
+57 -23
View File
@@ -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>
);
}
-115
View File
@@ -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>
);
}
-108
View File
@@ -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}
/>
);
}
-282
View File
@@ -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>
);
}
+7
View File
@@ -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>
),
};