mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30ca94c79b | |||
| 9c1f9a6e7e | |||
| ec7be38266 |
@@ -174,6 +174,39 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
// 默认未看过:配置接口必须先如实告知前端,否则首次操作说明会被跳过。
|
||||
initial := svc.Danmaku.ConfigForUser(t.Context(), "user-1")
|
||||
if initial.Vr360GuideSeen {
|
||||
t.Fatal("vr360 guide should be unread for a fresh user")
|
||||
}
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"vr360_guide_seen":true}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var cfg service.DanmakuRenderConfig
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !cfg.Vr360GuideSeen {
|
||||
t.Fatalf("config should reflect the persisted guide flag: %+v", cfg)
|
||||
}
|
||||
|
||||
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read persisted user: %v", err)
|
||||
}
|
||||
if !user.PlayerVr360GuideSeen {
|
||||
t.Fatalf("guide flag not persisted on the user: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
|
||||
+12
-9
@@ -37,15 +37,18 @@ type User struct {
|
||||
// original / simplified / traditional。
|
||||
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
|
||||
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
||||
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
||||
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
||||
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
|
||||
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
|
||||
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
|
||||
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
|
||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
|
||||
// PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明,
|
||||
// 按用户保存:看过一次之后不再弹出。
|
||||
PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"`
|
||||
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
||||
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
||||
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
||||
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
|
||||
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
|
||||
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
|
||||
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
|
||||
// DanmakuAppKey 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
|
||||
DanmakuAppKey string `gorm:"size:256" json:"-"`
|
||||
// ExpiredAt is the account expiry time. Nil means the account never
|
||||
|
||||
@@ -71,6 +71,8 @@ type DanmakuRenderConfig struct {
|
||||
Volume float64 `json:"volume"`
|
||||
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
|
||||
PlaybackRate float64 `json:"playback_rate"`
|
||||
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
|
||||
Vr360GuideSeen bool `json:"vr360_guide_seen"`
|
||||
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
|
||||
@@ -98,6 +100,8 @@ type DanmakuSettingsPatch struct {
|
||||
MergeSources *bool `json:"merge_sources"`
|
||||
Volume *float64 `json:"volume"`
|
||||
PlaybackRate *float64 `json:"playback_rate"`
|
||||
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
|
||||
Vr360GuideSeen *bool `json:"vr360_guide_seen"`
|
||||
}
|
||||
|
||||
type danmakuUserContextKey struct{}
|
||||
@@ -447,6 +451,7 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||
Area: strconv.FormatFloat(area, 'f', -1, 64),
|
||||
Volume: volume,
|
||||
PlaybackRate: playbackRate,
|
||||
Vr360GuideSeen: user.PlayerVr360GuideSeen,
|
||||
MergeSources: user.DanmakuMergeSources,
|
||||
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
|
||||
}
|
||||
@@ -528,6 +533,9 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
|
||||
}
|
||||
updates["player_playback_rate"] = *patch.PlaybackRate
|
||||
}
|
||||
if patch.Vr360GuideSeen != nil {
|
||||
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface DanmakuConfig {
|
||||
area: string
|
||||
volume: number
|
||||
playback_rate: number
|
||||
/** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */
|
||||
vr360_guide_seen: boolean
|
||||
/** Per-user preference: merge the same episode's multiple sources. */
|
||||
merge_sources: boolean
|
||||
}
|
||||
@@ -89,6 +91,8 @@ export interface DanmakuSettingsPatch {
|
||||
merge_sources?: boolean
|
||||
volume?: number
|
||||
playback_rate?: number
|
||||
/** 看过 VR 操作说明后置为 true,之后不再弹出。 */
|
||||
vr360_guide_seen?: boolean
|
||||
}
|
||||
|
||||
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
|
||||
|
||||
@@ -44,6 +44,10 @@ import {
|
||||
// controls> (which cannot host custom buttons). The danmaku toggle sits right
|
||||
// next to the volume control. The bar auto-hides while playing and reappears
|
||||
// on mouse movement; it stays visible while paused or when hovering/interacting.
|
||||
//
|
||||
// VR 全景播放里这条规则要反过来:按住拖动就是转动视角,属于观看动作而不是
|
||||
// 操作意图,所以这类鼠标/触摸移动既不唤出控制栏,还会在开始转视角时立刻把
|
||||
// 浮层收掉(见 onVrPointerDown / onVrPointerMove)。
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!Number.isFinite(s) || s < 0) s = 0
|
||||
@@ -58,6 +62,24 @@ const SEEK_APPLY_MS = 220
|
||||
const SEEK_HINT_MS = 700
|
||||
const PLAYBACK_RATE_REPEAT_MS = 140
|
||||
const PLAYBACK_RATE_HINT_MS = 900
|
||||
/** 播放中控制栏无操作自动隐藏的延时。 */
|
||||
const CONTROLS_HIDE_DELAY_MS = 3000
|
||||
/** VR 全景播放的隐藏延时更短:浮层由单击唤出,收得越快越不打扰观看。 */
|
||||
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'
|
||||
@@ -92,6 +114,13 @@ type PlayerControlsProps = {
|
||||
onPlaybackRateCommit?: (rate: number) => void
|
||||
uiVisible: boolean
|
||||
onUiVisibleChange: (visible: boolean) => void
|
||||
/** 外部浮层(VR 工具条/设置面板)正被鼠标悬停:此时控制栏不自动隐藏。 */
|
||||
uiHold?: boolean
|
||||
/**
|
||||
* 画面已被锁住(播放器左侧的锁)。锁住时操作栏整条隐藏,自动隐藏计时器
|
||||
* 也不再启动——否则计时器会在用户看不见的地方继续改写可见状态。
|
||||
*/
|
||||
uiLocked?: boolean
|
||||
subs: SubtitleTrack[]
|
||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||
subtitleIndex: number
|
||||
@@ -141,6 +170,8 @@ export function PlayerControls({
|
||||
onPlaybackRateCommit,
|
||||
uiVisible,
|
||||
onUiVisibleChange,
|
||||
uiHold = false,
|
||||
uiLocked = false,
|
||||
subs,
|
||||
subtitleIndex,
|
||||
onSelectSubtitle,
|
||||
@@ -196,13 +227,15 @@ export function PlayerControls({
|
||||
const [speedMenuOpen, setSpeedMenuOpen] = useState(false)
|
||||
const speedMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const controlsHoveredRef = useRef(false)
|
||||
const isScrubbingRef = useRef(false)
|
||||
const subtitleMenuOpenRef = useRef(false)
|
||||
const qualityMenuOpenRef = useRef(false)
|
||||
const speedMenuOpenRef = useRef(false)
|
||||
const danmakuOpenRef = useRef(false)
|
||||
const playlistOpenRef = useRef(false)
|
||||
/** 控制栏当前是否处于「必须保持显示」的状态(悬停/拖进度/菜单或面板打开)。 */
|
||||
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<number | null>(null)
|
||||
const applySeekRef = useRef<(absolute: number) => void>(() => undefined)
|
||||
const durationRef = useRef(0)
|
||||
@@ -217,33 +250,44 @@ export function PlayerControls({
|
||||
const [playbackRateHint, setPlaybackRateHint] = useState<number | null>(null)
|
||||
const [stageEl, setStageEl] = useState<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
controlsHoveredRef.current = controlsHovered
|
||||
}, [controlsHovered])
|
||||
|
||||
useEffect(() => {
|
||||
isScrubbingRef.current = isScrubbing
|
||||
}, [isScrubbing])
|
||||
|
||||
useEffect(() => {
|
||||
subtitleMenuOpenRef.current = subtitleMenuOpen
|
||||
}, [subtitleMenuOpen])
|
||||
vr360ActiveRef.current = Boolean(vr360)
|
||||
}, [vr360])
|
||||
|
||||
useEffect(() => {
|
||||
qualityMenuOpenRef.current = qualityMenuOpen
|
||||
}, [qualityMenuOpen])
|
||||
uiLockedRef.current = uiLocked
|
||||
}, [uiLocked])
|
||||
|
||||
// 「保持显示」条件集中在这里:悬停控制栏、拖动进度条、打开任何菜单或面板。
|
||||
// 事件回调里读 ref,渲染里读派生值,两边始终一致。
|
||||
const keepControlsVisible =
|
||||
uiHold ||
|
||||
controlsHovered ||
|
||||
isScrubbing ||
|
||||
subtitleMenuOpen ||
|
||||
qualityMenuOpen ||
|
||||
speedMenuOpen ||
|
||||
danmakuOpen ||
|
||||
playlistOpen
|
||||
useEffect(() => {
|
||||
speedMenuOpenRef.current = speedMenuOpen
|
||||
}, [speedMenuOpen])
|
||||
holdControlsRef.current = keepControlsVisible
|
||||
}, [keepControlsVisible])
|
||||
|
||||
// 控制栏「悬停保持显示」在触摸设备上没有可靠的结束信号:手指抬起后浏览器不保证
|
||||
// 补发 mouseleave,标记会一直挂着,控制栏就永远不隐藏。触摸设备直接不进入悬停
|
||||
// 保持状态,改用 pointerup 兜底清理,避免个别浏览器仍派发 mouseenter。
|
||||
useEffect(() => {
|
||||
danmakuOpenRef.current = danmakuOpen
|
||||
}, [danmakuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
playlistOpenRef.current = playlistOpen
|
||||
}, [playlistOpen])
|
||||
const onPointerUp = (event: PointerEvent) => {
|
||||
if (event.pointerType !== 'touch') return
|
||||
setControlsHovered(false)
|
||||
}
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
return () => window.removeEventListener('pointerup', onPointerUp)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
durationRef.current = duration
|
||||
@@ -303,45 +347,68 @@ export function PlayerControls({
|
||||
const stage = container()
|
||||
if (!el || !stage) return
|
||||
|
||||
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 &&
|
||||
!controlsHoveredRef.current &&
|
||||
!isScrubbingRef.current &&
|
||||
!subtitleMenuOpenRef.current &&
|
||||
!qualityMenuOpenRef.current &&
|
||||
!speedMenuOpenRef.current &&
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
if (
|
||||
!controlsHoveredRef.current &&
|
||||
!isScrubbingRef.current &&
|
||||
!subtitleMenuOpenRef.current &&
|
||||
!qualityMenuOpenRef.current &&
|
||||
!speedMenuOpenRef.current &&
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
onUiVisibleChange(false)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
if (el.paused || holdControls() || isLocked()) return
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
if (holdControls()) return
|
||||
onUiVisibleChange(false)
|
||||
}, hideDelay())
|
||||
}
|
||||
|
||||
// 通用「用户活动」入口:显示控制栏并重新计时。播放/暂停等事件也走这里。
|
||||
const onMove = () => {
|
||||
if (isLocked()) return
|
||||
onUiVisibleChange(true)
|
||||
resetTimer()
|
||||
}
|
||||
|
||||
// VR 全景播放里鼠标/触摸移动是「转动视角」的观看动作,不是操作控制栏的意图:
|
||||
// · 移动一律不唤出控制栏(拖动结束后的轻微抖动、手机上轻触产生的兼容
|
||||
// mousemove 都不会让进度条又冒出来);
|
||||
// · 一旦判定用户真的在转视角,就立刻收起浮层,还给用户一个干净的画面。
|
||||
// 需要控制栏时用轻触/单击画面唤出(见 PlayerVideoStage 的 handleSurfaceActivate)。
|
||||
const onStageMouseMove = () => {
|
||||
if (vr360ActiveRef.current) return
|
||||
onMove()
|
||||
}
|
||||
|
||||
const onVrPointerDown = (event: PointerEvent) => {
|
||||
if (!vr360ActiveRef.current) return
|
||||
const target = event.target
|
||||
if (!(target instanceof Element) || !target.closest('[data-vr360-surface]')) return
|
||||
vrDragRef.current = { x: event.clientX, y: event.clientY, moved: false }
|
||||
}
|
||||
|
||||
const onVrPointerMove = (event: PointerEvent) => {
|
||||
const drag = vrDragRef.current
|
||||
if (!drag || drag.moved) return
|
||||
if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) <= VR_VIEW_DRAG_DISTANCE) return
|
||||
drag.moved = true
|
||||
if (holdControls()) return
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
onUiVisibleChange(false)
|
||||
}
|
||||
|
||||
const endVrDrag = () => {
|
||||
vrDragRef.current = null
|
||||
}
|
||||
|
||||
const onLeave = (e: MouseEvent) => {
|
||||
endVrDrag()
|
||||
// 仅当光标真正移出 stage 容器时才处理
|
||||
if (e.relatedTarget && stage.contains(e.relatedTarget as Node)) {
|
||||
return
|
||||
}
|
||||
if (el.paused || controlsHoveredRef.current || isScrubbingRef.current || playlistOpenRef.current) return
|
||||
if (el.paused || holdControls()) return
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
onUiVisibleChange(false)
|
||||
}
|
||||
@@ -381,8 +448,14 @@ export function PlayerControls({
|
||||
const syncFullscreen = () => setFullscreen(Boolean(document.fullscreenElement))
|
||||
const syncPip = () => setPip(document.pictureInPictureElement === el)
|
||||
|
||||
stage.addEventListener('mousemove', onMove)
|
||||
stage.addEventListener('mousemove', onStageMouseMove)
|
||||
stage.addEventListener('mouseleave', onLeave)
|
||||
// VR 视角拖拽:pointer 事件比 mouse 事件先到达,可以在同一次移动里
|
||||
// 先判定「这是转动视角」,避免鼠标移动先把控制栏唤出来再收掉。
|
||||
stage.addEventListener('pointerdown', onVrPointerDown)
|
||||
stage.addEventListener('pointermove', onVrPointerMove)
|
||||
stage.addEventListener('pointerup', endVrDrag)
|
||||
stage.addEventListener('pointercancel', endVrDrag)
|
||||
|
||||
el.addEventListener('play', syncPlay)
|
||||
el.addEventListener('playing', syncPlay)
|
||||
@@ -399,8 +472,12 @@ export function PlayerControls({
|
||||
syncVolume()
|
||||
syncFullscreen()
|
||||
return () => {
|
||||
stage.removeEventListener('mousemove', onMove)
|
||||
stage.removeEventListener('mousemove', onStageMouseMove)
|
||||
stage.removeEventListener('mouseleave', onLeave)
|
||||
stage.removeEventListener('pointerdown', onVrPointerDown)
|
||||
stage.removeEventListener('pointermove', onVrPointerMove)
|
||||
stage.removeEventListener('pointerup', endVrDrag)
|
||||
stage.removeEventListener('pointercancel', endVrDrag)
|
||||
el.removeEventListener('play', syncPlay)
|
||||
el.removeEventListener('playing', syncPlay)
|
||||
el.removeEventListener('pause', syncPlay)
|
||||
@@ -422,19 +499,25 @@ export function PlayerControls({
|
||||
setDuration(Math.max(knownDuration || 0, streamOffset + streamDur))
|
||||
}, [knownDuration, streamOffset, video])
|
||||
|
||||
// 当悬停或菜单状态改变时,更新控制栏计时器
|
||||
// 控制栏的自动隐藏计时器:只要控制栏处于显示状态,就始终安排一次隐藏。
|
||||
// 之前只在「悬停/菜单状态变化」时才安排,用轻触或点击唤出控制栏后如果没有
|
||||
// 鼠标移动,计时器永远不会被安排,控制栏就会一直留在画面上。
|
||||
useEffect(() => {
|
||||
if (controlsHovered || isScrubbing || subtitleMenuOpen || qualityMenuOpen || speedMenuOpen || danmakuOpen || playlistOpen) {
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
// 锁住时控制栏整条不可见,也没有任何计时器需要跑。
|
||||
if (uiLocked) return
|
||||
if (keepControlsVisible) {
|
||||
onUiVisibleChange(true)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
} else {
|
||||
const el = video()
|
||||
if (el && !el.paused) {
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000)
|
||||
}
|
||||
return
|
||||
}
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, qualityMenuOpen, speedMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video])
|
||||
if (!uiVisible) return
|
||||
const el = video()
|
||||
if (!el || el.paused) return
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
if (holdControlsRef.current) return
|
||||
onUiVisibleChange(false)
|
||||
}, vr360ActiveRef.current ? VR_CONTROLS_HIDE_DELAY_MS : CONTROLS_HIDE_DELAY_MS)
|
||||
}, [uiVisible, keepControlsVisible, uiLocked, onUiVisibleChange, video])
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = video()
|
||||
@@ -513,33 +596,18 @@ export function PlayerControls({
|
||||
}
|
||||
|
||||
const revealControls = useCallback(() => {
|
||||
// 锁住时键盘操作(方向键调进度/音量)不应该把控制栏唤回来。
|
||||
if (uiLockedRef.current) return
|
||||
onUiVisibleChange(true)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
const el = video()
|
||||
if (
|
||||
!el ||
|
||||
el.paused ||
|
||||
controlsHoveredRef.current ||
|
||||
isScrubbingRef.current ||
|
||||
subtitleMenuOpenRef.current ||
|
||||
qualityMenuOpenRef.current ||
|
||||
danmakuOpenRef.current ||
|
||||
playlistOpenRef.current
|
||||
) {
|
||||
if (!el || el.paused || holdControlsRef.current) {
|
||||
return
|
||||
}
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
if (
|
||||
!controlsHoveredRef.current &&
|
||||
!isScrubbingRef.current &&
|
||||
!subtitleMenuOpenRef.current &&
|
||||
!qualityMenuOpenRef.current &&
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
onUiVisibleChange(false)
|
||||
}
|
||||
}, 3000)
|
||||
if (holdControlsRef.current) return
|
||||
onUiVisibleChange(false)
|
||||
}, vr360ActiveRef.current ? VR_CONTROLS_HIDE_DELAY_MS : CONTROLS_HIDE_DELAY_MS)
|
||||
}, [onUiVisibleChange, video])
|
||||
|
||||
const queueRelativeSeek = useCallback((delta: number, immediate: boolean) => {
|
||||
@@ -756,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()}
|
||||
|
||||
@@ -74,6 +74,11 @@ type Vr360StageProps = {
|
||||
onError: (message: string) => void
|
||||
/** 用户调整投影方式/立体布局时回调,由播放页保存为下次进入的默认值。 */
|
||||
onProfileChange?: (profile: Vr360Profile) => void
|
||||
/**
|
||||
* 鼠标是否停留在 VR 工具条/设置面板上。由播放页转给控制栏:悬停这些浮层时
|
||||
* 控制栏不应该自动隐藏(否则设置面板会在点击前消失)。
|
||||
*/
|
||||
onUiHoldChange?: (hold: boolean) => void
|
||||
}
|
||||
|
||||
/** 每像素拖拽对应的转角(弧度)。视场角越小时画面越「拉近」,拖拽也应更细腻。 */
|
||||
@@ -96,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'>
|
||||
}
|
||||
@@ -117,6 +136,7 @@ export function Vr360Stage({
|
||||
onReady,
|
||||
onError,
|
||||
onProfileChange,
|
||||
onUiHoldChange,
|
||||
}: Vr360StageProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const rendererRef = useRef<Vr360Renderer | null>(null)
|
||||
@@ -372,6 +392,15 @@ export function Vr360Stage({
|
||||
|
||||
useEffect(() => () => stopGyro(), [stopGyro])
|
||||
|
||||
// 控制栏/浮层隐藏时收起「画面」设置面板:它属于同一套浮层,下次唤出控制栏时
|
||||
// 不应该自动弹回来挡住画面。
|
||||
useEffect(() => {
|
||||
if (uiVisible) return
|
||||
setSettingsOpen(false)
|
||||
// 浮层不可见时鼠标离开事件不会再触发,这里主动解除「悬停保持显示」。
|
||||
onUiHoldChange?.(false)
|
||||
}, [uiVisible, onUiHoldChange])
|
||||
|
||||
const zoomBy = useCallback(
|
||||
(factor: number) => {
|
||||
const view = viewRef.current
|
||||
@@ -400,6 +429,10 @@ export function Vr360Stage({
|
||||
}, [])
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
// 轻触画面把视角拖动之外的浮层悬停状态清掉:触摸设备不会再发出 mouseleave,
|
||||
// 上一步点击工具条/设置面板留下的「悬停保持显示」会一直挂着,控制栏就永远
|
||||
// 不隐藏了。用户已经在点画面,说明指针早已离开浮层。
|
||||
if (event.pointerType === 'touch') onUiHoldChange?.(false)
|
||||
try {
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
} catch {
|
||||
@@ -487,7 +520,10 @@ export function Vr360Stage({
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
data-vr360-surface
|
||||
className="absolute inset-0 h-full w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
||||
className={`absolute inset-0 h-full w-full touch-none select-none ${
|
||||
// 浮层隐藏时连鼠标指针一起藏起来:全景画面里光标一直悬在画面中间很出戏。
|
||||
uiVisible ? 'cursor-grab active:cursor-grabbing' : 'cursor-none'
|
||||
}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={endPointer}
|
||||
@@ -497,11 +533,27 @@ export function Vr360Stage({
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
<div
|
||||
// 阻止浮层内按钮/面板的点击冒泡到舞台的 handleSurfaceActivate:否则点击
|
||||
// 投影方式/画幅布局等设置项会被误判为「单击收起控制栏」,面板在切换后立即消失。
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className={`pointer-events-none absolute inset-x-0 top-2 z-20 flex flex-col items-center gap-1.5 px-2 transition-opacity duration-300 ${
|
||||
uiVisible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="pointer-events-auto flex flex-wrap items-center justify-center gap-1.5 rounded-2xl border border-white/15 bg-black/65 px-2 py-1.5 text-white shadow-2xl backdrop-blur">
|
||||
<div
|
||||
className={`flex flex-wrap items-center justify-center gap-1.5 rounded-2xl border border-white/15 bg-black/65 px-2 py-1.5 text-white shadow-2xl backdrop-blur ${
|
||||
// 浮层隐藏时必须彻底不接收指针事件:否则看不见的按钮既会在画面顶部
|
||||
// 抢走拖动转视角的手势,也可能被误点(例如误开陀螺仪、误开设置面板)。
|
||||
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
|
||||
}`}
|
||||
// 触摸设备不参与「悬停保持显示」:手指抬起后不会补发 mouseleave,
|
||||
// 标记会永久挂在打开状态,控制栏和 VR 工具条就再也不自动隐藏。
|
||||
onMouseEnter={() => {
|
||||
if (isHoverlessDevice()) return
|
||||
onUiHoldChange?.(true)
|
||||
}}
|
||||
onMouseLeave={() => onUiHoldChange?.(false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleGyro}
|
||||
@@ -564,7 +616,16 @@ export function Vr360Stage({
|
||||
{hint || '拖动旋转视角 · 滚轮或双指缩放'}
|
||||
</p>
|
||||
{settingsOpen ? (
|
||||
<div className="pointer-events-auto w-[min(92vw,420px)] rounded-2xl border border-white/15 bg-black/75 px-3 py-2.5 text-white shadow-2xl backdrop-blur">
|
||||
<div
|
||||
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'
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
if (isHoverlessDevice()) return
|
||||
onUiHoldChange?.(true)
|
||||
}}
|
||||
onMouseLeave={() => onUiHoldChange?.(false)}
|
||||
>
|
||||
<p className="mb-1.5 text-[10px] uppercase tracking-wide text-white/45">投影方式</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{VR360_PROJECTION_OPTIONS.map(([value, label]) => (
|
||||
|
||||
@@ -138,6 +138,9 @@ export function PlayerPage() {
|
||||
normalizePlaybackRate(authUser?.player_playback_rate),
|
||||
)
|
||||
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
||||
// VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来,
|
||||
// 这时不弹说明,避免给老用户闪一下。
|
||||
const [vr360GuideSeen, setVr360GuideSeen] = useState<boolean | null>(null)
|
||||
const playerVolumeTouchedRef = useRef(false)
|
||||
const playerPlaybackRateTouchedRef = useRef(false)
|
||||
const playerPlaybackRateSaveSeqRef = useRef(0)
|
||||
@@ -284,6 +287,7 @@ export function PlayerPage() {
|
||||
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
||||
setDanmakuArea(Number(cfg.area) || 1)
|
||||
setDanmakuMergeSources(Boolean(cfg.merge_sources))
|
||||
setVr360GuideSeen(Boolean(cfg.vr360_guide_seen))
|
||||
applyDanmakuConnectionConfig(cfg)
|
||||
if (!playerVolumeTouchedRef.current) {
|
||||
const current = useAuthStore.getState().user
|
||||
@@ -650,6 +654,13 @@ export function PlayerPage() {
|
||||
saveVr360Preference({ ...loadVr360Preference(), profile })
|
||||
}, [])
|
||||
|
||||
// VR 操作说明是「按用户只弹一次」:用户关掉说明时就落库,之后进入 VR 不再显示。
|
||||
// 保存失败只影响下次是否再弹一次,不打断播放,所以本地状态不回滚。
|
||||
const dismissVr360Guide = useCallback(() => {
|
||||
setVr360GuideSeen(true)
|
||||
void danmakuAPI.updateSettings({ vr360_guide_seen: true }).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
// Wire up the actual <video> element when we know the mode.
|
||||
// Depend on media.id (not the media object): refreshing duration after
|
||||
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
||||
@@ -1675,6 +1686,8 @@ export function PlayerPage() {
|
||||
onToggleVr360={toggleVr360}
|
||||
onVr360ProfileChange={changeVr360Profile}
|
||||
onVr360Error={handleVr360Error}
|
||||
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
|
||||
onDismissVr360Guide={dismissVr360Guide}
|
||||
playlistPanel={
|
||||
<PlayerPlaylistPanel
|
||||
open={playlistOpen}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Hand,
|
||||
Loader2,
|
||||
Lock,
|
||||
LockOpen,
|
||||
MousePointerClick,
|
||||
Rotate3d,
|
||||
Smartphone,
|
||||
ZoomIn,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||
@@ -19,6 +28,7 @@ import {
|
||||
type SubtitlePosition,
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
import { isPointerInLockZone } from '../utils/playerLockZone'
|
||||
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
|
||||
|
||||
type SubtitleRenderGroup = {
|
||||
@@ -27,6 +37,16 @@ type SubtitleRenderGroup = {
|
||||
style: CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁按钮每次被唤出后停留的时间(毫秒)。
|
||||
*
|
||||
* 锁本身默认隐身,只在「鼠标悬停感应区」或「点击画面」之后露一会儿。解锁的瞬间
|
||||
* 操作栏会立刻消失,如果锁也一起消失,用户就不知道锁在哪、也没法再锁回去。
|
||||
*/
|
||||
const LOCK_REVEAL_MS = 2600
|
||||
/** 首次操作说明的最长停留时间:到这里仍未点「知道了」也自动收起并记录为已看过。 */
|
||||
const VR_GUIDE_AUTO_DISMISS_MS = 20_000
|
||||
|
||||
function uniqueSubtitleCues(cues: SubtitleCue[]): SubtitleCue[] {
|
||||
const seen = new Set<string>()
|
||||
const unique: SubtitleCue[] = []
|
||||
@@ -195,6 +215,9 @@ type PlayerVideoStageProps = {
|
||||
onToggleVr360?: () => void
|
||||
onVr360ProfileChange?: (profile: Vr360Profile) => void
|
||||
onVr360Error?: (message: string) => void
|
||||
/** 是否展示 VR 全景播放的首次操作说明(按用户只弹一次)。 */
|
||||
showVr360Guide?: boolean
|
||||
onDismissVr360Guide?: () => void
|
||||
waiting?: boolean
|
||||
waitingMessage?: string
|
||||
}
|
||||
@@ -255,6 +278,8 @@ export function PlayerVideoStage({
|
||||
onToggleVr360,
|
||||
onVr360ProfileChange,
|
||||
onVr360Error,
|
||||
showVr360Guide = false,
|
||||
onDismissVr360Guide,
|
||||
waiting = false,
|
||||
waitingMessage = '',
|
||||
}: PlayerVideoStageProps) {
|
||||
@@ -262,6 +287,22 @@ export function PlayerVideoStage({
|
||||
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
||||
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
||||
const [controlsVisible, setControlsVisible] = useState(true)
|
||||
// 鼠标是否停在 VR 工具条/设置面板上:停住时控制栏不自动隐藏,否则设置面板会在点击前消失。
|
||||
const [vrUiHovered, setVrUiHovered] = useState(false)
|
||||
// 画面是否被「锁住」:锁上之后操作栏、VR 工具条、进度框全部隐藏,只剩干净的
|
||||
// 画面,轻触画面也不会把它们唤回来,也不会误触发播放/暂停;想看操作栏必须先
|
||||
// 点左边的锁解锁。VR 和普通播放共用这一套状态。
|
||||
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)
|
||||
// VR 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
|
||||
// 只按「媒体 ID」记录:切换投影方式/画幅布局不会重建画布,也就不能清掉这个
|
||||
// 状态——渲染器只在挂载后上报一次就绪,清掉之后提示会一直停在「正在启动」。
|
||||
@@ -408,20 +449,117 @@ export function PlayerVideoStage({
|
||||
if (video.paused) void video.play()?.catch(() => undefined)
|
||||
else video.pause()
|
||||
}
|
||||
/**
|
||||
* 临时把锁按钮唤出来(移动端点击画面、以及刚解锁时)。
|
||||
*
|
||||
* 每次都重新计时:连续点击时不会因为第一次的计时器到点而提前隐身。
|
||||
*/
|
||||
const revealLock = useCallback((withUnlockHint: boolean) => {
|
||||
setLockShown(true)
|
||||
setLockHint(withUnlockHint)
|
||||
if (lockRevealTimerRef.current) clearTimeout(lockRevealTimerRef.current)
|
||||
lockRevealTimerRef.current = setTimeout(() => {
|
||||
lockRevealTimerRef.current = null
|
||||
setLockShown(false)
|
||||
setLockHint(false)
|
||||
}, LOCK_REVEAL_MS)
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (lockRevealTimerRef.current) clearTimeout(lockRevealTimerRef.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
revealControlsOnlyRef.current = event.pointerType === 'touch' && !controlsVisible
|
||||
// 触摸端没有悬停,靠点击画面把锁唤出来(顺带给刚上锁的用户一个解锁入口)。
|
||||
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 = () => {
|
||||
if (locked) return
|
||||
if (revealControlsOnlyRef.current) {
|
||||
revealControlsOnlyRef.current = false
|
||||
setControlsVisible(true)
|
||||
return
|
||||
}
|
||||
revealControlsOnlyRef.current = false
|
||||
if (vrActive) {
|
||||
setControlsVisible((visible) => !visible)
|
||||
return
|
||||
}
|
||||
togglePlay()
|
||||
}
|
||||
|
||||
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(() => {
|
||||
resetLock()
|
||||
if (vrActive) {
|
||||
setControlsVisible(true)
|
||||
return
|
||||
}
|
||||
setVrUiHovered(false)
|
||||
}, [resetLock, vrActive])
|
||||
|
||||
// 换集/换片时同样复位:新内容应该从干净的、可操作的状态开始。
|
||||
useEffect(() => {
|
||||
resetLock()
|
||||
}, [media?.id, resetLock])
|
||||
|
||||
// 首次操作说明只出现一次:读完了点「知道了」会立即落库;放着不管也会在
|
||||
// VR_GUIDE_AUTO_DISMISS_MS 之后自动收起并同样记录为已看过,避免每次进 VR 都弹。
|
||||
const vrGuideVisible = vrActive && vrReady && showVr360Guide
|
||||
useEffect(() => {
|
||||
if (!vrGuideVisible) return
|
||||
const timer = setTimeout(() => onDismissVr360Guide?.(), VR_GUIDE_AUTO_DISMISS_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [vrGuideVisible, onDismissVr360Guide])
|
||||
const toggleFullscreen = () => {
|
||||
const stage = stageRef.current
|
||||
if (!stage) return
|
||||
@@ -597,6 +735,8 @@ export function PlayerVideoStage({
|
||||
data-player-stage
|
||||
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
||||
onPointerDown={handleStagePointerDown}
|
||||
onPointerMove={handleStagePointerMove}
|
||||
onMouseLeave={() => setLockHovered(false)}
|
||||
onClick={handleSurfaceActivate}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
>
|
||||
@@ -640,6 +780,7 @@ export function PlayerVideoStage({
|
||||
onReady={() => setVrReadyMediaId(media.id)}
|
||||
onError={(message) => onVr360Error?.(message)}
|
||||
onProfileChange={onVr360ProfileChange}
|
||||
onUiHoldChange={setVrUiHovered}
|
||||
/>
|
||||
) : null}
|
||||
<DanmakuStage
|
||||
@@ -702,8 +843,10 @@ export function PlayerVideoStage({
|
||||
playbackRate={playerPlaybackRate}
|
||||
onPlaybackRateChange={onPlayerPlaybackRateChange}
|
||||
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
|
||||
uiVisible={controlsVisible}
|
||||
uiVisible={controlsVisible && !locked}
|
||||
onUiVisibleChange={setControlsVisible}
|
||||
uiHold={vrUiHovered}
|
||||
uiLocked={locked}
|
||||
subs={subs}
|
||||
subtitleIndex={subtitleIndex}
|
||||
onSelectSubtitle={onSelectSubtitle}
|
||||
@@ -752,6 +895,85 @@ export function PlayerVideoStage({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* VR 首次操作说明:按用户只弹一次。刻意不铺全屏遮罩,用户可以一边看说明
|
||||
一边拖动画面试操作。 */}
|
||||
{vrGuideVisible ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center px-4">
|
||||
<div className="pointer-events-auto w-[min(92vw,400px)] rounded-2xl border border-white/15 bg-black/85 px-5 py-4 text-white shadow-2xl backdrop-blur">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Rotate3d size={16} className="text-rose-400" />
|
||||
VR 全景播放
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2 text-xs leading-relaxed text-white/80">
|
||||
<li className="flex items-start gap-2">
|
||||
<Hand size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||
按住拖动画面:转动视角
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ZoomIn size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||
滚轮 / 双指捏合:缩放视野
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<MousePointerClick size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||
单击画面:显示或收起操作栏(进度条、音量、画质、倍速、VR 设置)
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Lock size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||
锁默认隐身:电脑把鼠标移到左侧中间、手机点一下画面就会浮现
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Smartphone size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||
手机上点顶部「陀螺仪」:转动设备就能转动视角
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismissVr360Guide}
|
||||
className="mt-4 w-full rounded-xl bg-rose-500 px-4 py-2 text-xs font-medium text-white transition hover:bg-rose-600"
|
||||
>
|
||||
知道了,不再提示
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 锁定按钮:贴在画面左侧正中,VR 和普通播放都有,但默认隐身。
|
||||
桌面端把鼠标移到左侧中间就会浮现,移开即收;移动端点一下画面唤出,并在
|
||||
LOCK_REVEAL_MS 后自动隐身。
|
||||
未锁时点一下就把操作栏、进度框和 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>
|
||||
) : null}
|
||||
{playerError ? (
|
||||
<div className="absolute bottom-20 left-1/2 w-[min(92vw,720px)] -translate-x-1/2 rounded-2xl border border-white/15 bg-black/75 px-5 py-4 text-sm text-white shadow-2xl backdrop-blur">
|
||||
{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