mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-09 06:46:37 +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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
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 err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
||||||
if !c.Writer.Written() {
|
if !c.Writer.Written() {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
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) {
|
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||||
return
|
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"))
|
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
||||||
if errors.Is(err, service.ErrMediaNotFound) {
|
if errors.Is(err, service.ErrMediaNotFound) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
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
|
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 请求头以支持拖动播放)。
|
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
|
||||||
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
||||||
if link == nil || link.URL == "" {
|
if link == nil || link.URL == "" {
|
||||||
return errors.New("空直链")
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for k, v := range link.Headers {
|
for k, v := range link.Headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
if r != nil {
|
||||||
req.Header.Set("Range", rangeHeader)
|
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)
|
resp, err := s.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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 != "" {
|
if value := resp.Header.Get(header); value != "" {
|
||||||
w.Header().Set(header, value)
|
w.Header().Set(header, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
// 原样透传上游状态码:Range 请求必须回 206,改写成 200 会让浏览器误判
|
||||||
w.WriteHeader(http.StatusOK)
|
// 响应长度,拖动进度条时反复重新拉流。
|
||||||
} else {
|
w.WriteHeader(resp.StatusCode)
|
||||||
w.WriteHeader(resp.StatusCode)
|
if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if resp.StatusCode == http.StatusPartialContent || resp.StatusCode == http.StatusOK {
|
if method == http.MethodHead {
|
||||||
_, _ = io.Copy(w, resp.Body)
|
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
|
// streamURL returns a direct-play URL for <video src>. The JWT is added as
|
||||||
// a query parameter because <video> elements cannot send Authorization
|
// a query parameter because <video> elements cannot send Authorization
|
||||||
// headers.
|
// 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.
|
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ type Vr360StageProps = {
|
|||||||
uiVisible: boolean
|
uiVisible: boolean
|
||||||
/** 轻触画面(未发生拖拽)时回调,与普通播放一致:唤出控制栏或切换播放。 */
|
/** 轻触画面(未发生拖拽)时回调,与普通播放一致:唤出控制栏或切换播放。 */
|
||||||
onSurfaceTap: () => void
|
onSurfaceTap: () => void
|
||||||
|
/**
|
||||||
|
* 第一帧真正画出来时回调,每次挂载只上报一次(改投影方式/画幅布局不会重新上报),
|
||||||
|
* 调用方据此结束「正在启动」提示。
|
||||||
|
*/
|
||||||
onReady: () => void
|
onReady: () => void
|
||||||
onError: (message: string) => void
|
onError: (message: string) => void
|
||||||
/** 用户调整投影方式/立体布局时回调,由播放页保存为下次进入的默认值。 */
|
/** 用户调整投影方式/立体布局时回调,由播放页保存为下次进入的默认值。 */
|
||||||
|
|||||||
+126
-32
@@ -33,7 +33,7 @@ import {
|
|||||||
type SubtitlePosition,
|
type SubtitlePosition,
|
||||||
type SubtitleStylePreset,
|
type SubtitleStylePreset,
|
||||||
} from '../utils/subtitleDisplay'
|
} 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 { classifyDirectPlayError } from './directPlayError'
|
||||||
import { apiErrorMessage } from './StrmManagePage'
|
import { apiErrorMessage } from './StrmManagePage'
|
||||||
import { PlayerTopBar } from './PlayerTopBar'
|
import { PlayerTopBar } from './PlayerTopBar'
|
||||||
@@ -45,6 +45,7 @@ import { mediaVersionsOf } from '../utils/mediaVersion'
|
|||||||
import {
|
import {
|
||||||
detectVr360Profile,
|
detectVr360Profile,
|
||||||
loadVr360Preference,
|
loadVr360Preference,
|
||||||
|
sameVr360Profile,
|
||||||
saveVr360Preference,
|
saveVr360Preference,
|
||||||
type Vr360Detection,
|
type Vr360Detection,
|
||||||
type Vr360Profile,
|
type Vr360Profile,
|
||||||
@@ -108,6 +109,17 @@ export function PlayerPage() {
|
|||||||
const [vr360, setVr360] = useState<Vr360Profile | null>(null)
|
const [vr360, setVr360] = useState<Vr360Profile | null>(null)
|
||||||
const [vr360Detected, setVr360Detected] = useState(false)
|
const [vr360Detected, setVr360Detected] = useState(false)
|
||||||
const vr360TouchedRef = useRef(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 [mode, setMode] = useState<PlayerMode>('direct')
|
||||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||||
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
|
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
|
||||||
@@ -471,6 +483,8 @@ export function PlayerPage() {
|
|||||||
setMedia(null)
|
setMedia(null)
|
||||||
setLoadError('')
|
setLoadError('')
|
||||||
vr360TouchedRef.current = false
|
vr360TouchedRef.current = false
|
||||||
|
vr360TextureRetryUsedRef.current = false
|
||||||
|
vr360TextureRetryUntilRef.current = 0
|
||||||
setVr360(null)
|
setVr360(null)
|
||||||
setVr360Detected(false)
|
setVr360Detected(false)
|
||||||
setHlsStartSec(0)
|
setHlsStartSec(0)
|
||||||
@@ -578,6 +592,35 @@ export function PlayerPage() {
|
|||||||
setVr360(detection.profile)
|
setVr360(detection.profile)
|
||||||
}, [id, media])
|
}, [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(() => {
|
const toggleVr360 = useCallback(() => {
|
||||||
vr360TouchedRef.current = true
|
vr360TouchedRef.current = true
|
||||||
if (vr360) {
|
if (vr360) {
|
||||||
@@ -607,13 +650,6 @@ export function PlayerPage() {
|
|||||||
saveVr360Preference({ ...loadVr360Preference(), profile })
|
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.
|
// Wire up the actual <video> element when we know the mode.
|
||||||
// Depend on media.id (not the media object): refreshing duration after
|
// Depend on media.id (not the media object): refreshing duration after
|
||||||
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
||||||
@@ -685,6 +721,33 @@ export function PlayerPage() {
|
|||||||
[mediaId, setPlaybackMode, switchToLocalHLS],
|
[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。
|
// 云端转码等待:按后端建议间隔轮询,完成后自动切到云 HLS。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cloudWaiting || !mediaId) return
|
if (!cloudWaiting || !mediaId) return
|
||||||
@@ -869,13 +932,30 @@ export function PlayerPage() {
|
|||||||
setPlaybackMode('direct')
|
setPlaybackMode('direct')
|
||||||
})
|
})
|
||||||
} else {
|
} 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
|
const absoluteURL = new URL(url, window.location.href).href
|
||||||
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
|
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
|
||||||
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
|
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
|
||||||
if (video.src !== absoluteURL) {
|
if (video.src !== absoluteURL) {
|
||||||
directRetryRef.current = false
|
directRetryRef.current = false
|
||||||
clearFallbackTimer()
|
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
|
video.src = url
|
||||||
void video.play().catch(() => undefined)
|
void video.play().catch(() => undefined)
|
||||||
}
|
}
|
||||||
@@ -904,6 +984,7 @@ export function PlayerPage() {
|
|||||||
setPlaybackMode,
|
setPlaybackMode,
|
||||||
switchToLocalHLS,
|
switchToLocalHLS,
|
||||||
teardownHls,
|
teardownHls,
|
||||||
|
vr360DirectProxy,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
|
// 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'
|
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||||
if (next === 'hls') {
|
if (next === 'hls') {
|
||||||
setHlsStartSec(0)
|
enterHlsPlayback(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()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setCloudWaiting(false)
|
setCloudWaiting(false)
|
||||||
}
|
}
|
||||||
setPlaybackMode(next)
|
setPlaybackMode(next)
|
||||||
}, [
|
}, [
|
||||||
|
enterHlsPlayback,
|
||||||
isDirectStream,
|
isDirectStream,
|
||||||
mode,
|
mode,
|
||||||
playbackInfo,
|
|
||||||
selectedQuality,
|
|
||||||
setPlaybackMode,
|
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(
|
const handleSeekAbsolute = useCallback(
|
||||||
(absoluteSec: number) => {
|
(absoluteSec: number) => {
|
||||||
if (mode !== 'hls') return false
|
if (mode !== 'hls') return false
|
||||||
@@ -1420,7 +1512,9 @@ export function PlayerPage() {
|
|||||||
|
|
||||||
if (retryingDirectRef.current) return
|
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({
|
const action = classifyDirectPlayError({
|
||||||
errorCode: video?.error?.code,
|
errorCode: video?.error?.code,
|
||||||
readyState: video?.readyState ?? 0,
|
readyState: video?.readyState ?? 0,
|
||||||
|
|||||||
@@ -263,7 +263,10 @@ export function PlayerVideoStage({
|
|||||||
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
||||||
const [controlsVisible, setControlsVisible] = useState(true)
|
const [controlsVisible, setControlsVisible] = useState(true)
|
||||||
// VR 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
|
// 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)
|
const revealControlsOnlyRef = useRef(false)
|
||||||
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
||||||
const [activeCues, setActiveCues] = useState<SubtitleCue[]>([])
|
const [activeCues, setActiveCues] = useState<SubtitleCue[]>([])
|
||||||
@@ -286,11 +289,6 @@ export function PlayerVideoStage({
|
|||||||
setAssFallbackPath(null)
|
setAssFallbackPath(null)
|
||||||
}, [media?.id, activeSubtitleTrack?.path, subs])
|
}, [media?.id, activeSubtitleTrack?.path, subs])
|
||||||
|
|
||||||
// 切换媒体或 VR 配置后重新等待渲染器的第一帧。
|
|
||||||
useEffect(() => {
|
|
||||||
setVrReady(false)
|
|
||||||
}, [media?.id, vr360?.projection, vr360?.stereo])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectedTrack = subs[subtitleIndex]
|
const selectedTrack = subs[subtitleIndex]
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -639,7 +637,7 @@ export function PlayerVideoStage({
|
|||||||
profile={vr360}
|
profile={vr360}
|
||||||
uiVisible={controlsVisible}
|
uiVisible={controlsVisible}
|
||||||
onSurfaceTap={handleSurfaceActivate}
|
onSurfaceTap={handleSurfaceActivate}
|
||||||
onReady={() => setVrReady(true)}
|
onReady={() => setVrReadyMediaId(media.id)}
|
||||||
onError={(message) => onVr360Error?.(message)}
|
onError={(message) => onVr360Error?.(message)}
|
||||||
onProfileChange={onVr360ProfileChange}
|
onProfileChange={onVr360ProfileChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -78,6 +78,23 @@ check('VR180 的 1:2 画幅为上下并排', vr180Ou.profile.stereo === 'ou')
|
|||||||
const topBottom = detectVr360Profile({ path: '/media/vr/vr360-top-bottom.mp4' })
|
const topBottom = detectVr360Profile({ path: '/media/vr/vr360-top-bottom.mp4' })
|
||||||
check('top-bottom 关键词为上下并排', topBottom.profile.stereo === 'ou')
|
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]))
|
check('单眼不裁切', closeAll(vr360FrameUv('mono').scale, [1, 1]))
|
||||||
|
|||||||
+13
-5
@@ -93,16 +93,22 @@ export type Vr360Detection = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 关键词分两类:明确的关键词按子串匹配("vr360" 里也含 "360"),
|
// 关键词分两类:明确的关键词按子串匹配("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 FISHEYE_SUBSTRINGS = ['fisheye', '鱼眼']
|
||||||
const VR180_SUBSTRINGS = ['vr180', '180vr', '180°', '180度', '半景']
|
const VR180_SUBSTRINGS = ['vr180', '180vr', '180°', '180度', '半景']
|
||||||
const SBS_SUBSTRINGS = ['side-by-side', 'sidebyside', 'side_by_side', '左右']
|
const SBS_SUBSTRINGS = ['side-by-side', 'sidebyside', 'side_by_side', '左右']
|
||||||
const OU_SUBSTRINGS = ['over-under', 'top-bottom', 'topbottom', '上下']
|
const OU_SUBSTRINGS = ['over-under', 'top-bottom', 'topbottom', '上下']
|
||||||
const VR_TOKENS = ['vr']
|
|
||||||
const SBS_TOKENS = ['sbs', 'hsbs', 'lr']
|
const SBS_TOKENS = ['sbs', 'hsbs', 'lr']
|
||||||
const OU_TOKENS = ['ou', 'tb', 'vou', 'tou']
|
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 {
|
function detectText(input: Vr360DetectInput): string {
|
||||||
return [input.path, input.relativePath, input.originalName, input.title]
|
return [input.path, input.relativePath, input.originalName, input.title]
|
||||||
.filter((value): value is string => Boolean(value))
|
.filter((value): value is string => Boolean(value))
|
||||||
@@ -152,8 +158,10 @@ export function detectVr360Profile(input: Vr360DetectInput): Vr360Detection {
|
|||||||
|
|
||||||
const fisheye = hasSubstring(text, FISHEYE_SUBSTRINGS)
|
const fisheye = hasSubstring(text, FISHEYE_SUBSTRINGS)
|
||||||
const vr180 = hasSubstring(text, VR180_SUBSTRINGS)
|
const vr180 = hasSubstring(text, VR180_SUBSTRINGS)
|
||||||
const panorama = hasSubstring(text, PANORAMA_SUBSTRINGS)
|
const panorama =
|
||||||
const vrToken = hasToken(tokens, VR_TOKENS) || nameText.includes('vr')
|
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
|
const confident = fisheye || vr180 || panorama || vrToken
|
||||||
|
|
||||||
let projection: Vr360Projection = DEFAULT_VR360_PROFILE.projection
|
let projection: Vr360Projection = DEFAULT_VR360_PROFILE.projection
|
||||||
|
|||||||
Reference in New Issue
Block a user