mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 13:26:38 +08:00
优化问题
This commit is contained in:
@@ -69,6 +69,18 @@ const VR_CONTROLS_HIDE_DELAY_MS = 3000
|
|||||||
/** VR 视角拖拽超过这个距离才算「真的在转动视角」,轻触不受影响。 */
|
/** VR 视角拖拽超过这个距离才算「真的在转动视角」,轻触不受影响。 */
|
||||||
const VR_VIEW_DRAG_DISTANCE = 6
|
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 = {
|
type SeekHint = {
|
||||||
dir: 'back' | 'forward'
|
dir: 'back' | 'forward'
|
||||||
seconds: number
|
seconds: number
|
||||||
@@ -104,6 +116,11 @@ type PlayerControlsProps = {
|
|||||||
onUiVisibleChange: (visible: boolean) => void
|
onUiVisibleChange: (visible: boolean) => void
|
||||||
/** 外部浮层(VR 工具条/设置面板)正被鼠标悬停:此时控制栏不自动隐藏。 */
|
/** 外部浮层(VR 工具条/设置面板)正被鼠标悬停:此时控制栏不自动隐藏。 */
|
||||||
uiHold?: boolean
|
uiHold?: boolean
|
||||||
|
/**
|
||||||
|
* 画面已被锁住(播放器左侧的锁)。锁住时操作栏整条隐藏,自动隐藏计时器
|
||||||
|
* 也不再启动——否则计时器会在用户看不见的地方继续改写可见状态。
|
||||||
|
*/
|
||||||
|
uiLocked?: boolean
|
||||||
subs: SubtitleTrack[]
|
subs: SubtitleTrack[]
|
||||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||||
subtitleIndex: number
|
subtitleIndex: number
|
||||||
@@ -154,6 +171,7 @@ export function PlayerControls({
|
|||||||
uiVisible,
|
uiVisible,
|
||||||
onUiVisibleChange,
|
onUiVisibleChange,
|
||||||
uiHold = false,
|
uiHold = false,
|
||||||
|
uiLocked = false,
|
||||||
subs,
|
subs,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
onSelectSubtitle,
|
onSelectSubtitle,
|
||||||
@@ -214,6 +232,8 @@ export function PlayerControls({
|
|||||||
const holdControlsRef = useRef(false)
|
const holdControlsRef = useRef(false)
|
||||||
/** 当前是否在 VR 全景播放中。 */
|
/** 当前是否在 VR 全景播放中。 */
|
||||||
const vr360ActiveRef = useRef(Boolean(vr360))
|
const vr360ActiveRef = useRef(Boolean(vr360))
|
||||||
|
/** 画面是否已锁住(VR 锁)。事件回调里读它,避免闭包读到过期值。 */
|
||||||
|
const uiLockedRef = useRef(uiLocked)
|
||||||
/** VR 画布上的按下状态:按住拖动 = 转动视角,不唤出控制栏。 */
|
/** VR 画布上的按下状态:按住拖动 = 转动视角,不唤出控制栏。 */
|
||||||
const vrDragRef = useRef<{ x: number; y: number; moved: boolean } | null>(null)
|
const vrDragRef = useRef<{ x: number; y: number; moved: boolean } | null>(null)
|
||||||
const pendingSeekRef = useRef<number | null>(null)
|
const pendingSeekRef = useRef<number | null>(null)
|
||||||
@@ -238,6 +258,10 @@ export function PlayerControls({
|
|||||||
vr360ActiveRef.current = Boolean(vr360)
|
vr360ActiveRef.current = Boolean(vr360)
|
||||||
}, [vr360])
|
}, [vr360])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
uiLockedRef.current = uiLocked
|
||||||
|
}, [uiLocked])
|
||||||
|
|
||||||
// 「保持显示」条件集中在这里:悬停控制栏、拖动进度条、打开任何菜单或面板。
|
// 「保持显示」条件集中在这里:悬停控制栏、拖动进度条、打开任何菜单或面板。
|
||||||
// 事件回调里读 ref,渲染里读派生值,两边始终一致。
|
// 事件回调里读 ref,渲染里读派生值,两边始终一致。
|
||||||
const keepControlsVisible =
|
const keepControlsVisible =
|
||||||
@@ -253,6 +277,18 @@ export function PlayerControls({
|
|||||||
holdControlsRef.current = keepControlsVisible
|
holdControlsRef.current = keepControlsVisible
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
durationRef.current = duration
|
durationRef.current = duration
|
||||||
}, [duration])
|
}, [duration])
|
||||||
@@ -313,12 +349,15 @@ export function PlayerControls({
|
|||||||
|
|
||||||
const holdControls = () => holdControlsRef.current
|
const holdControls = () => holdControlsRef.current
|
||||||
|
|
||||||
|
/** 画面锁住时,控制栏必须保持隐藏,任何「用户活动」都不应该把它唤回来。 */
|
||||||
|
const isLocked = () => uiLockedRef.current
|
||||||
|
|
||||||
const hideDelay = () =>
|
const hideDelay = () =>
|
||||||
vr360ActiveRef.current ? VR_CONTROLS_HIDE_DELAY_MS : CONTROLS_HIDE_DELAY_MS
|
vr360ActiveRef.current ? VR_CONTROLS_HIDE_DELAY_MS : CONTROLS_HIDE_DELAY_MS
|
||||||
|
|
||||||
const resetTimer = () => {
|
const resetTimer = () => {
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||||
if (el.paused || holdControls()) return
|
if (el.paused || holdControls() || isLocked()) return
|
||||||
hideTimerRef.current = setTimeout(() => {
|
hideTimerRef.current = setTimeout(() => {
|
||||||
if (holdControls()) return
|
if (holdControls()) return
|
||||||
onUiVisibleChange(false)
|
onUiVisibleChange(false)
|
||||||
@@ -327,6 +366,7 @@ export function PlayerControls({
|
|||||||
|
|
||||||
// 通用「用户活动」入口:显示控制栏并重新计时。播放/暂停等事件也走这里。
|
// 通用「用户活动」入口:显示控制栏并重新计时。播放/暂停等事件也走这里。
|
||||||
const onMove = () => {
|
const onMove = () => {
|
||||||
|
if (isLocked()) return
|
||||||
onUiVisibleChange(true)
|
onUiVisibleChange(true)
|
||||||
resetTimer()
|
resetTimer()
|
||||||
}
|
}
|
||||||
@@ -464,6 +504,8 @@ export function PlayerControls({
|
|||||||
// 鼠标移动,计时器永远不会被安排,控制栏就会一直留在画面上。
|
// 鼠标移动,计时器永远不会被安排,控制栏就会一直留在画面上。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||||
|
// 锁住时控制栏整条不可见,也没有任何计时器需要跑。
|
||||||
|
if (uiLocked) return
|
||||||
if (keepControlsVisible) {
|
if (keepControlsVisible) {
|
||||||
onUiVisibleChange(true)
|
onUiVisibleChange(true)
|
||||||
return
|
return
|
||||||
@@ -475,7 +517,7 @@ export function PlayerControls({
|
|||||||
if (holdControlsRef.current) return
|
if (holdControlsRef.current) return
|
||||||
onUiVisibleChange(false)
|
onUiVisibleChange(false)
|
||||||
}, vr360ActiveRef.current ? VR_CONTROLS_HIDE_DELAY_MS : CONTROLS_HIDE_DELAY_MS)
|
}, vr360ActiveRef.current ? VR_CONTROLS_HIDE_DELAY_MS : CONTROLS_HIDE_DELAY_MS)
|
||||||
}, [uiVisible, keepControlsVisible, onUiVisibleChange, video])
|
}, [uiVisible, keepControlsVisible, uiLocked, onUiVisibleChange, video])
|
||||||
|
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
const el = video()
|
const el = video()
|
||||||
@@ -554,6 +596,8 @@ export function PlayerControls({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const revealControls = useCallback(() => {
|
const revealControls = useCallback(() => {
|
||||||
|
// 锁住时键盘操作(方向键调进度/音量)不应该把控制栏唤回来。
|
||||||
|
if (uiLockedRef.current) return
|
||||||
onUiVisibleChange(true)
|
onUiVisibleChange(true)
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||||
const el = video()
|
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 ${
|
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'
|
uiVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||||
}`}
|
}`}
|
||||||
onMouseEnter={() => setControlsHovered(true)}
|
onMouseEnter={() => {
|
||||||
|
if (isHoverlessDevice()) return
|
||||||
|
setControlsHovered(true)
|
||||||
|
}}
|
||||||
onMouseLeave={() => setControlsHovered(false)}
|
onMouseLeave={() => setControlsHovered(false)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onPointerUp={(e) => e.stopPropagation()}
|
onPointerUp={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@@ -101,6 +101,20 @@ function screenOrientationAngle(): number {
|
|||||||
return typeof legacy === 'number' ? legacy : 0
|
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 = {
|
type DeviceOrientationEventConstructor = {
|
||||||
requestPermission?: () => Promise<'granted' | 'denied' | 'default'>
|
requestPermission?: () => Promise<'granted' | 'denied' | 'default'>
|
||||||
}
|
}
|
||||||
@@ -415,6 +429,10 @@ export function Vr360Stage({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
// 轻触画面把视角拖动之外的浮层悬停状态清掉:触摸设备不会再发出 mouseleave,
|
||||||
|
// 上一步点击工具条/设置面板留下的「悬停保持显示」会一直挂着,控制栏就永远
|
||||||
|
// 不隐藏了。用户已经在点画面,说明指针早已离开浮层。
|
||||||
|
if (event.pointerType === 'touch') onUiHoldChange?.(false)
|
||||||
try {
|
try {
|
||||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -528,7 +546,12 @@ export function Vr360Stage({
|
|||||||
// 抢走拖动转视角的手势,也可能被误点(例如误开陀螺仪、误开设置面板)。
|
// 抢走拖动转视角的手势,也可能被误点(例如误开陀螺仪、误开设置面板)。
|
||||||
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
|
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
|
||||||
}`}
|
}`}
|
||||||
onMouseEnter={() => onUiHoldChange?.(true)}
|
// 触摸设备不参与「悬停保持显示」:手指抬起后不会补发 mouseleave,
|
||||||
|
// 标记会永久挂在打开状态,控制栏和 VR 工具条就再也不自动隐藏。
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (isHoverlessDevice()) return
|
||||||
|
onUiHoldChange?.(true)
|
||||||
|
}}
|
||||||
onMouseLeave={() => onUiHoldChange?.(false)}
|
onMouseLeave={() => onUiHoldChange?.(false)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -597,7 +620,10 @@ export function Vr360Stage({
|
|||||||
className={`w-[min(92vw,420px)] rounded-2xl border border-white/15 bg-black/75 px-3 py-2.5 text-white shadow-2xl backdrop-blur ${
|
className={`w-[min(92vw,420px)] rounded-2xl border border-white/15 bg-black/75 px-3 py-2.5 text-white shadow-2xl backdrop-blur ${
|
||||||
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
|
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
|
||||||
}`}
|
}`}
|
||||||
onMouseEnter={() => onUiHoldChange?.(true)}
|
onMouseEnter={() => {
|
||||||
|
if (isHoverlessDevice()) return
|
||||||
|
onUiHoldChange?.(true)
|
||||||
|
}}
|
||||||
onMouseLeave={() => onUiHoldChange?.(false)}
|
onMouseLeave={() => onUiHoldChange?.(false)}
|
||||||
>
|
>
|
||||||
<p className="mb-1.5 text-[10px] uppercase tracking-wide text-white/45">投影方式</p>
|
<p className="mb-1.5 text-[10px] uppercase tracking-wide text-white/45">投影方式</p>
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
||||||
import { Hand, Loader2, MousePointerClick, Rotate3d, Settings2, Smartphone, ZoomIn } from 'lucide-react'
|
import {
|
||||||
|
Hand,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
|
LockOpen,
|
||||||
|
MousePointerClick,
|
||||||
|
Rotate3d,
|
||||||
|
Smartphone,
|
||||||
|
ZoomIn,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||||
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||||
@@ -19,6 +28,7 @@ import {
|
|||||||
type SubtitlePosition,
|
type SubtitlePosition,
|
||||||
type SubtitleStylePreset,
|
type SubtitleStylePreset,
|
||||||
} from '../utils/subtitleDisplay'
|
} from '../utils/subtitleDisplay'
|
||||||
|
import { isPointerInLockZone } from '../utils/playerLockZone'
|
||||||
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
|
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
|
||||||
|
|
||||||
type SubtitleRenderGroup = {
|
type SubtitleRenderGroup = {
|
||||||
@@ -27,10 +37,13 @@ type SubtitleRenderGroup = {
|
|||||||
style: CSSProperties
|
style: CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
/** VR 模式下必须连续点击这么多次才唤出控制栏:单击太容易在转视角时误触。 */
|
/**
|
||||||
const VR_TAP_COUNT = 3
|
* 锁按钮每次被唤出后停留的时间(毫秒)。
|
||||||
/** 相邻两次点击间隔超过该毫秒数就重新开始计数。 */
|
*
|
||||||
const VR_TAP_GAP_MS = 900
|
* 锁本身默认隐身,只在「鼠标悬停感应区」或「点击画面」之后露一会儿。解锁的瞬间
|
||||||
|
* 操作栏会立刻消失,如果锁也一起消失,用户就不知道锁在哪、也没法再锁回去。
|
||||||
|
*/
|
||||||
|
const LOCK_REVEAL_MS = 2600
|
||||||
/** 首次操作说明的最长停留时间:到这里仍未点「知道了」也自动收起并记录为已看过。 */
|
/** 首次操作说明的最长停留时间:到这里仍未点「知道了」也自动收起并记录为已看过。 */
|
||||||
const VR_GUIDE_AUTO_DISMISS_MS = 20_000
|
const VR_GUIDE_AUTO_DISMISS_MS = 20_000
|
||||||
|
|
||||||
@@ -276,10 +289,19 @@ export function PlayerVideoStage({
|
|||||||
const [controlsVisible, setControlsVisible] = useState(true)
|
const [controlsVisible, setControlsVisible] = useState(true)
|
||||||
// 鼠标是否停在 VR 工具条/设置面板上:停住时控制栏不自动隐藏,否则设置面板会在点击前消失。
|
// 鼠标是否停在 VR 工具条/设置面板上:停住时控制栏不自动隐藏,否则设置面板会在点击前消失。
|
||||||
const [vrUiHovered, setVrUiHovered] = useState(false)
|
const [vrUiHovered, setVrUiHovered] = useState(false)
|
||||||
// VR 下「连续点击唤出控制栏」已累计的次数(0 表示没在计数)。
|
// 画面是否被「锁住」:锁上之后操作栏、VR 工具条、进度框全部隐藏,只剩干净的
|
||||||
const [vrTapCount, setVrTapCount] = useState(0)
|
// 画面,轻触画面也不会把它们唤回来,也不会误触发播放/暂停;想看操作栏必须先
|
||||||
const vrTapCountRef = useRef(0)
|
// 点左边的锁解锁。VR 和普通播放共用这一套状态。
|
||||||
const vrTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const [locked, setLocked] = useState(false)
|
||||||
|
// 锁按钮本身默认隐身(见下方的 lockVisible),这三个状态决定它什么时候露面:
|
||||||
|
// lockHovered —— 桌面鼠标停在感应区里(移开就收);
|
||||||
|
// lockShown —— 点了画面之后的临时显示窗口;
|
||||||
|
// lockHint —— 刚解锁,顺带显示「已解锁」提示。
|
||||||
|
const [lockHovered, setLockHovered] = useState(false)
|
||||||
|
const [lockShown, setLockShown] = useState(false)
|
||||||
|
const [lockHint, setLockHint] = useState(false)
|
||||||
|
const lockRevealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const lockVisible = lockHovered || lockShown
|
||||||
const vrActive = Boolean(vr360)
|
const vrActive = Boolean(vr360)
|
||||||
// VR 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
|
// VR 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
|
||||||
// 只按「媒体 ID」记录:切换投影方式/画幅布局不会重建画布,也就不能清掉这个
|
// 只按「媒体 ID」记录:切换投影方式/画幅布局不会重建画布,也就不能清掉这个
|
||||||
@@ -427,25 +449,58 @@ export function PlayerVideoStage({
|
|||||||
if (video.paused) void video.play()?.catch(() => undefined)
|
if (video.paused) void video.play()?.catch(() => undefined)
|
||||||
else video.pause()
|
else video.pause()
|
||||||
}
|
}
|
||||||
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
/**
|
||||||
// VR 模式下单击不再负责唤出控制栏(改成连续 3 次点击),所以这里不预置开关。
|
* 临时把锁按钮唤出来(移动端点击画面、以及刚解锁时)。
|
||||||
revealControlsOnlyRef.current = !vrActive && event.pointerType === 'touch' && !controlsVisible
|
*
|
||||||
}
|
* 每次都重新计时:连续点击时不会因为第一次的计时器到点而提前隐身。
|
||||||
|
*/
|
||||||
const resetVrTapCount = useCallback(() => {
|
const revealLock = useCallback((withUnlockHint: boolean) => {
|
||||||
vrTapCountRef.current = 0
|
setLockShown(true)
|
||||||
setVrTapCount(0)
|
setLockHint(withUnlockHint)
|
||||||
if (vrTapTimerRef.current) {
|
if (lockRevealTimerRef.current) clearTimeout(lockRevealTimerRef.current)
|
||||||
clearTimeout(vrTapTimerRef.current)
|
lockRevealTimerRef.current = setTimeout(() => {
|
||||||
vrTapTimerRef.current = null
|
lockRevealTimerRef.current = null
|
||||||
}
|
setLockShown(false)
|
||||||
|
setLockHint(false)
|
||||||
|
}, LOCK_REVEAL_MS)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 舞台任意位置的「激活」(轻触/单击):移动端控制栏隐藏时首次轻触只唤出
|
useEffect(
|
||||||
// 控制栏,控制栏已显示时再次轻触才切换播放/暂停。
|
() => () => {
|
||||||
// VR 模式下单击不切换播放,也不唤出控制栏:单击太容易误触,必须连续点击
|
if (lockRevealTimerRef.current) clearTimeout(lockRevealTimerRef.current)
|
||||||
// VR_TAP_COUNT 次才会显示控制栏;控制栏已显示时单击即收起。
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
// 触摸端没有悬停,靠点击画面把锁唤出来(顺带给刚上锁的用户一个解锁入口)。
|
||||||
|
if (event.pointerType === 'touch') revealLock(false)
|
||||||
|
// 锁住时轻触画面不做任何事;VR 模式下也不负责唤出控制栏(画面默认是干净的)。
|
||||||
|
if (locked) {
|
||||||
|
revealControlsOnlyRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 未锁的移动端:控制栏隐藏时首次轻触只唤出控制栏,不切换播放。
|
||||||
|
revealControlsOnlyRef.current =
|
||||||
|
!vrActive && event.pointerType === 'touch' && !controlsVisible
|
||||||
|
}
|
||||||
|
|
||||||
|
// 桌面端:鼠标移到画面左侧中间就把锁露出来,移开就收回去。
|
||||||
|
const handleStagePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (event.pointerType !== 'mouse') return
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect()
|
||||||
|
setLockHovered(
|
||||||
|
isPointerInLockZone(event.clientX - rect.left, event.clientY - rect.top, rect.height),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 舞台任意位置的「激活」(轻触/单击):
|
||||||
|
// 普通播放:桌面端直接切换播放/暂停;移动端控制栏隐藏时首次轻触只唤出控制栏,
|
||||||
|
// 控制栏已显示时再次轻触才切换播放/暂停。
|
||||||
|
// VR 模式:单击只负责显示/收起操作栏,不切换播放。
|
||||||
|
// 两种情况都在锁住时完全无反应——只能点锁解锁(锁由上面的 pointerdown 唤出)。
|
||||||
const handleSurfaceActivate = () => {
|
const handleSurfaceActivate = () => {
|
||||||
|
if (locked) return
|
||||||
if (revealControlsOnlyRef.current) {
|
if (revealControlsOnlyRef.current) {
|
||||||
revealControlsOnlyRef.current = false
|
revealControlsOnlyRef.current = false
|
||||||
setControlsVisible(true)
|
setControlsVisible(true)
|
||||||
@@ -453,50 +508,49 @@ export function PlayerVideoStage({
|
|||||||
}
|
}
|
||||||
revealControlsOnlyRef.current = false
|
revealControlsOnlyRef.current = false
|
||||||
if (vrActive) {
|
if (vrActive) {
|
||||||
if (controlsVisible) {
|
setControlsVisible((visible) => !visible)
|
||||||
resetVrTapCount()
|
|
||||||
setControlsVisible(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const next = vrTapCountRef.current + 1
|
|
||||||
vrTapCountRef.current = next
|
|
||||||
setVrTapCount(next)
|
|
||||||
if (vrTapTimerRef.current) {
|
|
||||||
clearTimeout(vrTapTimerRef.current)
|
|
||||||
vrTapTimerRef.current = null
|
|
||||||
}
|
|
||||||
if (next >= VR_TAP_COUNT) {
|
|
||||||
resetVrTapCount()
|
|
||||||
setControlsVisible(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 相邻两次点击间隔超过 VR_TAP_GAP_MS 就重新计数,避免把零散点击累加起来。
|
|
||||||
vrTapTimerRef.current = setTimeout(() => {
|
|
||||||
vrTapTimerRef.current = null
|
|
||||||
vrTapCountRef.current = 0
|
|
||||||
setVrTapCount(0)
|
|
||||||
}, VR_TAP_GAP_MS)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
togglePlay()
|
togglePlay()
|
||||||
}
|
}
|
||||||
// 进入 VR 全景播放时先亮一次控制栏与 VR 工具条,让用户知道入口在哪;
|
|
||||||
// 之后完全由自动隐藏和「连点画面」控制。退出 VR 时要清掉 VR 浮层的悬停状态,
|
const toggleLock = () => {
|
||||||
// 否则控制栏会以为还有浮层被悬停而一直不隐藏。
|
const next = !locked
|
||||||
|
setLocked(next)
|
||||||
|
// 锁上时立刻收起操作栏;解锁时也保持收起,这样画面在任何一侧都不会突然
|
||||||
|
// 弹出一整条浮层。解锁那一下顺带显示「已解锁」提示。
|
||||||
|
setControlsVisible(false)
|
||||||
|
revealLock(!next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复位锁定状态:切模式/换片时调用,避免上一次的锁挡住新的内容。 */
|
||||||
|
const resetLock = useCallback(() => {
|
||||||
|
setLocked(false)
|
||||||
|
setLockShown(false)
|
||||||
|
setLockHint(false)
|
||||||
|
setLockHovered(false)
|
||||||
|
if (lockRevealTimerRef.current) {
|
||||||
|
clearTimeout(lockRevealTimerRef.current)
|
||||||
|
lockRevealTimerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 进入 VR 全景播放时先亮一次操作栏与 VR 工具条,让用户知道入口在哪;
|
||||||
|
// 退出 VR 时清掉 VR 浮层的悬停状态,否则控制栏会以为还有浮层被悬停而不隐藏。
|
||||||
|
// 两种情况都要解除锁定:切模式是个明确的操作意图,不该被上一次的锁挡住。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetVrTapCount()
|
resetLock()
|
||||||
if (vrActive) {
|
if (vrActive) {
|
||||||
setControlsVisible(true)
|
setControlsVisible(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setVrUiHovered(false)
|
setVrUiHovered(false)
|
||||||
}, [vrActive, resetVrTapCount])
|
}, [resetLock, vrActive])
|
||||||
useEffect(
|
|
||||||
() => () => {
|
// 换集/换片时同样复位:新内容应该从干净的、可操作的状态开始。
|
||||||
if (vrTapTimerRef.current) clearTimeout(vrTapTimerRef.current)
|
useEffect(() => {
|
||||||
},
|
resetLock()
|
||||||
[],
|
}, [media?.id, resetLock])
|
||||||
)
|
|
||||||
|
|
||||||
// 首次操作说明只出现一次:读完了点「知道了」会立即落库;放着不管也会在
|
// 首次操作说明只出现一次:读完了点「知道了」会立即落库;放着不管也会在
|
||||||
// VR_GUIDE_AUTO_DISMISS_MS 之后自动收起并同样记录为已看过,避免每次进 VR 都弹。
|
// VR_GUIDE_AUTO_DISMISS_MS 之后自动收起并同样记录为已看过,避免每次进 VR 都弹。
|
||||||
@@ -681,6 +735,8 @@ export function PlayerVideoStage({
|
|||||||
data-player-stage
|
data-player-stage
|
||||||
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
||||||
onPointerDown={handleStagePointerDown}
|
onPointerDown={handleStagePointerDown}
|
||||||
|
onPointerMove={handleStagePointerMove}
|
||||||
|
onMouseLeave={() => setLockHovered(false)}
|
||||||
onClick={handleSurfaceActivate}
|
onClick={handleSurfaceActivate}
|
||||||
onDoubleClick={toggleFullscreen}
|
onDoubleClick={toggleFullscreen}
|
||||||
>
|
>
|
||||||
@@ -787,9 +843,10 @@ export function PlayerVideoStage({
|
|||||||
playbackRate={playerPlaybackRate}
|
playbackRate={playerPlaybackRate}
|
||||||
onPlaybackRateChange={onPlayerPlaybackRateChange}
|
onPlaybackRateChange={onPlayerPlaybackRateChange}
|
||||||
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
|
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
|
||||||
uiVisible={controlsVisible}
|
uiVisible={controlsVisible && !locked}
|
||||||
onUiVisibleChange={setControlsVisible}
|
onUiVisibleChange={setControlsVisible}
|
||||||
uiHold={vrUiHovered}
|
uiHold={vrUiHovered}
|
||||||
|
uiLocked={locked}
|
||||||
subs={subs}
|
subs={subs}
|
||||||
subtitleIndex={subtitleIndex}
|
subtitleIndex={subtitleIndex}
|
||||||
onSelectSubtitle={onSelectSubtitle}
|
onSelectSubtitle={onSelectSubtitle}
|
||||||
@@ -858,11 +915,11 @@ export function PlayerVideoStage({
|
|||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<MousePointerClick size={14} className="mt-0.5 shrink-0 text-white/45" />
|
<MousePointerClick size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||||
连续点击画面 {VR_TAP_COUNT} 次:显示控制栏(进度条、音量、画质、倍速、VR 设置)
|
单击画面:显示或收起操作栏(进度条、音量、画质、倍速、VR 设置)
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<Settings2 size={14} className="mt-0.5 shrink-0 text-white/45" />
|
<Lock size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||||
控制栏显示时单击画面即可收起
|
锁默认隐身:电脑把鼠标移到左侧中间、手机点一下画面就会浮现
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<Smartphone size={14} className="mt-0.5 shrink-0 text-white/45" />
|
<Smartphone size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||||
@@ -879,10 +936,42 @@ export function PlayerVideoStage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{/* 连点计数反馈:没有反馈时用户连点两次会觉得没反应。 */}
|
{/* 锁定按钮:贴在画面左侧正中,VR 和普通播放都有,但默认隐身。
|
||||||
{vrActive && !vrGuideVisible && !controlsVisible && vrTapCount > 0 ? (
|
桌面端把鼠标移到左侧中间就会浮现,移开即收;移动端点一下画面唤出,并在
|
||||||
<div className="pointer-events-none absolute bottom-24 left-1/2 z-30 -translate-x-1/2 rounded-full border border-white/15 bg-black/75 px-4 py-1.5 text-xs text-white/90 shadow-xl backdrop-blur">
|
LOCK_REVEAL_MS 后自动隐身。
|
||||||
再连点 {VR_TAP_COUNT - vrTapCount} 次显示控制栏
|
未锁时点一下就把操作栏、进度框和 VR 工具条全部藏起来,同时屏蔽画面轻触
|
||||||
|
(不会误触发播放/暂停);锁住后按钮变淡,提醒「这里是锁」。
|
||||||
|
注意:隐身时必须 pointer-events-none——否则这块隐形按钮会吃掉画面单击,
|
||||||
|
手机上想点画面唤出锁反而会误触上锁。 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
toggleLock()
|
||||||
|
}}
|
||||||
|
// 舞台在 pointerdown 上判断「是不是移动端轻触唤出控制栏」,锁按钮上的
|
||||||
|
// 触摸不应该参与那套判断,否则点锁会被当成一次画面轻触。
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
|
aria-hidden={!lockVisible}
|
||||||
|
tabIndex={lockVisible ? 0 : -1}
|
||||||
|
aria-label={locked ? '解锁画面,恢复操作栏' : '锁定画面,隐藏操作栏和进度框'}
|
||||||
|
title={locked ? '点一下解锁,恢复操作栏与进度框' : '锁上后画面轻触无效,只留这个锁'}
|
||||||
|
className={`absolute left-2 top-1/2 z-30 -translate-y-1/2 rounded-full border p-3 shadow-2xl backdrop-blur transition-opacity duration-200 sm:left-4 ${
|
||||||
|
lockVisible ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'
|
||||||
|
} ${
|
||||||
|
locked
|
||||||
|
? // 锁住时按钮退到很淡的状态:既提示「这里是锁」,又不打扰观看。
|
||||||
|
'border-white/10 bg-black/35 text-white/45 hover:bg-black/65 hover:text-white'
|
||||||
|
: 'border-white/15 bg-black/65 text-white hover:bg-black/85'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{locked ? <Lock size={20} /> : <LockOpen size={20} />}
|
||||||
|
</button>
|
||||||
|
{/* 解锁提示:操作栏此时已经藏起来了,用户点锁之后需要知道发生了什么。 */}
|
||||||
|
{lockHint ? (
|
||||||
|
<div className="pointer-events-none absolute left-16 top-1/2 z-30 -translate-y-1/2 rounded-full border border-white/15 bg-black/75 px-3 py-1.5 text-xs text-white/90 shadow-xl backdrop-blur sm:left-20">
|
||||||
|
已解锁,点画面可显示操作栏
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{playerError ? (
|
{playerError ? (
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user