fix: stabilize iOS web fullscreen across auto-next

This commit is contained in:
kuekhaoyang
2026-04-02 20:35:27 +08:00
parent 773f0f3dc8
commit c8b7544f36
2 changed files with 103 additions and 45 deletions
+93 -23
View File
@@ -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<WebFullscreenSize, number> = {
full: 1,
large: 0.92,
focused: 0.84,
};
interface ViewportMetrics {
width: number;
height: number;
}
type LegacyInlineVideoProps = React.VideoHTMLAttributes<HTMLVideoElement> & {
'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<ViewportMetrics>(() => readViewportMetrics());
const [seekStepSeconds, setSeekStepSeconds] = React.useState(DEFAULT_SEEK_STEP_SECONDS);
const [webFullscreenSize, setWebFullscreenSize] = React.useState<WebFullscreenSize>(() => {
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<React.CSSProperties | undefined>(() => {
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}
+10 -22
View File
@@ -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;