优化vr播放逻辑

This commit is contained in:
truewhile
2026-09-17 09:22:00 +08:00
parent ebf791c436
commit ec7be38266
8 changed files with 349 additions and 99 deletions
+33
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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
}
+4
View File
@@ -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
+108 -84
View File
@@ -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,12 @@ 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 = 2000
/** VR 视角拖拽超过这个距离才算「真的在转动视角」,轻触不受影响。 */
const VR_VIEW_DRAG_DISTANCE = 6
type SeekHint = {
dir: 'back' | 'forward'
@@ -92,6 +102,8 @@ type PlayerControlsProps = {
onPlaybackRateCommit?: (rate: number) => void
uiVisible: boolean
onUiVisibleChange: (visible: boolean) => void
/** 外部浮层(VR 工具条/设置面板)正被鼠标悬停:此时控制栏不自动隐藏。 */
uiHold?: boolean
subs: SubtitleTrack[]
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
subtitleIndex: number
@@ -141,6 +153,7 @@ export function PlayerControls({
onPlaybackRateCommit,
uiVisible,
onUiVisibleChange,
uiHold = false,
subs,
subtitleIndex,
onSelectSubtitle,
@@ -196,13 +209,13 @@ 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 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 +230,28 @@ 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])
// 「保持显示」条件集中在这里:悬停控制栏、拖动进度条、打开任何菜单或面板。
// 事件回调里读 ref,渲染里读派生值,两边始终一致。
const keepControlsVisible =
uiHold ||
controlsHovered ||
isScrubbing ||
subtitleMenuOpen ||
qualityMenuOpen ||
speedMenuOpen ||
danmakuOpen ||
playlistOpen
useEffect(() => {
qualityMenuOpenRef.current = qualityMenuOpen
}, [qualityMenuOpen])
useEffect(() => {
speedMenuOpenRef.current = speedMenuOpen
}, [speedMenuOpen])
useEffect(() => {
danmakuOpenRef.current = danmakuOpen
}, [danmakuOpen])
useEffect(() => {
playlistOpenRef.current = playlistOpen
}, [playlistOpen])
holdControlsRef.current = keepControlsVisible
}, [keepControlsVisible])
useEffect(() => {
durationRef.current = duration
@@ -303,45 +311,64 @@ export function PlayerControls({
const stage = container()
if (!el || !stage) return
const holdControls = () => holdControlsRef.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()) return
hideTimerRef.current = setTimeout(() => {
if (holdControls()) return
onUiVisibleChange(false)
}, hideDelay())
}
// 通用「用户活动」入口:显示控制栏并重新计时。播放/暂停等事件也走这里。
const onMove = () => {
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 +408,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 +432,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 +459,23 @@ 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 (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, onUiVisibleChange, video])
const togglePlay = () => {
const el = video()
@@ -516,30 +557,13 @@ export function PlayerControls({
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) => {
+35 -3
View File
@@ -74,6 +74,11 @@ type Vr360StageProps = {
onError: (message: string) => void
/** 用户调整投影方式/立体布局时回调,由播放页保存为下次进入的默认值。 */
onProfileChange?: (profile: Vr360Profile) => void
/**
* 鼠标是否停留在 VR 工具条/设置面板上。由播放页转给控制栏:悬停这些浮层时
* 控制栏不应该自动隐藏(否则设置面板会在点击前消失)。
*/
onUiHoldChange?: (hold: boolean) => void
}
/** 每像素拖拽对应的转角(弧度)。视场角越小时画面越「拉近」,拖拽也应更细腻。 */
@@ -117,6 +122,7 @@ export function Vr360Stage({
onReady,
onError,
onProfileChange,
onUiHoldChange,
}: Vr360StageProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const rendererRef = useRef<Vr360Renderer | null>(null)
@@ -372,6 +378,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
@@ -487,7 +502,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}
@@ -501,7 +519,15 @@ export function Vr360Stage({
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'
}`}
onMouseEnter={() => onUiHoldChange?.(true)}
onMouseLeave={() => onUiHoldChange?.(false)}
>
<button
type="button"
onClick={toggleGyro}
@@ -564,7 +590,13 @@ 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={() => 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]) => (
+13
View File
@@ -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}
+136 -3
View File
@@ -1,6 +1,6 @@
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, MousePointerClick, Rotate3d, Settings2, Smartphone, ZoomIn } from 'lucide-react'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
@@ -27,6 +27,13 @@ type SubtitleRenderGroup = {
style: CSSProperties
}
/** VR 模式下必须连续点击这么多次才唤出控制栏:单击太容易在转视角时误触。 */
const VR_TAP_COUNT = 3
/** 相邻两次点击间隔超过该毫秒数就重新开始计数。 */
const VR_TAP_GAP_MS = 900
/** 首次操作说明的最长停留时间:到这里仍未点「知道了」也自动收起并记录为已看过。 */
const VR_GUIDE_AUTO_DISMISS_MS = 20_000
function uniqueSubtitleCues(cues: SubtitleCue[]): SubtitleCue[] {
const seen = new Set<string>()
const unique: SubtitleCue[] = []
@@ -195,6 +202,9 @@ type PlayerVideoStageProps = {
onToggleVr360?: () => void
onVr360ProfileChange?: (profile: Vr360Profile) => void
onVr360Error?: (message: string) => void
/** 是否展示 VR 全景播放的首次操作说明(按用户只弹一次)。 */
showVr360Guide?: boolean
onDismissVr360Guide?: () => void
waiting?: boolean
waitingMessage?: string
}
@@ -255,6 +265,8 @@ export function PlayerVideoStage({
onToggleVr360,
onVr360ProfileChange,
onVr360Error,
showVr360Guide = false,
onDismissVr360Guide,
waiting = false,
waitingMessage = '',
}: PlayerVideoStageProps) {
@@ -262,6 +274,13 @@ 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 下「连续点击唤出控制栏」已累计的次数(0 表示没在计数)。
const [vrTapCount, setVrTapCount] = useState(0)
const vrTapCountRef = useRef(0)
const vrTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const vrActive = Boolean(vr360)
// VR 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
// 只按「媒体 ID」记录:切换投影方式/画幅布局不会重建画布,也就不能清掉这个
// 状态——渲染器只在挂载后上报一次就绪,清掉之后提示会一直停在「正在启动」。
@@ -409,10 +428,23 @@ export function PlayerVideoStage({
else video.pause()
}
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
revealControlsOnlyRef.current = event.pointerType === 'touch' && !controlsVisible
// VR 模式下单击不再负责唤出控制栏(改成连续 3 次点击),所以这里不预置开关。
revealControlsOnlyRef.current = !vrActive && event.pointerType === 'touch' && !controlsVisible
}
const resetVrTapCount = useCallback(() => {
vrTapCountRef.current = 0
setVrTapCount(0)
if (vrTapTimerRef.current) {
clearTimeout(vrTapTimerRef.current)
vrTapTimerRef.current = null
}
}, [])
// 舞台任意位置的「激活」(轻触/单击):移动端控制栏隐藏时首次轻触只唤出
// 控制栏,控制栏已显示时再次轻触才切换播放/暂停。
// VR 模式下单击不切换播放,也不唤出控制栏:单击太容易误触,必须连续点击
// VR_TAP_COUNT 次才会显示控制栏;控制栏已显示时单击即收起。
const handleSurfaceActivate = () => {
if (revealControlsOnlyRef.current) {
revealControlsOnlyRef.current = false
@@ -420,8 +452,60 @@ export function PlayerVideoStage({
return
}
revealControlsOnlyRef.current = false
if (vrActive) {
if (controlsVisible) {
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
}
togglePlay()
}
// 进入 VR 全景播放时先亮一次控制栏与 VR 工具条,让用户知道入口在哪;
// 之后完全由自动隐藏和「连点画面」控制。退出 VR 时要清掉 VR 浮层的悬停状态,
// 否则控制栏会以为还有浮层被悬停而一直不隐藏。
useEffect(() => {
resetVrTapCount()
if (vrActive) {
setControlsVisible(true)
return
}
setVrUiHovered(false)
}, [vrActive, resetVrTapCount])
useEffect(
() => () => {
if (vrTapTimerRef.current) clearTimeout(vrTapTimerRef.current)
},
[],
)
// 首次操作说明只出现一次:读完了点「知道了」会立即落库;放着不管也会在
// 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
@@ -640,6 +724,7 @@ export function PlayerVideoStage({
onReady={() => setVrReadyMediaId(media.id)}
onError={(message) => onVr360Error?.(message)}
onProfileChange={onVr360ProfileChange}
onUiHoldChange={setVrUiHovered}
/>
) : null}
<DanmakuStage
@@ -704,6 +789,7 @@ export function PlayerVideoStage({
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
uiVisible={controlsVisible}
onUiVisibleChange={setControlsVisible}
uiHold={vrUiHovered}
subs={subs}
subtitleIndex={subtitleIndex}
onSelectSubtitle={onSelectSubtitle}
@@ -752,6 +838,53 @@ 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_TAP_COUNT} 次:显示控制栏(进度条、音量、画质、倍速、VR 设置)
</li>
<li className="flex items-start gap-2">
<Settings2 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}
{/* 连点计数反馈:没有反馈时用户连点两次会觉得没反应。 */}
{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">
再连点 {VR_TAP_COUNT - vrTapCount} 次显示控制栏
</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}