diff --git a/web/src/components/PlayerControls.tsx b/web/src/components/PlayerControls.tsx index ba0363e..76559b8 100644 --- a/web/src/components/PlayerControls.tsx +++ b/web/src/components/PlayerControls.tsx @@ -16,6 +16,7 @@ import { PictureInPicture, Play, Rewind, + Rotate3d, SkipBack, SkipForward, Timer, @@ -24,6 +25,7 @@ import { } from 'lucide-react' import type { SubtitleTrack } from '../api/subtitles' import type { PlaybackQuality } from '../types' +import type { Vr360Profile } from '../utils/vr360' import { formatPlaybackRate, normalizePlaybackRate, @@ -116,6 +118,11 @@ type PlayerControlsProps = { selectedQuality?: string onSelectQuality?: (quality: PlaybackQuality) => void showQuality?: boolean + /** VR 全景播放配置;非 null 表示当前处于 VR 模式。 */ + vr360?: Vr360Profile | null + /** 是否由文件名/画幅自动识别为 VR 素材(按钮上加一个小圆点提示)。 */ + vr360Detected?: boolean + onToggleVr360?: () => void /** Media metadata duration (seconds). Used when HLS only knows transcoded length. */ knownDuration?: number /** Absolute source offset of the current HLS session (seconds). */ @@ -159,6 +166,9 @@ export function PlayerControls({ selectedQuality = '', onSelectQuality, showQuality = false, + vr360 = null, + vr360Detected = false, + onToggleVr360, knownDuration = 0, streamOffset = 0, onSeekAbsolute, @@ -1090,6 +1100,24 @@ export function PlayerControls({ {danmakuEnabled && } + {/* VR 全景播放开关 */} + {onToggleVr360 && ( + + )} + + + +
+ + + {(VR360_DEFAULT_FOV / fovHint).toFixed(1)}× + + +
+ +

+ {hint || '拖动旋转视角 · 滚轮或双指缩放'} +

+ {settingsOpen ? ( +
+

投影方式

+
+ {VR360_PROJECTION_OPTIONS.map(([value, label]) => ( + + ))} +
+

+ 画幅布局 +

+
+ {VR360_STEREO_OPTIONS.map(([value, label]) => ( + + ))} +
+ {profile.stereo !== 'mono' && ( +

+ 左右/上下并排素材在当前屏幕上只显示左眼画面;真正的双眼立体画面需要 VR + 头显(WebXR)。 +

+ )} +
+ ) : null} + + + ) +} diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index d90c0eb..41cb9c1 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -42,6 +42,13 @@ import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel' import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel' import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher' import { mediaVersionsOf } from '../utils/mediaVersion' +import { + detectVr360Profile, + loadVr360Preference, + saveVr360Preference, + type Vr360Detection, + type Vr360Profile, +} from '../utils/vr360' // Fullscreen, dark-themed video page. // @@ -96,6 +103,11 @@ export function PlayerPage() { const fallbackTimerRef = useRef | null>(null) const [media, setMedia] = useState(null) + // VR 全景播放:null = 普通播放。识别结果与用户开关只存本机, + // 不改动数据库,换设备/换浏览器不会互相影响。 + const [vr360, setVr360] = useState(null) + const [vr360Detected, setVr360Detected] = useState(false) + const vr360TouchedRef = useRef(false) const [mode, setMode] = useState('direct') const [subs, setSubs] = useState([]) const [subtitleIndex, setSubtitleIndex] = useState(0) @@ -458,6 +470,9 @@ export function PlayerPage() { useEffect(() => { setMedia(null) setLoadError('') + vr360TouchedRef.current = false + setVr360(null) + setVr360Detected(false) setHlsStartSec(0) setPlaybackInfo(null) setHlsSource('local') @@ -537,6 +552,68 @@ export function PlayerPage() { } }, [id, modeParam, directOnly]) + // VR 全景素材识别:只在用户没手动改过开关时自动进入 VR 模式。 + // 识别完全基于文件名/路径关键词与画幅比例,误判时点一下 VR 按钮即可退出。 + useEffect(() => { + if (!media || media.id !== id) return + const detection = detectVr360Profile({ + title: media.title, + originalName: media.original_name, + path: media.path, + relativePath: media.relative_path, + width: media.width, + height: media.height, + }) + setVr360Detected(detection.confident) + if (vr360TouchedRef.current) return + const preference = loadVr360Preference() + if (!preference.autoDetect || !detection.confident) { + setVr360(null) + return + } + if (detection.profile.projection !== preference.profile.projection) { + // 投影方式由文件名决定(360 / 180 / 鱼眼),记住它作为下次的默认值。 + saveVr360Preference({ ...preference, profile: detection.profile }) + } + setVr360(detection.profile) + }, [id, media]) + + const toggleVr360 = useCallback(() => { + vr360TouchedRef.current = true + if (vr360) { + setVr360(null) + return + } + const current = mediaRef.current + const detection: Vr360Detection | null = current + ? detectVr360Profile({ + title: current.title, + originalName: current.original_name, + path: current.path, + relativePath: current.relative_path, + width: current.width, + height: current.height, + }) + : null + const preference = loadVr360Preference() + const profile = detection?.profile ?? preference.profile + saveVr360Preference({ ...preference, profile }) + setVr360(profile) + }, [vr360]) + + const changeVr360Profile = useCallback((profile: Vr360Profile) => { + vr360TouchedRef.current = true + setVr360(profile) + saveVr360Preference({ ...loadVr360Preference(), profile }) + }, []) + + const handleVr360Error = useCallback((message: string) => { + vr360TouchedRef.current = true + setVr360(null) + setPlayerError(message) + toast.error(message) + }, []) + // Wire up the actual