diff --git a/internal/handler/danmaku_settings_test.go b/internal/handler/danmaku_settings_test.go index acd3cd2..cb0422c 100644 --- a/internal/handler/danmaku_settings_test.go +++ b/internal/handler/danmaku_settings_test.go @@ -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) diff --git a/internal/model/user.go b/internal/model/user.go index 8de221c..f25da0a 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -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 diff --git a/internal/service/danmaku_service.go b/internal/service/danmaku_service.go index 3b0d170..2aba865 100644 --- a/internal/service/danmaku_service.go +++ b/internal/service/danmaku_service.go @@ -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 } diff --git a/web/src/api/danmaku.ts b/web/src/api/danmaku.ts index e8e04cb..eb81c10 100644 --- a/web/src/api/danmaku.ts +++ b/web/src/api/danmaku.ts @@ -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 diff --git a/web/src/components/PlayerControls.tsx b/web/src/components/PlayerControls.tsx index 76559b8..a1bca4f 100644 --- a/web/src/components/PlayerControls.tsx +++ b/web/src/components/PlayerControls.tsx @@ -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(null) const hideTimerRef = useRef | 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(null) const applySeekRef = useRef<(absolute: number) => void>(() => undefined) const durationRef = useRef(0) @@ -217,33 +230,28 @@ export function PlayerControls({ const [playbackRateHint, setPlaybackRateHint] = useState(null) const [stageEl, setStageEl] = useState(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) => { diff --git a/web/src/components/Vr360Stage.tsx b/web/src/components/Vr360Stage.tsx index 99cd9bd..628f159 100644 --- a/web/src/components/Vr360Stage.tsx +++ b/web/src/components/Vr360Stage.tsx @@ -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(null) const rendererRef = useRef(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({ -
+
onUiHoldChange?.(true)} + onMouseLeave={() => onUiHoldChange?.(false)} + > +
+
+ ) : null} + {/* 连点计数反馈:没有反馈时用户连点两次会觉得没反应。 */} + {vrActive && !vrGuideVisible && !controlsVisible && vrTapCount > 0 ? ( +
+ 再连点 {VR_TAP_COUNT - vrTapCount} 次显示控制栏 +
+ ) : null} {playerError ? (
{playerError}