优化360播放

This commit is contained in:
truewhile
2026-09-17 01:08:28 +08:00
parent 1153fe4191
commit 83c0b65a0b
10 changed files with 398 additions and 58 deletions
+7 -2
View File
@@ -141,8 +141,13 @@ const profileQuery = () => {
// streamURL returns a direct-play URL for <video src>. The JWT is added as
// a query parameter because <video> elements cannot send Authorization
// headers.
export function streamURL(mediaId: string): string {
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}`
//
// proxy=true 时由服务端把网盘/STRM 直链转发为同源数据(画质与原文件一致、
// 不转码)。VR 全景渲染需要把视频帧读进 WebGL 纹理,跨域直链会被浏览器
// 判定为污染资源而禁止读取,因此只有这种场景才需要开启。
export function streamURL(mediaId: string, options: { proxy?: boolean } = {}): string {
const proxy = options.proxy ? '&proxy=1' : ''
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}${proxy}`
}
// hlsURL returns the m3u8 playlist URL fed into hls.js.
+4
View File
@@ -66,6 +66,10 @@ type Vr360StageProps = {
uiVisible: boolean
/** 轻触画面(未发生拖拽)时回调,与普通播放一致:唤出控制栏或切换播放。 */
onSurfaceTap: () => void
/**
* 第一帧真正画出来时回调,每次挂载只上报一次(改投影方式/画幅布局不会重新上报),
* 调用方据此结束「正在启动」提示。
*/
onReady: () => void
onError: (message: string) => void
/** 用户调整投影方式/立体布局时回调,由播放页保存为下次进入的默认值。 */
+126 -32
View File
@@ -33,7 +33,7 @@ import {
type SubtitlePosition,
type SubtitleStylePreset,
} from '../utils/subtitleDisplay'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel'
import { classifyDirectPlayError } from './directPlayError'
import { apiErrorMessage } from './StrmManagePage'
import { PlayerTopBar } from './PlayerTopBar'
@@ -45,6 +45,7 @@ import { mediaVersionsOf } from '../utils/mediaVersion'
import {
detectVr360Profile,
loadVr360Preference,
sameVr360Profile,
saveVr360Preference,
type Vr360Detection,
type Vr360Profile,
@@ -108,6 +109,17 @@ export function PlayerPage() {
const [vr360, setVr360] = useState<Vr360Profile | null>(null)
const [vr360Detected, setVr360Detected] = useState(false)
const vr360TouchedRef = useRef(false)
// VR 取帧失败后的自愈:只尝试一次「切到 HLS」,并在切换窗口内忽略重复报错。
const vr360TextureRetryUsedRef = useRef(false)
const vr360TextureRetryUntilRef = useRef(0)
// VR 全景下 STRM/网盘直连必须由服务端同源转发,浏览器才允许 WebGL 读取画面;
// 普通播放仍然 302 直连,避免把网盘流量压到服务器。放在 ref 里是为了让
// handleVideoError 里的 src 比对始终拿到最新值。
const vr360DirectProxy = Boolean(vr360) && isStrmMedia(media)
const vr360DirectProxyRef = useRef(vr360DirectProxy)
useEffect(() => {
vr360DirectProxyRef.current = vr360DirectProxy
}, [vr360DirectProxy])
const [mode, setMode] = useState<PlayerMode>('direct')
const [subs, setSubs] = useState<SubtitleTrack[]>([])
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
@@ -471,6 +483,8 @@ export function PlayerPage() {
setMedia(null)
setLoadError('')
vr360TouchedRef.current = false
vr360TextureRetryUsedRef.current = false
vr360TextureRetryUntilRef.current = 0
setVr360(null)
setVr360Detected(false)
setHlsStartSec(0)
@@ -578,6 +592,35 @@ export function PlayerPage() {
setVr360(detection.profile)
}, [id, media])
// 远端 Emby 挂载的媒体常常没有宽高元数据(width/height 为 0),等播放器拿到
// 真实画面尺寸后再补一次识别。HLS 转码是等比缩放,所以比例依然可信。
useEffect(() => {
if (!media || media.id !== id) return
if (media.width > 0 && media.height > 0) return
const video = ref.current
if (!video) return
const refine = () => {
if (vr360TouchedRef.current || video.videoWidth <= 0 || video.videoHeight <= 0) return
const detection = detectVr360Profile({
title: media.title,
originalName: media.original_name,
path: media.path,
relativePath: media.relative_path,
width: video.videoWidth,
height: video.videoHeight,
})
setVr360Detected(detection.confident)
const preference = loadVr360Preference()
if (!preference.autoDetect || !detection.confident) return
setVr360((current) =>
current && sameVr360Profile(current, detection.profile) ? current : detection.profile,
)
}
video.addEventListener('loadedmetadata', refine)
refine()
return () => video.removeEventListener('loadedmetadata', refine)
}, [id, media])
const toggleVr360 = useCallback(() => {
vr360TouchedRef.current = true
if (vr360) {
@@ -607,13 +650,6 @@ export function PlayerPage() {
saveVr360Preference({ ...loadVr360Preference(), profile })
}, [])
const handleVr360Error = useCallback((message: string) => {
vr360TouchedRef.current = true
setVr360(null)
setPlayerError(message)
toast.error(message)
}, [])
// 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.
@@ -685,6 +721,33 @@ export function PlayerPage() {
[mediaId, setPlaybackMode, switchToLocalHLS],
)
// 切到 HLS 播放(优先 115 云端,其次本地转码)。toggleMode 与「VR 需要同源帧」
// 的自愈逻辑共用,避免两处各写一遍云端/本地选择。
const enterHlsPlayback = useCallback(
(startSec: number) => {
setHlsStartSec(startSec)
const preferred =
playbackInfo?.provider === 'cloud115'
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
: undefined
if (preferred?.source === 'cloud') {
setHlsSource('cloud')
if (preferred.available) {
setCloudWaiting(false)
} else {
setCloudWaiting(true)
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
void startCloudTranscode(Number(preferred.id) || 4)
}
} else {
switchToLocalHLS(startSec)
}
setPlaybackMode('hls')
},
[playbackInfo, selectedQuality, setPlaybackMode, startCloudTranscode, switchToLocalHLS],
)
// 云端转码等待:按后端建议间隔轮询,完成后自动切到云 HLS。
useEffect(() => {
if (!cloudWaiting || !mediaId) return
@@ -869,13 +932,30 @@ export function PlayerPage() {
setPlaybackMode('direct')
})
} else {
const url = streamURL(mediaId)
// VR 全景需要浏览器能读帧:STRM/网盘直链会被 302 到跨域 CDN,此时改为
// 服务端同源转发(原画,不转码);普通播放仍走 302 直连,省服务器流量。
const url = streamURL(mediaId, { proxy: vr360DirectProxyRef.current })
const absoluteURL = new URL(url, window.location.href).href
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
if (video.src !== absoluteURL) {
directRetryRef.current = false
clearFallbackTimer()
// 进出 VR 会切换播放源地址,这里保住当前播放位置。
const resumeAt = video.currentTime > 2 ? video.currentTime : 0
if (resumeAt > 0) {
video.addEventListener(
'loadedmetadata',
() => {
try {
video.currentTime = resumeAt
} catch {
// ignore
}
},
{ once: true },
)
}
video.src = url
void video.play().catch(() => undefined)
}
@@ -904,6 +984,7 @@ export function PlayerPage() {
setPlaybackMode,
switchToLocalHLS,
teardownHls,
vr360DirectProxy,
])
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
@@ -1263,38 +1344,49 @@ export function PlayerPage() {
}
const next = mode === 'hls' ? 'direct' : 'hls'
if (next === 'hls') {
setHlsStartSec(0)
const preferred =
playbackInfo?.provider === 'cloud115'
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
: undefined
if (preferred?.source === 'cloud') {
setHlsSource('cloud')
if (preferred.available) {
setCloudWaiting(false)
} else {
setCloudWaiting(true)
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
void startCloudTranscode(Number(preferred.id) || 4)
}
} else {
switchToLocalHLS()
}
enterHlsPlayback(0)
} else {
setCloudWaiting(false)
}
setPlaybackMode(next)
}, [
enterHlsPlayback,
isDirectStream,
mode,
playbackInfo,
selectedQuality,
setPlaybackMode,
startCloudTranscode,
switchToLocalHLS,
])
// VR 渲染取不到帧时:网盘/STRM 的直连会 302 跳到 CDN,视频就变成跨域资源,
// 浏览器禁止 WebGL 读取(texImage2D 抛 SecurityError)。此时自动切到同源的
// HLS(云端优先)再继续 VR,只有确实做不到时才如实报错并退出 VR。
const handleVr360Error = useCallback(
(message: string) => {
const now = Date.now()
// 切换 HLS 的过程中旧的直连帧还会继续上报失败,先忽略这一窗口内的重复错误。
if (now < vr360TextureRetryUntilRef.current) return
const canRetryWithHls =
!vr360TextureRetryUsedRef.current &&
modeRef.current === 'direct' &&
!directOnly &&
!isRemoteEmbyID(mediaRef.current?.id)
if (canRetryWithHls) {
vr360TextureRetryUsedRef.current = true
vr360TextureRetryUntilRef.current = now + 15_000
setPlayerError('')
toast('直连源跨域,浏览器无法取帧;正在切到 HLS 转码后继续 VR 全景')
enterHlsPlayback(ref.current?.currentTime || 0)
return
}
vr360TouchedRef.current = true
setVr360(null)
setPlayerError(
`${message}。若当前是网盘/STRM 直连(会 302 跳转到 CDN),浏览器不允许 WebGL 读取跨域画面,请改用 HLS 播放后再开启 VR。`,
)
toast.error(message)
},
[directOnly, enterHlsPlayback],
)
const handleSeekAbsolute = useCallback(
(absoluteSec: number) => {
if (mode !== 'hls') return false
@@ -1420,7 +1512,9 @@ export function PlayerPage() {
if (retryingDirectRef.current) return
const expectedSrc = mediaId ? new URL(streamURL(mediaId), window.location.href).href : ''
const expectedSrc = mediaId
? new URL(streamURL(mediaId, { proxy: vr360DirectProxyRef.current }), window.location.href).href
: ''
const action = classifyDirectPlayError({
errorCode: video?.error?.code,
readyState: video?.readyState ?? 0,
+5 -7
View File
@@ -263,7 +263,10 @@ export function PlayerVideoStage({
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
const [controlsVisible, setControlsVisible] = useState(true)
// VR 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
const [vrReady, setVrReady] = useState(false)
// 只按「媒体 ID」记录:切换投影方式/画幅布局不会重建画布,也就不能清掉这个
// 状态——渲染器只在挂载后上报一次就绪,清掉之后提示会一直停在「正在启动」。
const [vrReadyMediaId, setVrReadyMediaId] = useState<string | null>(null)
const vrReady = Boolean(media && vrReadyMediaId === media.id)
const revealControlsOnlyRef = useRef(false)
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
const [activeCues, setActiveCues] = useState<SubtitleCue[]>([])
@@ -286,11 +289,6 @@ export function PlayerVideoStage({
setAssFallbackPath(null)
}, [media?.id, activeSubtitleTrack?.path, subs])
// 切换媒体或 VR 配置后重新等待渲染器的第一帧。
useEffect(() => {
setVrReady(false)
}, [media?.id, vr360?.projection, vr360?.stereo])
useEffect(() => {
const selectedTrack = subs[subtitleIndex]
let cancelled = false
@@ -639,7 +637,7 @@ export function PlayerVideoStage({
profile={vr360}
uiVisible={controlsVisible}
onSurfaceTap={handleSurfaceActivate}
onReady={() => setVrReady(true)}
onReady={() => setVrReadyMediaId(media.id)}
onError={(message) => onVr360Error?.(message)}
onProfileChange={onVr360ProfileChange}
/>
+17
View File
@@ -78,6 +78,23 @@ check('VR180 的 1:2 画幅为上下并排', vr180Ou.profile.stereo === 'ou')
const topBottom = detectVr360Profile({ path: '/media/vr/vr360-top-bottom.mp4' })
check('top-bottom 关键词为上下并排', topBottom.profile.stereo === 'ou')
// 回归:真实媒体库里出现过的误判(单词里含 vr、分辨率写法含 360)。
const valvrave = detectVr360Profile({
title: '革命机Valvrave',
path: '/media/anime/2013/[革命机] Valvrave the Liberator (2013)/01.mkv',
})
check('单词里的 vr 不误判(Valvrave)', !valvrave.confident)
check('误判时仍给出可用的默认配置', profileOf(valvrave.profile) === 'equirect360/mono')
const resolution360p = detectVr360Profile({ path: '/media/movie/movie-360p.mp4', width: 640, height: 360 })
check('360p 分辨率写法不误判', !resolution360p.confident)
const titleWithVrWord = detectVr360Profile({ title: '某动画 VR 特别篇', path: '/media/anime/01.mkv' })
check('标题里的独立 VR 词元可识别', titleWithVrWord.confident)
const ipvr = detectVr360Profile({ path: '/media/云下载/ipvr00192pl/ipvr00192.part2.mp4' })
check('编号形态 ipvr00192 可识别', ipvr.confident && ipvr.profile.projection === 'equirect180')
// ---------- 立体裁切与偏好归一化 ----------
check('单眼不裁切', closeAll(vr360FrameUv('mono').scale, [1, 1]))
+13 -5
View File
@@ -93,16 +93,22 @@ export type Vr360Detection = {
}
// 关键词分两类:明确的关键词按子串匹配("vr360" 里也含 "360"),
// 容易误伤的两字母缩写按独立词元匹配(避免 "1ou" / "sivr" 之类误判)。
const PANORAMA_SUBSTRINGS = ['360', 'equirect', 'insta360', 'pano', 'vuze', 'theta', '全景']
// 容易误伤的两字母缩写按作品编号形态匹配。
const PANORAMA_SUBSTRINGS = ['equirect', 'insta360', 'pano', 'vuze', 'theta', '全景']
const FISHEYE_SUBSTRINGS = ['fisheye', '鱼眼']
const VR180_SUBSTRINGS = ['vr180', '180vr', '180°', '180度', '半景']
const SBS_SUBSTRINGS = ['side-by-side', 'sidebyside', 'side_by_side', '左右']
const OU_SUBSTRINGS = ['over-under', 'top-bottom', 'topbottom', '上下']
const VR_TOKENS = ['vr']
const SBS_TOKENS = ['sbs', 'hsbs', 'lr']
const OU_TOKENS = ['ou', 'tb', 'vou', 'tou']
// 「VR」必须落在作品编号形态上,不能只是单词里的字母组合:
// vr180 / vr00192(后面跟数字)、sivr-270 / kmvr.mkv(跟在字母后并以非字母数字收尾)。
// 这样 "Valvrave"、"VRoid" 这类单词不会被误判成 VR 素材。
const VR_NAME_PATTERN = /vr[0-9]|[a-z]vr(?![a-z0-9])/
// 「360」需要排除 "360p" 之类的分辨率写法和 "1360" 之类的数字片段。
const PANORAMA_360_PATTERN = /(?<![0-9])360(?![0-9p])/
function detectText(input: Vr360DetectInput): string {
return [input.path, input.relativePath, input.originalName, input.title]
.filter((value): value is string => Boolean(value))
@@ -152,8 +158,10 @@ export function detectVr360Profile(input: Vr360DetectInput): Vr360Detection {
const fisheye = hasSubstring(text, FISHEYE_SUBSTRINGS)
const vr180 = hasSubstring(text, VR180_SUBSTRINGS)
const panorama = hasSubstring(text, PANORAMA_SUBSTRINGS)
const vrToken = hasToken(tokens, VR_TOKENS) || nameText.includes('vr')
const panorama =
hasSubstring(text, PANORAMA_SUBSTRINGS) || PANORAMA_360_PATTERN.test(text)
// 标题里只认独立的 "vr" 词元;文件名/路径里允许作品编号形态。
const vrToken = tokens.has('vr') || VR_NAME_PATTERN.test(nameText)
const confident = fisheye || vr180 || panorama || vrToken
let projection: Vr360Projection = DEFAULT_VR360_PROFILE.projection