From c8b7544f36c3aa4726ad07158e81009bff25b453 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Thu, 2 Apr 2026 20:35:27 +0800 Subject: [PATCH] fix: stabilize iOS web fullscreen across auto-next --- components/player/DesktopVideoPlayer.tsx | 116 ++++++++++++++++++----- components/player/web-fullscreen.css | 32 ++----- 2 files changed, 103 insertions(+), 45 deletions(-) diff --git a/components/player/DesktopVideoPlayer.tsx b/components/player/DesktopVideoPlayer.tsx index a45f632..49ac611 100644 --- a/components/player/DesktopVideoPlayer.tsx +++ b/components/player/DesktopVideoPlayer.tsx @@ -22,6 +22,36 @@ type WebFullscreenSize = 'full' | 'large' | 'focused'; const WEB_FULLSCREEN_SIZE_KEY = 'kvideo-web-fullscreen-size'; const WEB_FULLSCREEN_SIZE_ORDER: WebFullscreenSize[] = ['full', 'large', 'focused']; +const WEB_FULLSCREEN_SCALE: Record = { + full: 1, + large: 0.92, + focused: 0.84, +}; + +interface ViewportMetrics { + width: number; + height: number; +} + +type LegacyInlineVideoProps = React.VideoHTMLAttributes & { + 'webkit-playsinline'?: 'true'; +}; + +const LEGACY_INLINE_VIDEO_PROPS: LegacyInlineVideoProps = { + 'webkit-playsinline': 'true', +}; + +function readViewportMetrics(): ViewportMetrics { + if (typeof window === 'undefined') { + return { width: 0, height: 0 }; + } + + const viewport = window.visualViewport; + return { + width: Math.round(viewport?.width ?? window.innerWidth ?? 0), + height: Math.round(viewport?.height ?? window.innerHeight ?? 0), + }; +} interface DesktopVideoPlayerProps { src: string; @@ -63,6 +93,7 @@ export function DesktopVideoPlayer({ const { fullscreenType: settingsFullscreenType } = usePlayerSettings(isPremium); const isIOS = useIsIOS(); const isMobile = useIsMobile(); + const [viewportMetrics, setViewportMetrics] = React.useState(() => readViewportMetrics()); const [seekStepSeconds, setSeekStepSeconds] = React.useState(DEFAULT_SEEK_STEP_SECONDS); const [webFullscreenSize, setWebFullscreenSize] = React.useState(() => { if (typeof window === 'undefined') return 'full'; @@ -88,25 +119,30 @@ export function DesktopVideoPlayer({ episodeIndex: currentEpisodeIndex, }); - // State to track if device is in landscape mode - const [isLandscape, setIsLandscape] = React.useState(true); + const updateViewportMetrics = React.useCallback(() => { + setViewportMetrics((current) => { + const next = readViewportMetrics(); + if (current.width === next.width && current.height === next.height) { + return current; + } + return next; + }); + }, []); React.useEffect(() => { - const checkOrientation = () => { - // Check if width > height - if (typeof window !== 'undefined') { - setIsLandscape(window.innerWidth > window.innerHeight); - } - }; + updateViewportMetrics(); + + const visualViewport = window.visualViewport; + window.addEventListener('resize', updateViewportMetrics); + window.addEventListener('orientationchange', updateViewportMetrics); + visualViewport?.addEventListener('resize', updateViewportMetrics); - checkOrientation(); - window.addEventListener('resize', checkOrientation); - window.addEventListener('orientationchange', checkOrientation); return () => { - window.removeEventListener('resize', checkOrientation); - window.removeEventListener('orientationchange', checkOrientation); + window.removeEventListener('resize', updateViewportMetrics); + window.removeEventListener('orientationchange', updateViewportMetrics); + visualViewport?.removeEventListener('resize', updateViewportMetrics); }; - }, []); + }, [updateViewportMetrics]); // Use user preference for fullscreen type, resolving 'auto' to device default // Auto Rules: @@ -116,9 +152,25 @@ export function DesktopVideoPlayer({ ? (isIOS ? 'window' : isMobile ? 'window' : 'native') // Treat all mobile as window for consistency if auto : settingsFullscreenType; + const isLandscape = viewportMetrics.width > viewportMetrics.height; + // Check if we need to force landscape (iOS + Fullscreen + Portrait) const shouldForceLandscape = data.fullscreenMode === 'window' && isIOS && !isLandscape; + React.useEffect(() => { + updateViewportMetrics(); + + if (data.fullscreenMode !== 'window') return; + + const rafId = window.requestAnimationFrame(updateViewportMetrics); + const timeoutId = window.setTimeout(updateViewportMetrics, 250); + + return () => { + window.cancelAnimationFrame(rafId); + window.clearTimeout(timeoutId); + }; + }, [data.fullscreenMode, src, updateViewportMetrics]); + React.useEffect(() => { localStorage.setItem(WEB_FULLSCREEN_SIZE_KEY, webFullscreenSize); }, [webFullscreenSize]); @@ -167,6 +219,7 @@ export function DesktopVideoPlayer({ const { videoRef, containerRef, + moreMenuTimeoutRef, } = refs; const { @@ -243,8 +296,24 @@ export function DesktopVideoPlayer({ }); }, []); + const webFullscreenStyle = React.useMemo(() => { + if (data.fullscreenMode !== 'window') return undefined; + if (viewportMetrics.width <= 0 || viewportMetrics.height <= 0) return undefined; + + const stageWidth = shouldForceLandscape ? viewportMetrics.height : viewportMetrics.width; + const stageHeight = shouldForceLandscape ? viewportMetrics.width : viewportMetrics.height; + + return { + ['--kvideo-viewport-width' as string]: `${viewportMetrics.width}px`, + ['--kvideo-viewport-height' as string]: `${viewportMetrics.height}px`, + ['--kvideo-stage-viewport-width' as string]: `${stageWidth}px`, + ['--kvideo-stage-viewport-height' as string]: `${stageHeight}px`, + ['--kvideo-web-scale' as string]: WEB_FULLSCREEN_SCALE[webFullscreenSize].toString(), + }; + }, [data.fullscreenMode, shouldForceLandscape, viewportMetrics, webFullscreenSize]); + const stageClassName = data.fullscreenMode === 'window' - ? `kvideo-stage kvideo-web-fullscreen-stage web-fullscreen-size-${webFullscreenSize}` + ? 'kvideo-stage kvideo-web-fullscreen-stage' : 'kvideo-stage absolute inset-0'; // Mobile double-tap gesture for skip forward/backward @@ -274,6 +343,7 @@ export function DesktopVideoPlayer({ ref={containerRef} className={`kvideo-container relative aspect-video bg-black rounded-[var(--radius-2xl)] group ${data.fullscreenMode === 'window' ? 'is-web-fullscreen' : '' } ${shouldForceLandscape ? 'force-landscape' : ''}`} + style={webFullscreenStyle} onMouseMove={() => { handleMouseMove(); }} onMouseLeave={() => isPlaying && setShowControls(false)} > @@ -302,7 +372,7 @@ export function DesktopVideoPlayer({ togglePlay(); } : undefined} onTouchStart={isMobile ? handleTap : undefined} - {...({ 'webkit-playsinline': 'true' } as any)} // Legacy iOS support + {...LEGACY_INLINE_VIDEO_PROPS} // Legacy iOS support /> {/* Danmaku Canvas */} @@ -341,18 +411,18 @@ export function DesktopVideoPlayer({ isProxied={src.includes('/api/proxy')} onToggleMoreMenu={() => actions.setShowMoreMenu(!data.showMoreMenu)} onMoreMenuMouseEnter={() => { - if (refs.moreMenuTimeoutRef.current) { - clearTimeout(refs.moreMenuTimeoutRef.current); - refs.moreMenuTimeoutRef.current = null; + if (moreMenuTimeoutRef.current) { + clearTimeout(moreMenuTimeoutRef.current); + moreMenuTimeoutRef.current = null; } }} onMoreMenuMouseLeave={() => { - if (refs.moreMenuTimeoutRef.current) { - clearTimeout(refs.moreMenuTimeoutRef.current); + if (moreMenuTimeoutRef.current) { + clearTimeout(moreMenuTimeoutRef.current); } - refs.moreMenuTimeoutRef.current = setTimeout(() => { + moreMenuTimeoutRef.current = setTimeout(() => { actions.setShowMoreMenu(false); - refs.moreMenuTimeoutRef.current = null; + moreMenuTimeoutRef.current = null; }, 800); // Increased timeout for better stability }} onCopyLink={logic.handleCopyLink} diff --git a/components/player/web-fullscreen.css b/components/player/web-fullscreen.css index 7fe4acd..f46f00c 100644 --- a/components/player/web-fullscreen.css +++ b/components/player/web-fullscreen.css @@ -3,9 +3,9 @@ position: fixed !important; top: 0 !important; left: 0 !important; - width: 100vw !important; - height: 100vh !important; - height: 100dvh !important; + width: var(--kvideo-viewport-width, 100vw) !important; + height: var(--kvideo-viewport-height, 100vh) !important; + height: var(--kvideo-viewport-height, 100dvh) !important; /* Fallback for browsers supporting dvh */ z-index: 2147483647 !important; background: black !important; @@ -18,22 +18,10 @@ .kvideo-container.is-web-fullscreen .kvideo-web-fullscreen-stage { position: relative !important; - width: min(calc(100vw * var(--kvideo-web-scale)), calc(100vh * var(--kvideo-web-scale) * 16 / 9)) !important; - height: min(calc(100vh * var(--kvideo-web-scale)), calc(100vw * var(--kvideo-web-scale) * 9 / 16)) !important; - max-width: 100vw !important; - max-height: 100vh !important; -} - -.kvideo-container.is-web-fullscreen .web-fullscreen-size-full { - --kvideo-web-scale: 1; -} - -.kvideo-container.is-web-fullscreen .web-fullscreen-size-large { - --kvideo-web-scale: 0.92; -} - -.kvideo-container.is-web-fullscreen .web-fullscreen-size-focused { - --kvideo-web-scale: 0.84; + width: min(calc(var(--kvideo-stage-viewport-width, var(--kvideo-viewport-width, 100vw)) * var(--kvideo-web-scale)), calc(var(--kvideo-stage-viewport-height, var(--kvideo-viewport-height, 100vh)) * var(--kvideo-web-scale) * 16 / 9)) !important; + height: min(calc(var(--kvideo-stage-viewport-height, var(--kvideo-viewport-height, 100vh)) * var(--kvideo-web-scale)), calc(var(--kvideo-stage-viewport-width, var(--kvideo-viewport-width, 100vw)) * var(--kvideo-web-scale) * 9 / 16)) !important; + max-width: var(--kvideo-stage-viewport-width, var(--kvideo-viewport-width, 100vw)) !important; + max-height: var(--kvideo-stage-viewport-height, var(--kvideo-viewport-height, 100vh)) !important; } /* Ensure video takes up full space in web fullscreen */ @@ -45,9 +33,9 @@ /* Force Landscape Mode for Mobile (iOS mainly) */ .kvideo-container.is-web-fullscreen.force-landscape { - width: 100vh !important; - width: 100dvh !important; - height: 100vw !important; + width: var(--kvideo-viewport-height, 100vh) !important; + width: var(--kvideo-viewport-height, 100dvh) !important; + height: var(--kvideo-viewport-width, 100vw) !important; top: 50% !important; left: 50% !important; transform: translate(-50%, -50%) rotate(90deg) !important;