From 30ca94c79bd037b29a5c100ef5a2b7b3311c7698 Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Thu, 17 Sep 2026 14:17:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/PlayerControls.tsx | 53 +++++- web/src/components/Vr360Stage.tsx | 30 +++- web/src/pages/PlayerVideoStage.tsx | 223 ++++++++++++++++++-------- web/src/utils/playerLockZone.test.ts | 40 +++++ web/src/utils/playerLockZone.ts | 27 ++++ 5 files changed, 301 insertions(+), 72 deletions(-) create mode 100644 web/src/utils/playerLockZone.test.ts create mode 100644 web/src/utils/playerLockZone.ts diff --git a/web/src/components/PlayerControls.tsx b/web/src/components/PlayerControls.tsx index b53112c..3e22c81 100644 --- a/web/src/components/PlayerControls.tsx +++ b/web/src/components/PlayerControls.tsx @@ -69,6 +69,18 @@ const VR_CONTROLS_HIDE_DELAY_MS = 3000 /** VR 视角拖拽超过这个距离才算「真的在转动视角」,轻触不受影响。 */ const VR_VIEW_DRAG_DISTANCE = 6 +/** + * 当前设备是否没有真正的鼠标悬停能力(手机/平板)。 + * + * 触摸设备上「悬停保持显示」没有可靠的结束信号:手指抬起后浏览器不保证补发 + * mouseleave,标记一旦置位就再也清不掉,控制栏会永久留在画面上。这里按设备 + * 能力判断,避免依赖 MouseEvent.sourceCapabilities(实测常为 null,等于没修)。 + */ +function isHoverlessDevice(): boolean { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return true + return window.matchMedia('(hover: none)').matches +} + type SeekHint = { dir: 'back' | 'forward' seconds: number @@ -104,6 +116,11 @@ type PlayerControlsProps = { onUiVisibleChange: (visible: boolean) => void /** 外部浮层(VR 工具条/设置面板)正被鼠标悬停:此时控制栏不自动隐藏。 */ uiHold?: boolean + /** + * 画面已被锁住(播放器左侧的锁)。锁住时操作栏整条隐藏,自动隐藏计时器 + * 也不再启动——否则计时器会在用户看不见的地方继续改写可见状态。 + */ + uiLocked?: boolean subs: SubtitleTrack[] /** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */ subtitleIndex: number @@ -154,6 +171,7 @@ export function PlayerControls({ uiVisible, onUiVisibleChange, uiHold = false, + uiLocked = false, subs, subtitleIndex, onSelectSubtitle, @@ -214,6 +232,8 @@ export function PlayerControls({ const holdControlsRef = useRef(false) /** 当前是否在 VR 全景播放中。 */ const vr360ActiveRef = useRef(Boolean(vr360)) + /** 画面是否已锁住(VR 锁)。事件回调里读它,避免闭包读到过期值。 */ + const uiLockedRef = useRef(uiLocked) /** VR 画布上的按下状态:按住拖动 = 转动视角,不唤出控制栏。 */ const vrDragRef = useRef<{ x: number; y: number; moved: boolean } | null>(null) const pendingSeekRef = useRef(null) @@ -238,6 +258,10 @@ export function PlayerControls({ vr360ActiveRef.current = Boolean(vr360) }, [vr360]) + useEffect(() => { + uiLockedRef.current = uiLocked + }, [uiLocked]) + // 「保持显示」条件集中在这里:悬停控制栏、拖动进度条、打开任何菜单或面板。 // 事件回调里读 ref,渲染里读派生值,两边始终一致。 const keepControlsVisible = @@ -253,6 +277,18 @@ export function PlayerControls({ holdControlsRef.current = keepControlsVisible }, [keepControlsVisible]) + // 控制栏「悬停保持显示」在触摸设备上没有可靠的结束信号:手指抬起后浏览器不保证 + // 补发 mouseleave,标记会一直挂着,控制栏就永远不隐藏。触摸设备直接不进入悬停 + // 保持状态,改用 pointerup 兜底清理,避免个别浏览器仍派发 mouseenter。 + useEffect(() => { + const onPointerUp = (event: PointerEvent) => { + if (event.pointerType !== 'touch') return + setControlsHovered(false) + } + window.addEventListener('pointerup', onPointerUp) + return () => window.removeEventListener('pointerup', onPointerUp) + }, []) + useEffect(() => { durationRef.current = duration }, [duration]) @@ -313,12 +349,15 @@ export function PlayerControls({ const holdControls = () => holdControlsRef.current + /** 画面锁住时,控制栏必须保持隐藏,任何「用户活动」都不应该把它唤回来。 */ + const isLocked = () => uiLockedRef.current + const hideDelay = () => vr360ActiveRef.current ? VR_CONTROLS_HIDE_DELAY_MS : CONTROLS_HIDE_DELAY_MS const resetTimer = () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current) - if (el.paused || holdControls()) return + if (el.paused || holdControls() || isLocked()) return hideTimerRef.current = setTimeout(() => { if (holdControls()) return onUiVisibleChange(false) @@ -327,6 +366,7 @@ export function PlayerControls({ // 通用「用户活动」入口:显示控制栏并重新计时。播放/暂停等事件也走这里。 const onMove = () => { + if (isLocked()) return onUiVisibleChange(true) resetTimer() } @@ -464,6 +504,8 @@ export function PlayerControls({ // 鼠标移动,计时器永远不会被安排,控制栏就会一直留在画面上。 useEffect(() => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current) + // 锁住时控制栏整条不可见,也没有任何计时器需要跑。 + if (uiLocked) return if (keepControlsVisible) { onUiVisibleChange(true) return @@ -475,7 +517,7 @@ export function PlayerControls({ if (holdControlsRef.current) return onUiVisibleChange(false) }, vr360ActiveRef.current ? VR_CONTROLS_HIDE_DELAY_MS : CONTROLS_HIDE_DELAY_MS) - }, [uiVisible, keepControlsVisible, onUiVisibleChange, video]) + }, [uiVisible, keepControlsVisible, uiLocked, onUiVisibleChange, video]) const togglePlay = () => { const el = video() @@ -554,6 +596,8 @@ export function PlayerControls({ } const revealControls = useCallback(() => { + // 锁住时键盘操作(方向键调进度/音量)不应该把控制栏唤回来。 + if (uiLockedRef.current) return onUiVisibleChange(true) if (hideTimerRef.current) clearTimeout(hideTimerRef.current) const el = video() @@ -780,7 +824,10 @@ export function PlayerControls({ className={`pointer-events-auto absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-3 pt-14 transition-opacity duration-300 ${ uiVisible ? 'opacity-100' : 'pointer-events-none opacity-0' }`} - onMouseEnter={() => setControlsHovered(true)} + onMouseEnter={() => { + if (isHoverlessDevice()) return + setControlsHovered(true) + }} onMouseLeave={() => setControlsHovered(false)} onClick={(e) => e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} diff --git a/web/src/components/Vr360Stage.tsx b/web/src/components/Vr360Stage.tsx index 65a35a9..ea87bb3 100644 --- a/web/src/components/Vr360Stage.tsx +++ b/web/src/components/Vr360Stage.tsx @@ -101,6 +101,20 @@ function screenOrientationAngle(): number { return typeof legacy === 'number' ? legacy : 0 } +/** + * 当前设备是否没有真正的鼠标悬停能力(手机/平板)。 + * + * 触摸设备上「悬停保持显示」这套机制本身就是错的:手指抬起后浏览器不保证补发 + * mouseleave,一旦走进悬停状态标记就再也清不掉,控制栏和 VR 工具条会永久留在 + * 画面上。这里按设备能力判断,而不是去猜某个鼠标事件是不是触摸合成的—— + * 后者依赖 MouseEvent.sourceCapabilities,实测在不少浏览器里是 null,判断会 + * 静默失效、等于没修。 + */ +function isHoverlessDevice(): boolean { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return true + return window.matchMedia('(hover: none)').matches +} + type DeviceOrientationEventConstructor = { requestPermission?: () => Promise<'granted' | 'denied' | 'default'> } @@ -415,6 +429,10 @@ export function Vr360Stage({ }, []) const handlePointerDown = (event: ReactPointerEvent) => { + // 轻触画面把视角拖动之外的浮层悬停状态清掉:触摸设备不会再发出 mouseleave, + // 上一步点击工具条/设置面板留下的「悬停保持显示」会一直挂着,控制栏就永远 + // 不隐藏了。用户已经在点画面,说明指针早已离开浮层。 + if (event.pointerType === 'touch') onUiHoldChange?.(false) try { event.currentTarget.setPointerCapture?.(event.pointerId) } catch { @@ -528,7 +546,12 @@ export function Vr360Stage({ // 抢走拖动转视角的手势,也可能被误点(例如误开陀螺仪、误开设置面板)。 uiVisible ? 'pointer-events-auto' : 'pointer-events-none' }`} - onMouseEnter={() => onUiHoldChange?.(true)} + // 触摸设备不参与「悬停保持显示」:手指抬起后不会补发 mouseleave, + // 标记会永久挂在打开状态,控制栏和 VR 工具条就再也不自动隐藏。 + onMouseEnter={() => { + if (isHoverlessDevice()) return + onUiHoldChange?.(true) + }} onMouseLeave={() => onUiHoldChange?.(false)} > + {/* 解锁提示:操作栏此时已经藏起来了,用户点锁之后需要知道发生了什么。 */} + {lockHint ? ( +
+ 已解锁,点画面可显示操作栏
) : null} {playerError ? ( diff --git a/web/src/utils/playerLockZone.test.ts b/web/src/utils/playerLockZone.test.ts new file mode 100644 index 0000000..904d016 --- /dev/null +++ b/web/src/utils/playerLockZone.test.ts @@ -0,0 +1,40 @@ +import { + LOCK_ZONE_HEIGHT, + LOCK_ZONE_WIDTH, + isPointerInLockZone, +} from './playerLockZone.ts' + +function check(name: string, condition: boolean) { + if (!condition) throw new Error(`playerLockZone: ${name}`) +} + +const H = 800 +const MID = H / 2 + +// 命中:紧贴左边缘、垂直正中。 +check('left edge at vertical center hits', isPointerInLockZone(0, MID, H)) +check('inside the zone near the left hits', isPointerInLockZone(LOCK_ZONE_WIDTH - 1, MID, H)) +check('top of the zone still hits', isPointerInLockZone(10, MID - LOCK_ZONE_HEIGHT / 2, H)) +check('bottom of the zone still hits', isPointerInLockZone(10, MID + LOCK_ZONE_HEIGHT / 2, H)) + +// 未命中:右侧超出宽度、上下超出高度、中心之外。 +check('beyond the width misses', !isPointerInLockZone(LOCK_ZONE_WIDTH + 1, MID, H)) +check('just past the width edge misses', !isPointerInLockZone(LOCK_ZONE_WIDTH + 0.5, MID, H)) +check('above the zone misses', !isPointerInLockZone(10, MID - LOCK_ZONE_HEIGHT / 2 - 1, H)) +check('below the zone misses', !isPointerInLockZone(10, MID + LOCK_ZONE_HEIGHT / 2 + 1, H)) +check('negative x misses', !isPointerInLockZone(-1, MID, H)) +check('top-left corner misses', !isPointerInLockZone(0, 0, H)) +check('right edge at center misses', !isPointerInLockZone(1200, MID, H)) + +// 不同画面高度下感应区跟着中心移动。 +check('tracks center on a short stage', isPointerInLockZone(10, 300, 600)) +check('short stage: off-center misses', !isPointerInLockZone(10, MID, 600)) + +// 脏数据一律不命中,避免锁在奇怪的位置冒出来。 +check('NaN x misses', !isPointerInLockZone(Number.NaN, MID, H)) +check('NaN y misses', !isPointerInLockZone(10, Number.NaN, H)) +check('zero stage height misses', !isPointerInLockZone(10, 0, 0)) +check('negative stage height misses', !isPointerInLockZone(10, 0, -100)) +check('NaN stage height misses', !isPointerInLockZone(10, 0, Number.NaN)) + +console.log('playerLockZone.test.ts ok') diff --git a/web/src/utils/playerLockZone.ts b/web/src/utils/playerLockZone.ts new file mode 100644 index 0000000..fb7e55f --- /dev/null +++ b/web/src/utils/playerLockZone.ts @@ -0,0 +1,27 @@ +// 播放器「锁」按钮的几何判定。 +// +// 锁按钮默认隐身:桌面端靠鼠标移到画面左侧中间把它唤出来,移动端靠点击画面。 +// 这里用坐标判断鼠标是否落在感应区里,而不是在画面上摆一个透明的热区元素—— +// 隐形热区会吃掉全景拖动的手势,也会挡住画面本身的单击。 + +/** 感应区宽度(像素),自画面左边缘向右展开。 */ +export const LOCK_ZONE_WIDTH = 72 +/** 感应区高度(像素),以画面垂直中心为基准上下各展开一半。 */ +export const LOCK_ZONE_HEIGHT = 88 + +/** + * 判断鼠标位置(相对画面左上角)是否落在锁按钮的感应区内。 + * + * 参数异常(NaN、画面高度为 0 等)一律返回 false:宁可锁不出现,也不要因为 + * 拿到脏数据让它在莫名其妙的位置冒出来。 + */ +export function isPointerInLockZone( + offsetX: number, + offsetY: number, + stageHeight: number, +): boolean { + if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) return false + if (!Number.isFinite(stageHeight) || stageHeight <= 0) return false + if (offsetX < 0 || offsetX > LOCK_ZONE_WIDTH) return false + return Math.abs(offsetY - stageHeight / 2) <= LOCK_ZONE_HEIGHT / 2 +}