mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
优化360播放
This commit is contained in:
@@ -722,7 +722,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if mount.ProxyPlay {
|
||||
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
|
||||
// 与挂载账号的 proxy_play 开关等价。
|
||||
if mount.ProxyPlay || wantSameOriginProxy(c) {
|
||||
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
@@ -758,6 +760,15 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
// ?proxy=1:把网盘/STRM 直链改为服务端同源转发(网页端读帧、VR 全景用),
|
||||
// 画质与原文件一致,不触发转码。
|
||||
if wantSameOriginProxy(c) {
|
||||
handled, proxyErr := proxySTRMStream(c, svc, m)
|
||||
if handled {
|
||||
writeProxyError(c, proxyErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
||||
if errors.Is(err, service.ErrMediaNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// 直连流的同源转发:网页端需要读取视频帧的场景(VR 全景渲染走 WebGL 纹理)
|
||||
// 不能使用会跳到网盘 CDN 的跨域直链,这里把直链改为服务端转发。
|
||||
//
|
||||
// 只在客户端显式带上 ?proxy=1 时生效,普通播放仍走原来的 302 直连,
|
||||
// 避免把网盘流量无谓地压到服务器上。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// wantSameOriginProxy 客户端是否要求把直连流改为服务端同源转发。
|
||||
func wantSameOriginProxy(c *gin.Context) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(c.Query("proxy"))) {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// proxySTRMStream 处理 STRM/网盘媒体的同源转发请求。返回 handled=false 表示
|
||||
// 该媒体不需要(或无法)代理,调用方继续按原有逻辑处理。
|
||||
func proxySTRMStream(c *gin.Context, svc *service.Container, m *model.Media) (bool, error) {
|
||||
if c == nil || svc == nil || svc.Strm == nil || m == nil {
|
||||
return false, nil
|
||||
}
|
||||
if !service.IsStrmMediaRow(m) {
|
||||
return false, nil
|
||||
}
|
||||
if err := svc.Strm.ProxyMediaDirect(c.Request.Context(), c.Writer, c.Request, m); err != nil {
|
||||
if errors.Is(err, service.ErrStrmProxyNotApplicable) {
|
||||
return false, nil
|
||||
}
|
||||
return true, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// writeProxyError 在尚未写入任何响应内容时回一个明确的网关错误。
|
||||
func writeProxyError(c *gin.Context, err error) {
|
||||
if c == nil || err == nil || c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
@@ -174,38 +174,111 @@ func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ErrStrmProxyNotApplicable 表示该媒体没有需要服务端转发的直链(本地文件本身同源)。
|
||||
var ErrStrmProxyNotApplicable = errors.New("strm proxy not applicable")
|
||||
|
||||
// ProxyMediaDirect 把媒体行的网盘/STRM 直链解析为真实地址后由服务端反向代理给
|
||||
// 客户端,让浏览器拿到「同源」数据。画质与原文件完全一致,不触发任何转码。
|
||||
//
|
||||
// 用途:VR 全景播放要把视频帧读进 WebGL 纹理,而跨域直链(网盘 302 跳到 CDN)
|
||||
// 在浏览器里属于被污染的资源,WebGL 读取会抛 SecurityError;把流量经服务端转发
|
||||
// 是「原画 + VR」唯一可行的办法。
|
||||
func (s *StrmService) ProxyMediaDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, m *model.Media) error {
|
||||
if s == nil || m == nil {
|
||||
return ErrStrmProxyNotApplicable
|
||||
}
|
||||
raw := strings.TrimSpace(m.STRMURL)
|
||||
if raw == "" {
|
||||
path := strings.TrimSpace(m.Path)
|
||||
if !strings.HasSuffix(strings.ToLower(path), ".strm") {
|
||||
return ErrStrmProxyNotApplicable
|
||||
}
|
||||
target, err := readLocalSTRMTarget(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = strings.TrimSpace(target)
|
||||
}
|
||||
if raw == "" {
|
||||
return ErrStrmProxyNotApplicable
|
||||
}
|
||||
result, err := s.ResolvePlayTarget(ctx, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case result.Link != nil && result.Link.URL != "":
|
||||
return s.ProxyDirect(ctx, w, r, result.Link)
|
||||
case result.RedirectURL != "":
|
||||
return s.ProxyDirect(ctx, w, r, &cloud.DirectLink{URL: result.RedirectURL})
|
||||
default:
|
||||
// 本地文件(LocalPath)由静态文件处理器提供,本身就是同源。
|
||||
return ErrStrmProxyNotApplicable
|
||||
}
|
||||
}
|
||||
|
||||
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
|
||||
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
||||
if link == nil || link.URL == "" {
|
||||
return errors.New("空直链")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil)
|
||||
method := http.MethodGet
|
||||
if r != nil && r.Method == http.MethodHead {
|
||||
method = http.MethodHead
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, link.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range link.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
if r != nil {
|
||||
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
// 部分网盘直链按 UA 防盗链;解析时未绑定 UA 的直链沿用浏览器 UA 更稳。
|
||||
if ua := strings.TrimSpace(r.Header.Get("User-Agent")); ua != "" && req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", ua)
|
||||
}
|
||||
}
|
||||
resp, err := s.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag"} {
|
||||
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
|
||||
if value := resp.Header.Get(header); value != "" {
|
||||
w.Header().Set(header, value)
|
||||
}
|
||||
}
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
// 原样透传上游状态码:Range 请求必须回 206,改写成 200 会让浏览器误判
|
||||
// 响应长度,拖动进度条时反复重新拉流。
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
if resp.StatusCode == http.StatusPartialContent || resp.StatusCode == http.StatusOK {
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
if method == http.MethodHead {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
// 边转发边 flush,避免大体积视频被 net/http 的写缓冲切成一段段卡顿。
|
||||
writer := io.Writer(w)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
writer = &flushWriter{writer: w, flusher: flusher}
|
||||
}
|
||||
_, err = io.Copy(writer, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
type flushWriter struct {
|
||||
writer io.Writer
|
||||
flusher http.Flusher
|
||||
}
|
||||
|
||||
func (f *flushWriter) Write(p []byte) (int, error) {
|
||||
n, err := f.writer.Write(p)
|
||||
if f.flusher != nil {
|
||||
f.flusher.Flush()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 同源转发必须原样保留 Range 语义:把 206 改写成 200 会让浏览器误判响应长度,
|
||||
// 拖动进度条时反复重新拉流。
|
||||
func TestProxyMediaDirectForwardsRangeAndStatus(t *testing.T) {
|
||||
var gotRange string
|
||||
var gotUA string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotRange = r.Header.Get("Range")
|
||||
gotUA = r.Header.Get("User-Agent")
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.Header().Set("Content-Range", "bytes 10-19/100")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write([]byte("0123456789"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
svc := testStrmService(t)
|
||||
media := &model.Media{STRMURL: upstream.URL + "/video.mp4"}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||
req.Header.Set("Range", "bytes=10-19")
|
||||
req.Header.Set("User-Agent", "MeBoxTest/1.0")
|
||||
|
||||
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
|
||||
t.Fatalf("ProxyMediaDirect: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, want 206", rec.Code)
|
||||
}
|
||||
if gotRange != "bytes=10-19" {
|
||||
t.Fatalf("upstream Range = %q, want bytes=10-19", gotRange)
|
||||
}
|
||||
if gotUA != "MeBoxTest/1.0" {
|
||||
t.Fatalf("upstream User-Agent = %q, want MeBoxTest/1.0", gotUA)
|
||||
}
|
||||
if rec.Header().Get("Content-Range") != "bytes 10-19/100" {
|
||||
t.Fatalf("Content-Range = %q", rec.Header().Get("Content-Range"))
|
||||
}
|
||||
if rec.Body.String() != "0123456789" {
|
||||
t.Fatalf("body = %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 本地文件本身就是同源资源,不应该被代理(调用方按原静态文件逻辑处理)。
|
||||
func TestProxyMediaDirectSkipsLocalFile(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "movie.mkv")
|
||||
writeFile(t, path, "not-a-video")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||
err := svc.ProxyMediaDirect(context.Background(), rec, req, &model.Media{Path: path})
|
||||
if !errors.Is(err, ErrStrmProxyNotApplicable) {
|
||||
t.Fatalf("err = %v, want ErrStrmProxyNotApplicable", err)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Fatalf("local file must not be proxied, body = %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -66,6 +66,10 @@ type Vr360StageProps = {
|
||||
uiVisible: boolean
|
||||
/** 轻触画面(未发生拖拽)时回调,与普通播放一致:唤出控制栏或切换播放。 */
|
||||
onSurfaceTap: () => void
|
||||
/**
|
||||
* 第一帧真正画出来时回调,每次挂载只上报一次(改投影方式/画幅布局不会重新上报),
|
||||
* 调用方据此结束「正在启动」提示。
|
||||
*/
|
||||
onReady: () => void
|
||||
onError: (message: string) => void
|
||||
/** 用户调整投影方式/立体布局时回调,由播放页保存为下次进入的默认值。 */
|
||||
|
||||
+126
-32
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user