Files
KVideo/components/player/DesktopVideoPlayer.tsx
T
kuekhaoyang 0e87c94a64 feat: 修复超长 URL (414) 问题,添加分辨率检测,修复高级模式 bug,提升兼容性
- 将 groupedSources 存储在 sessionStorage 中而非 URL 参数里,以防止因 URL 过长导致的 CDN 414 错误 (#112)
- 自动将旧版 groupedSources URL 迁移至短 gs 键
- 从视频元素中检测并显示实际视频分辨率(例如 1920x1080),不再依赖来源提供的标签
- 修复 PlayerNavbar 设置链接在高级模式下总是跳转到 /settings 而非 /premium/settings 的问题 (#106)
- 修复 WatchHistorySidebar 未向 HistoryList 传递 isPremium,导致历史记录项 URL 丢失 premium=1 参数的问题 (#106)
- 为 HLS 播放添加多级浏览器回退机制 (HLS.js → 原生 → 代理 → 错误并提供浏览器建议) (#104)
- 在 IPTV 播放器中添加 HEVC/H.265 级别过滤,防止 CCTV 等频道出现仅有音频播放的问题 (#81)
- 更新依赖并升级版本至 4.5.0
2026-03-12 22:58:30 +08:00

301 lines
9.8 KiB
TypeScript

'use client';
import React from 'react';
import { useDesktopPlayerState } from './hooks/useDesktopPlayerState';
import { useDesktopPlayerLogic } from './hooks/useDesktopPlayerLogic';
import { useHlsPlayer } from './hooks/useHlsPlayer';
import { useAutoSkip } from './hooks/useAutoSkip';
import { useStallDetection } from './hooks/useStallDetection';
import { useVideoResolution } from './hooks/useVideoResolution';
import { DesktopControlsWrapper } from './desktop/DesktopControlsWrapper';
import { DesktopOverlayWrapper } from './desktop/DesktopOverlayWrapper';
import { DanmakuCanvas } from './DanmakuCanvas';
import { usePlayerSettings } from './hooks/usePlayerSettings';
import { useDanmaku } from './hooks/useDanmaku';
import { useIsIOS, useIsMobile } from '@/lib/hooks/mobile/useDeviceDetection';
import { useDoubleTap } from '@/lib/hooks/mobile/useDoubleTap';
import './web-fullscreen.css';
interface DesktopVideoPlayerProps {
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;
isReversed?: boolean;
// Danmaku props
videoTitle?: string;
episodeName?: string;
}
export function DesktopVideoPlayer({
src,
poster,
onError,
onTimeUpdate,
initialTime = 0,
shouldAutoPlay = false,
totalEpisodes = 1,
currentEpisodeIndex = 0,
onNextEpisode,
isReversed = false,
videoTitle = '',
episodeName = '',
}: DesktopVideoPlayerProps) {
const { refs, data, actions } = useDesktopPlayerState();
const { fullscreenType: settingsFullscreenType } = usePlayerSettings();
const isIOS = useIsIOS();
const isMobile = useIsMobile();
// Detect actual video resolution
const videoResolution = useVideoResolution(refs.videoRef);
// Danmaku
const { danmakuEnabled, setDanmakuEnabled, comments: danmakuComments } = useDanmaku({
videoTitle,
episodeName,
episodeIndex: currentEpisodeIndex,
});
// State to track if device is in landscape mode
const [isLandscape, setIsLandscape] = React.useState(true);
React.useEffect(() => {
const checkOrientation = () => {
// Check if width > height
if (typeof window !== 'undefined') {
setIsLandscape(window.innerWidth > window.innerHeight);
}
};
checkOrientation();
window.addEventListener('resize', checkOrientation);
window.addEventListener('orientationchange', checkOrientation);
return () => {
window.removeEventListener('resize', checkOrientation);
window.removeEventListener('orientationchange', checkOrientation);
};
}, []);
// Use user preference for fullscreen type, resolving 'auto' to device default
// Auto Rules:
// - Mobile: Window Fullscreen (Better for Danmaku/Controls)
// - Desktop: Native Fullscreen (Better for PiP/Performance)
const fullscreenType = settingsFullscreenType === 'auto'
? (isIOS ? 'window' : isMobile ? 'window' : 'native') // Treat all mobile as window for consistency if auto
: settingsFullscreenType;
// Check if we need to force landscape (iOS + Fullscreen + Portrait)
const shouldForceLandscape = data.isFullscreen && fullscreenType === 'window' && isIOS && !isLandscape;
// Initialize HLS Player
useHlsPlayer({
videoRef: refs.videoRef,
src,
autoPlay: shouldAutoPlay
});
const {
videoRef,
containerRef,
} = refs;
const {
isPlaying,
currentTime,
duration,
} = data;
const {
setShowControls,
setIsLoading,
setCurrentTime,
setDuration,
} = actions;
// Reset loading state and show spinner when source changes
React.useEffect(() => {
setIsLoading(true);
}, [src, setIsLoading]);
const logic = useDesktopPlayerLogic({
src,
initialTime,
shouldAutoPlay,
onError,
onTimeUpdate,
refs,
data,
actions,
fullscreenType,
isForceLandscape: shouldForceLandscape
});
// Auto-skip intro/outro and auto-next episode
const { isOutroActive, isTransitioningToNextEpisode } = useAutoSkip({
videoRef,
currentTime,
duration,
isPlaying,
totalEpisodes,
currentEpisodeIndex,
onNextEpisode,
isReversed,
src,
});
// Sensitive stalling detection (e.g. video stuck but HTML5 state says playing)
useStallDetection({
videoRef,
isPlaying: data.isPlaying,
isDraggingProgressRef: refs.isDraggingProgressRef,
setIsLoading: actions.setIsLoading,
isTransitioningToNextEpisode
});
const {
handleMouseMove,
handleTouchToggleControls,
togglePlay,
handlePlay,
handlePause,
handleTimeUpdateEvent,
handleLoadedMetadata,
handleVideoError,
} = logic;
// Mobile double-tap gesture for skip forward/backward
const { handleTap } = useDoubleTap({
onSingleTap: handleTouchToggleControls,
onDoubleTapLeft: () => {
logic.skipBackward();
handleMouseMove(); // Reset 3s auto-hide timer
},
onDoubleTapRight: () => {
logic.skipForward();
handleMouseMove(); // Reset 3s auto-hide timer
},
onSkipContinueLeft: () => {
logic.skipBackward();
handleMouseMove();
},
onSkipContinueRight: () => {
logic.skipForward();
handleMouseMove();
},
isSkipModeActive: data.showSkipForwardIndicator || data.showSkipBackwardIndicator,
});
return (
<div
ref={containerRef}
className={`kvideo-container relative aspect-video bg-black rounded-[var(--radius-2xl)] group ${data.isFullscreen && fullscreenType === 'window' ? 'is-web-fullscreen' : ''
} ${shouldForceLandscape ? 'force-landscape' : ''}`}
onMouseMove={handleMouseMove}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
{/* Clipping Wrapper for video and overlays - Restores the 'Liquid Glass' rounded look */}
<div className={`absolute inset-0 overflow-hidden pointer-events-none ${data.isFullscreen && fullscreenType === 'window' ? 'rounded-0' : 'rounded-[var(--radius-2xl)]'
}`}>
<div className="absolute inset-0 pointer-events-auto">
{/* Video Element */}
<video
ref={videoRef}
className="w-full h-full object-contain"
poster={poster}
x-webkit-airplay="allow"
playsInline={true} // Crucial for iOS custom fullscreen to work without native player taking over
controls={false} // Explicitly disable native controls
onPlay={handlePlay}
onPause={handlePause}
onTimeUpdate={handleTimeUpdateEvent}
onLoadedMetadata={handleLoadedMetadata}
onError={handleVideoError}
onWaiting={() => setIsLoading(true)}
onCanPlay={() => setIsLoading(false)}
onClick={!isMobile ? (e) => {
togglePlay();
} : undefined}
onTouchStart={isMobile ? handleTap : undefined}
{...({ 'webkit-playsinline': 'true' } as any)} // Legacy iOS support
/>
{/* Danmaku Canvas */}
{danmakuEnabled && danmakuComments.length > 0 && (
<DanmakuCanvas
comments={danmakuComments}
currentTime={currentTime}
isPlaying={isPlaying}
duration={duration}
/>
)}
{/* Video Resolution Badge - shows actual resolution from video stream */}
{videoResolution && (
<div className="absolute top-3 left-3 z-20 pointer-events-none">
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-white ${videoResolution.color} opacity-80`}>
{videoResolution.label}
<span className="font-normal opacity-80">{videoResolution.width}x{videoResolution.height}</span>
</span>
</div>
)}
<DesktopOverlayWrapper
data={data}
actions={actions}
showControls={data.showControls}
isRotated={shouldForceLandscape}
onTogglePlay={togglePlay}
onSkipForward={logic.skipForward}
onSkipBackward={logic.skipBackward}
isTransitioningToNextEpisode={isTransitioningToNextEpisode}
// More Menu Props
showMoreMenu={data.showMoreMenu}
isProxied={src.includes('/api/proxy')}
onToggleMoreMenu={() => actions.setShowMoreMenu(!data.showMoreMenu)}
onMoreMenuMouseEnter={() => {
if (refs.moreMenuTimeoutRef.current) {
clearTimeout(refs.moreMenuTimeoutRef.current);
refs.moreMenuTimeoutRef.current = null;
}
}}
onMoreMenuMouseLeave={() => {
if (refs.moreMenuTimeoutRef.current) {
clearTimeout(refs.moreMenuTimeoutRef.current);
}
refs.moreMenuTimeoutRef.current = setTimeout(() => {
actions.setShowMoreMenu(false);
refs.moreMenuTimeoutRef.current = null;
}, 800); // Increased timeout for better stability
}}
onCopyLink={logic.handleCopyLink}
// Speed Menu Props
playbackRate={data.playbackRate}
showSpeedMenu={data.showSpeedMenu}
speeds={[0.5, 0.75, 1, 1.25, 1.5, 2]}
onToggleSpeedMenu={() => actions.setShowSpeedMenu(!data.showSpeedMenu)}
onSpeedChange={logic.changePlaybackSpeed}
onSpeedMenuMouseEnter={logic.clearSpeedMenuTimeout}
onSpeedMenuMouseLeave={logic.startSpeedMenuTimeout}
// Portal container
containerRef={containerRef}
/>
<DesktopControlsWrapper
src={src}
data={data}
actions={actions}
logic={logic}
refs={refs}
/>
</div>
</div>
</div>
);
}