mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30ca94c79b | |||
| 9c1f9a6e7e | |||
| ec7be38266 | |||
| ebf791c436 | |||
| 83c0b65a0b | |||
| 1153fe4191 | |||
| 93866d169b | |||
| d283d39008 |
@@ -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)
|
||||
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
+12
-9
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -179,9 +178,21 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
|
||||
libraryIDs = []string{media.LibraryID}
|
||||
}
|
||||
filter := repository.MediaQueryFilter{IncludeNSFW: true}
|
||||
candidates, narrowed, err := s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// 成人条目按番号分组,而番号常常只存在于路径/标题里:本地 NFO 来源的分片
|
||||
// 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部
|
||||
// ID 预先收窄候选集,同番号的其它分片会被 SQL 直接排除,表现就是库里折叠
|
||||
// 出了 N 个版本、详情页却只列出带 ID 的那几个。因此成人条目不预先收窄,
|
||||
// 直接在该库范围内比对版本键。未刮削但文件名带番号的分片同理:同一部片的
|
||||
// 各分片元数据来源不一致(刮削 vs pending),收窄后标题也对不上。
|
||||
var candidates []model.Media
|
||||
narrowed := false
|
||||
hasAdultCode := mediaAdultGroupCode(*media) != "" ||
|
||||
canonicalAdultGroupCode(AdultCodeFromMediaPath(media.Path)) != ""
|
||||
if !hasAdultCode {
|
||||
candidates, narrowed, err = s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !narrowed {
|
||||
candidates, err = s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
|
||||
@@ -190,23 +201,21 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
|
||||
}
|
||||
}
|
||||
s.attachLibraryMetadata(ctx, candidates)
|
||||
// 同番号只要有一个分片被确认为成人内容,尚未刮削的分片也按番号折叠,
|
||||
// 与列表页的分组结果保持一致。
|
||||
vouched := adultCodesVouchedByNSFW(candidates)
|
||||
if vouchKey := adultVouchedGroupKey(*media, vouched); vouchKey != "" {
|
||||
key = vouchKey
|
||||
}
|
||||
matched := make([]model.Media, 0, 4)
|
||||
for _, row := range candidates {
|
||||
if mediaVersionGroupKey(row) == key {
|
||||
if mediaVersionGroupKeyWithAdultVouch(row, vouched) == key {
|
||||
matched = append(matched, row)
|
||||
}
|
||||
}
|
||||
if len(matched) == 0 {
|
||||
return []model.Media{*media}, nil
|
||||
}
|
||||
sort.SliceStable(matched, func(i, j int) bool {
|
||||
if matched[i].ID == media.ID {
|
||||
return true
|
||||
}
|
||||
if matched[j].ID == media.ID {
|
||||
return false
|
||||
}
|
||||
return betterMediaVersion(matched[i], matched[j])
|
||||
})
|
||||
sortMediaVersionsForDisplay(matched)
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package service
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
@@ -75,35 +77,33 @@ func groupMediaVersions(items []model.Media) []MediaItem {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
// 同一番号只要有一个分片被刮削标记为成人内容,就认定整部片都是成人条目:
|
||||
// 未刮削的分片(nsfw=false)也按番号折叠。否则「一个分片刮削成功、其余仍
|
||||
// pending」时,已刮削的走番号分组、其余落到标题分组,整部片被拆成两张卡。
|
||||
adultCodes := adultCodesVouchedByNSFW(items)
|
||||
type group struct {
|
||||
key string
|
||||
primary model.Media
|
||||
rows []model.Media
|
||||
key string
|
||||
rows []model.Media
|
||||
}
|
||||
groups := make([]group, 0, len(items))
|
||||
byKey := make(map[string]int, len(items))
|
||||
for _, item := range items {
|
||||
key := mediaVersionGroupKey(item)
|
||||
key := mediaVersionGroupKeyWithAdultVouch(item, adultCodes)
|
||||
if key == "" {
|
||||
groups = append(groups, group{primary: item, rows: []model.Media{item}})
|
||||
groups = append(groups, group{rows: []model.Media{item}})
|
||||
continue
|
||||
}
|
||||
if idx, ok := byKey[key]; ok {
|
||||
groups[idx].rows = append(groups[idx].rows, item)
|
||||
if betterMediaVersion(item, groups[idx].primary) {
|
||||
groups[idx].primary = item
|
||||
}
|
||||
continue
|
||||
}
|
||||
byKey[key] = len(groups)
|
||||
groups = append(groups, group{key: key, primary: item, rows: []model.Media{item}})
|
||||
groups = append(groups, group{key: key, rows: []model.Media{item}})
|
||||
}
|
||||
out := make([]MediaItem, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
sort.SliceStable(g.rows, func(i, j int) bool {
|
||||
return betterMediaVersion(g.rows[i], g.rows[j])
|
||||
})
|
||||
item := MediaItem{Media: g.primary}
|
||||
sortMediaVersionsForDisplay(g.rows)
|
||||
item := MediaItem{Media: g.rows[0]}
|
||||
if len(g.rows) > 1 {
|
||||
item.Versions = g.rows
|
||||
}
|
||||
@@ -139,16 +139,65 @@ func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem {
|
||||
return grouped
|
||||
}
|
||||
|
||||
func mediaVersionGroupKey(m model.Media) string {
|
||||
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
||||
if IsEmbyRemoteID(m.ID) {
|
||||
return fmt.Sprintf("embyremote:%s", m.ID)
|
||||
}
|
||||
|
||||
// mediaVersionLibraryKey 返回版本身份里使用的库标识。
|
||||
func mediaVersionLibraryKey(m model.Media) string {
|
||||
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
|
||||
if libKey == "" {
|
||||
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
|
||||
}
|
||||
return libKey
|
||||
}
|
||||
|
||||
// adultCodesVouchedByNSFW 收集「已被刮削确认为成人内容」的番号。
|
||||
func adultCodesVouchedByNSFW(items []model.Media) map[string]bool {
|
||||
codes := map[string]bool{}
|
||||
for _, item := range items {
|
||||
if !item.NSFW {
|
||||
continue
|
||||
}
|
||||
if code := canonicalAdultGroupCode(AdultCodeFromMediaPath(item.Path)); code != "" {
|
||||
codes[code] = true
|
||||
continue
|
||||
}
|
||||
if code := mediaAdultGroupCode(item); code != "" {
|
||||
codes[code] = true
|
||||
}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// mediaVersionGroupKeyWithAdultVouch 是版本身份的统一入口:番号已被同组分片
|
||||
// 确认时优先按番号折叠,否则沿用 mediaVersionGroupKey 的原判定。
|
||||
func mediaVersionGroupKeyWithAdultVouch(m model.Media, vouched map[string]bool) string {
|
||||
if key := adultVouchedGroupKey(m, vouched); key != "" {
|
||||
return key
|
||||
}
|
||||
return mediaVersionGroupKey(m)
|
||||
}
|
||||
|
||||
// adultVouchedGroupKey 在番号已被同组分片确认时,返回番号分组键;
|
||||
// 其余情况返回空串,表示沿用 mediaVersionGroupKey 的原判定。
|
||||
func adultVouchedGroupKey(m model.Media, vouched map[string]bool) string {
|
||||
if len(vouched) == 0 || m.NSFW {
|
||||
return ""
|
||||
}
|
||||
code := canonicalAdultGroupCode(AdultCodeFromMediaPath(m.Path))
|
||||
if code == "" || !vouched[code] {
|
||||
return ""
|
||||
}
|
||||
libKey := mediaVersionLibraryKey(m)
|
||||
if libKey == "" {
|
||||
libKey = "_"
|
||||
}
|
||||
return fmt.Sprintf("adult:%s:%s", libKey, code)
|
||||
}
|
||||
|
||||
func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
||||
if IsEmbyRemoteID(m.ID) {
|
||||
return fmt.Sprintf("embyremote:%s", m.ID)
|
||||
}
|
||||
|
||||
libKey := mediaVersionLibraryKey(m)
|
||||
specialKind := mediaSpecialKind(m.Path)
|
||||
season, episode := m.SeasonNum, m.EpisodeNum
|
||||
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
|
||||
@@ -193,6 +242,18 @@ func mediaVersionGroupKey(m model.Media) string {
|
||||
return mediaVersionStemGroupKey(m, libKey)
|
||||
}
|
||||
|
||||
// 成人条目按番号折叠。在线刮削(MetaTube)会把番号与 provider「借用」
|
||||
// 存进 douban_id/thetvdb_id,本地 NFO 路径则拿不到这两个字段;一旦按
|
||||
// 外部 ID 分组,同一部片的各个分片就会因为元数据来源不同(在线刮削 vs
|
||||
// 本地 NFO)裂成两张标题相同的卡。番号取自原文件名/路径/标题,与元数据
|
||||
// 来源无关,因此这里统一用番号做版本身份。
|
||||
if code := mediaAdultGroupCode(m); code != "" {
|
||||
if libKey == "" {
|
||||
libKey = "_"
|
||||
}
|
||||
return fmt.Sprintf("adult:%s:%s", libKey, code)
|
||||
}
|
||||
|
||||
switch {
|
||||
case m.TMDbID > 0:
|
||||
if libKey != "" {
|
||||
@@ -267,6 +328,44 @@ func mediaVersionGroupKey(m model.Media) string {
|
||||
return fmt.Sprintf("movie:%s:%d", title, year)
|
||||
}
|
||||
|
||||
// mediaAdultGroupCode 返回成人条目的番号,作为与元数据来源无关的版本身份。
|
||||
//
|
||||
// 只在已经确认是成人条目(NSFW)时才返回番号:番号本身来自原文件名/路径/
|
||||
// 标题,不会随「在线刮削(MetaTube 把番号与 provider 借用进 douban_id/
|
||||
// thetvdb_id)」还是「本地 NFO(不带外部 ID)」而改变,因此两条来源能算出
|
||||
// 同一个键。未标记 NSFW 的条目仍走标题分组,普通影视库里 "The Matrix 1999"
|
||||
// 这类文件名不会被当成番号。
|
||||
func mediaAdultGroupCode(m model.Media) string {
|
||||
if !m.NSFW {
|
||||
return ""
|
||||
}
|
||||
code := firstText(
|
||||
normalizeAdultCode(m.OriginalName),
|
||||
AdultCodeFromMediaPath(m.Path),
|
||||
normalizeAdultCode(m.Title),
|
||||
)
|
||||
if code == "" {
|
||||
return ""
|
||||
}
|
||||
return canonicalAdultGroupCode(code)
|
||||
}
|
||||
|
||||
// canonicalAdultGroupCode 去掉番号数字部分的补零,让 IPVR-00192 与 IPVR-192
|
||||
// 这类同一部片的不同写法落到同一个分组键上。无法解析数字的番号原样返回
|
||||
// (如 FC2-PPV-4701981)。
|
||||
func canonicalAdultGroupCode(code string) string {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
prefix, digits, ok := strings.Cut(code, "-")
|
||||
if !ok {
|
||||
return code
|
||||
}
|
||||
value, err := strconv.Atoi(digits)
|
||||
if err != nil {
|
||||
return code
|
||||
}
|
||||
return prefix + "-" + strconv.Itoa(value)
|
||||
}
|
||||
|
||||
// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
|
||||
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
|
||||
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
|
||||
@@ -300,8 +399,11 @@ func mediaVersionFileStem(name string) string {
|
||||
}
|
||||
|
||||
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
|
||||
// 当只有容器等无法区分多版本的信息时,回退到清理后的文件名。
|
||||
func MediaVersionLabel(m model.Media) string {
|
||||
parts := make([]string, 0, 4)
|
||||
isStrm := strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") ||
|
||||
strings.TrimSpace(m.STRMURL) != ""
|
||||
if m.Height > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dp", m.Height))
|
||||
} else if m.Width > 0 {
|
||||
@@ -317,17 +419,91 @@ func MediaVersionLabel(m model.Media) string {
|
||||
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
|
||||
parts = append(parts, strings.ToUpper(codec))
|
||||
}
|
||||
if m.SizeBytes > 0 {
|
||||
// STRM 占位体积(通常几十到几百字节)不能区分分片,跳过。
|
||||
placeholderSize := isStrm && m.SizeBytes > 0 && m.SizeBytes < 1024*1024
|
||||
if m.SizeBytes > 0 && !placeholderSize {
|
||||
parts = append(parts, formatMediaSize(m.SizeBytes))
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
if len(parts) > 0 && !mediaVersionLabelIndistinct(parts) {
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/"))
|
||||
if base == "" || base == "." {
|
||||
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
|
||||
if cleaned := cleanMediaVersionFilename(m.Path); cleaned != "" {
|
||||
return cleaned
|
||||
}
|
||||
return base
|
||||
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
|
||||
}
|
||||
|
||||
func mediaVersionLabelIndistinct(parts []string) bool {
|
||||
if len(parts) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, part := range parts {
|
||||
upper := strings.ToUpper(strings.TrimSpace(part))
|
||||
if upper == "" || upper == "云端直链" || upper == "STRM" {
|
||||
continue
|
||||
}
|
||||
if _, ok := videoExtensions["."+strings.ToLower(upper)]; ok {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cleanMediaVersionFilename(path string) string {
|
||||
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
|
||||
if base == "" || base == "." {
|
||||
return ""
|
||||
}
|
||||
stem := mediaFileStem(base)
|
||||
stem = strings.ReplaceAll(stem, "_", " ")
|
||||
stem = strings.ReplaceAll(stem, ".", " ")
|
||||
return strings.Join(strings.Fields(stem), " ")
|
||||
}
|
||||
|
||||
// mediaPartNumber 从路径提取分片序号(part1 / cd2 / -3)。
|
||||
// 裸后缀只认 1–2 位数字,避免把年份 2024 当成 part。
|
||||
func mediaPartNumber(path string) (int, bool) {
|
||||
stem := mediaFileStem(filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")))
|
||||
if stem == "" {
|
||||
return 0, false
|
||||
}
|
||||
lower := strings.ToLower(stem)
|
||||
for _, re := range mediaPartNumberPatterns {
|
||||
if m := re.FindStringSubmatch(lower); len(m) == 2 {
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err == nil && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var mediaPartNumberPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?:^|[.\-_])part[\s._-]*(\d{1,3})$`),
|
||||
regexp.MustCompile(`(?:^|[.\-_])(?:cd|disc|disk)[\s._-]*(\d{1,3})$`),
|
||||
regexp.MustCompile(`[-_](\d{1,2})$`),
|
||||
}
|
||||
|
||||
func sortMediaVersionsForDisplay(rows []model.Media) {
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
return compareMediaVersionsForDisplay(rows[i], rows[j])
|
||||
})
|
||||
}
|
||||
|
||||
// compareMediaVersionsForDisplay:有分片号时按 1→2→3… 升序;否则按画质/体积择优。
|
||||
func compareMediaVersionsForDisplay(a, b model.Media) bool {
|
||||
pa, oka := mediaPartNumber(a.Path)
|
||||
pb, okb := mediaPartNumber(b.Path)
|
||||
if oka && okb {
|
||||
if pa != pb {
|
||||
return pa < pb
|
||||
}
|
||||
} else if oka != okb {
|
||||
return oka
|
||||
}
|
||||
return betterMediaVersion(a, b)
|
||||
}
|
||||
|
||||
func mediaVersionContainerFromPath(path, strmURL string) string {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 真实场景:同一部片的分片里只有第 4 个被刮削成功(nsfw=true),其余仍是 pending。
|
||||
// 修复前:已刮削的分片走「番号」分组、其余落到「标题」分组,7 个分片会裂成两张卡
|
||||
// (一张只有第 4 个,另一张是其余 6 个)。
|
||||
func TestGroupMediaVersionsKeepsUnscrapedPartsWithScrapedSibling(t *testing.T) {
|
||||
const libID = "lib-1"
|
||||
const dir = "/media/云下载/sivr-270/"
|
||||
|
||||
parts := func(nums ...int) []model.Media {
|
||||
out := make([]model.Media, 0, len(nums))
|
||||
for _, n := range nums {
|
||||
out = append(out, model.Media{
|
||||
LibraryID: libID,
|
||||
Title: "sivr 270",
|
||||
Path: dir + "sivr-270-" + strconv.Itoa(n) + ".strm",
|
||||
Container: "strm",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
items := parts(1, 2, 3)
|
||||
items = append(items, model.Media{
|
||||
LibraryID: libID,
|
||||
Title: "SIVR-270-【VR】河北彩花×ご奉仕ナース",
|
||||
OriginalName: "SIVR-270",
|
||||
Path: dir + "sivr-270-4.strm",
|
||||
NSFW: true,
|
||||
ScrapeStatus: "matched",
|
||||
Container: "strm",
|
||||
})
|
||||
items = append(items, parts(5, 6, 7)...)
|
||||
|
||||
grouped := GroupMediaVersions(items)
|
||||
if len(grouped) != 1 {
|
||||
paths := make([]string, 0, len(grouped))
|
||||
for _, g := range grouped {
|
||||
paths = append(paths, g.Path)
|
||||
}
|
||||
t.Fatalf("分成 %d 张卡 %v,期望折叠成 1 张", len(grouped), paths)
|
||||
}
|
||||
if len(grouped[0].Versions) != 7 {
|
||||
t.Fatalf("版本数 = %d,期望 7", len(grouped[0].Versions))
|
||||
}
|
||||
}
|
||||
|
||||
// 反向保护:没有任何分片被确认为成人内容时,不能仅凭文件名里的番号形态折叠,
|
||||
// 否则普通媒体也可能被误并进同一张卡。
|
||||
func TestGroupMediaVersionsIgnoresUnvouchedAdultCode(t *testing.T) {
|
||||
items := []model.Media{
|
||||
{LibraryID: "lib-2", Title: "Alpha Movie", Path: "/media/movies/Alpha Movie/ABC-123-1080p.mkv"},
|
||||
{LibraryID: "lib-2", Title: "Beta Movie", Path: "/media/movies/Beta Movie/ABC-123-2160p.mkv"},
|
||||
}
|
||||
grouped := GroupMediaVersions(items)
|
||||
if len(grouped) != 2 {
|
||||
t.Fatalf("分成 %d 张卡,期望 2 张(未被确认成人内容的番号不参与折叠)", len(grouped))
|
||||
}
|
||||
}
|
||||
@@ -442,3 +442,298 @@ func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) {
|
||||
t.Fatalf("unexpected label %q", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaPartNumberFromPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm", 1, true},
|
||||
{"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm", 2, true},
|
||||
{"/media/云下载/sivr-270/sivr-270-1.strm", 1, true},
|
||||
{"/media/云下载/sivr-270/sivr-270-7.strm", 7, true},
|
||||
{"/media/云下载/fc2/FC2PPV-4701981-cd2.strm", 2, true},
|
||||
{"/media/Movies/Inception.2010.1080p.BluRay.x264.mkv", 0, false},
|
||||
{"/media/Movies/Movie.2024.mkv", 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := mediaPartNumber(tc.path)
|
||||
if ok != tc.ok || got != tc.want {
|
||||
t.Fatalf("mediaPartNumber(%q) = (%d, %v), want (%d, %v)", tc.path, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsSortsPartsAscending(t *testing.T) {
|
||||
rows := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "p3"},
|
||||
LibraryID: "cloud",
|
||||
Title: "SIVR-270",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-3.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
SizeBytes: 300,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "p1"},
|
||||
LibraryID: "cloud",
|
||||
Title: "SIVR-270",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
SizeBytes: 100,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "p2"},
|
||||
LibraryID: "cloud",
|
||||
Title: "SIVR-270",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-2.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
SizeBytes: 200,
|
||||
},
|
||||
}
|
||||
grouped := groupMediaVersions(rows)
|
||||
if len(grouped) != 1 || len(grouped[0].Versions) != 3 {
|
||||
t.Fatalf("grouped = %#v", grouped)
|
||||
}
|
||||
want := []string{"p1", "p2", "p3"}
|
||||
for i, id := range want {
|
||||
if grouped[0].Versions[i].ID != id {
|
||||
t.Fatalf("versions[%d] = %q, want %q (order %#v)", i, grouped[0].Versions[i].ID, id, grouped[0].Versions)
|
||||
}
|
||||
}
|
||||
if grouped[0].ID != "p1" {
|
||||
t.Fatalf("primary = %q, want p1 (first part)", grouped[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaVersionLabelFallsBackToFilenameWhenIndistinct(t *testing.T) {
|
||||
label := MediaVersionLabel(model.Media{
|
||||
Title: "SIVR-270-【VR】河北彩花",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||
SizeBytes: 157,
|
||||
STRMURL: "/api/strm/play/cloud115/video.mp4?pickcode=a",
|
||||
})
|
||||
if !strings.Contains(label, "sivr-270-1") {
|
||||
t.Fatalf("indistinct strm label should fall back to filename, got %q", label)
|
||||
}
|
||||
if strings.EqualFold(label, "MP4") || strings.Contains(label, "云端") {
|
||||
t.Fatalf("should not keep generic-only label %q", label)
|
||||
}
|
||||
}
|
||||
|
||||
// SIVR-270 现场:一部分分片走在线刮削(MetaTube 把番号/provider 借用进
|
||||
// douban_id/thetvdb_id),另一部分只从本地 NFO 拿到标题(没有外部 ID)。
|
||||
// 按外部 ID 分组会把它们裂成两张标题完全相同的卡,必须按番号折成一张。
|
||||
func TestGroupMediaVersionsMergesAdultPartsAcrossMetadataSources(t *testing.T) {
|
||||
online := model.Media{
|
||||
Base: model.Base{ID: "sivr-270-2"},
|
||||
LibraryID: "cloud",
|
||||
Title: "SIVR-270-【VR】河北彩花",
|
||||
OriginalName: "SIVR-270",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-2.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
DoubanID: "SIVR-270",
|
||||
TheTVDBID: "JavBus",
|
||||
ScrapeStatus: "matched",
|
||||
SizeBytes: 200,
|
||||
}
|
||||
fromNFO := model.Media{
|
||||
Base: model.Base{ID: "sivr-270-3"},
|
||||
LibraryID: "cloud",
|
||||
Title: "SIVR-270-【VR】河北彩花",
|
||||
OriginalName: "SIVR-270",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-3.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
ScrapeStatus: "matched",
|
||||
SizeBytes: 100,
|
||||
}
|
||||
|
||||
grouped := groupMediaVersions([]model.Media{online, fromNFO})
|
||||
if len(grouped) != 1 {
|
||||
t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped)
|
||||
}
|
||||
if len(grouped[0].Versions) != 2 {
|
||||
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
|
||||
}
|
||||
if grouped[0].Path != online.Path {
|
||||
t.Fatalf("larger part should stay primary, got %q", grouped[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsKeepsDifferentAdultCodesSeparate(t *testing.T) {
|
||||
rows := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "sivr-270"},
|
||||
LibraryID: "cloud",
|
||||
Title: "SIVR-270 作品",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "sivr-271"},
|
||||
LibraryID: "cloud",
|
||||
Title: "SIVR-271 作品",
|
||||
Path: "/media/云下载/sivr-271/sivr-271-1.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
},
|
||||
}
|
||||
grouped := groupMediaVersions(rows)
|
||||
if len(grouped) != 2 {
|
||||
t.Fatalf("different adult codes must stay separate: %#v", grouped)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一部片的两种写法:路径里的 IPVR-00192 与刮削回来的 IPVR-192 必须归一。
|
||||
func TestGroupMediaVersionsMergesAdultCodesIgnoringZeroPadding(t *testing.T) {
|
||||
scraped := model.Media{
|
||||
Base: model.Base{ID: "ipvr-192-part2"},
|
||||
LibraryID: "cloud",
|
||||
Title: "IPVR-192-相沢みなみ",
|
||||
OriginalName: "IPVR-192",
|
||||
Path: "/media/云下载/ipvr00192pl/scraped/part2.strm",
|
||||
Year: 2022,
|
||||
NSFW: true,
|
||||
ScrapeStatus: "matched",
|
||||
SizeBytes: 200,
|
||||
}
|
||||
fromPath := model.Media{
|
||||
Base: model.Base{ID: "ipvr-00192-part1"},
|
||||
LibraryID: "cloud",
|
||||
Title: "fbzip com@ipvr00192",
|
||||
Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm",
|
||||
Year: 2022,
|
||||
NSFW: true,
|
||||
SizeBytes: 100,
|
||||
}
|
||||
grouped := groupMediaVersions([]model.Media{scraped, fromPath})
|
||||
if len(grouped) != 1 || len(grouped[0].Versions) != 2 {
|
||||
t.Fatalf("zero padded code must fold into one item: %#v", grouped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsMergesAdultPartsWithBorrowedMetaTubeIDs(t *testing.T) {
|
||||
// IPVR-00192 现场:两个 part 标题、年份完全一样,但 part2 是走 MetaTube
|
||||
// 刮削的(番号/provider 被借用进 douban_id/thetvdb_id),part1 只有本地
|
||||
// NFO 身份、两个字段为空。旧逻辑按外部 ID 分组会裂成两张卡。
|
||||
part1 := model.Media{
|
||||
Base: model.Base{ID: "ipvr-00192-part1"},
|
||||
LibraryID: "cloud",
|
||||
Title: "IPVR-00192-IPVR-192-【VR】相沢みなみ",
|
||||
OriginalName: "IPVR-00192",
|
||||
Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm",
|
||||
Year: 2022,
|
||||
NSFW: true,
|
||||
ScrapeStatus: "matched",
|
||||
SizeBytes: 100,
|
||||
}
|
||||
part2 := part1
|
||||
part2.ID = "ipvr-00192-part2"
|
||||
part2.Path = "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm"
|
||||
part2.DoubanID = "ipvr00192"
|
||||
part2.TheTVDBID = "JAV321"
|
||||
part2.SizeBytes = 200
|
||||
|
||||
grouped := groupMediaVersions([]model.Media{part1, part2})
|
||||
if len(grouped) != 1 {
|
||||
t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped)
|
||||
}
|
||||
if len(grouped[0].Versions) != 2 {
|
||||
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
|
||||
}
|
||||
if grouped[0].Versions[0].ID != "ipvr-00192-part1" || grouped[0].Versions[1].ID != "ipvr-00192-part2" {
|
||||
t.Fatalf("versions should be part1 then part2, got %#v", grouped[0].Versions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMediaItemListsAdultVersionsAcrossMetadataSources(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "云下载", Path: "/media/云下载", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := []model.Media{
|
||||
{
|
||||
// 走 MetaTube 刮削的分片:番号/provider 被借用进 douban/thetvdb。
|
||||
LibraryID: lib.ID,
|
||||
Title: "SIVR-270-【VR】河北彩花",
|
||||
OriginalName: "SIVR-270",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
DoubanID: "SIVR-270",
|
||||
TheTVDBID: "JavBus",
|
||||
ScrapeStatus: "matched",
|
||||
SizeBytes: 200,
|
||||
},
|
||||
{
|
||||
// 只有本地 NFO 身份的分片:两个外部 ID 都为空。
|
||||
LibraryID: lib.ID,
|
||||
Title: "SIVR-270-【VR】河北彩花",
|
||||
OriginalName: "SIVR-270",
|
||||
Path: "/media/云下载/sivr-270/sivr-270-4.strm",
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
ScrapeStatus: "matched",
|
||||
SizeBytes: 100,
|
||||
},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
|
||||
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
|
||||
item, err := svc.GetMediaItem(t.Context(), rows[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item == nil || len(item.Versions) != 2 {
|
||||
// 详情页版本列表不能只按外部 ID 收窄,否则没有 ID 的分片会消失。
|
||||
t.Fatalf("versions = %#v, want both parts", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaAdultGroupCodeRequiresNSFW(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
m model.Media
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "普通影片名里的字母+数字不当番号",
|
||||
m: model.Media{Title: "Spider-Man 2008 1080p", Path: "/media/Movies/Spider-Man 2008 1080p.mkv"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "未识别成人的条目即使文件名像番号也不折叠",
|
||||
m: model.Media{Title: "SIVR-270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "已标记 NSFW 时按番号分组",
|
||||
m: model.Media{NSFW: true, Title: "sivr 270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"},
|
||||
want: "SIVR-270",
|
||||
},
|
||||
{
|
||||
name: "站点前缀里带补零的番号会归一",
|
||||
m: model.Media{NSFW: true, Title: "fbzip com@ipvr00192", Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm"},
|
||||
want: "IPVR-192",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := mediaAdultGroupCode(tc.m); got != tc.want {
|
||||
t.Fatalf("mediaAdultGroupCode = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+44
-14
@@ -18,6 +18,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -48,9 +49,13 @@ type movieNFO struct {
|
||||
Poster string `xml:"thumb,omitempty"`
|
||||
Fanart string `xml:"fanart,omitempty"`
|
||||
TMDb int `xml:"tmdbid,omitempty"`
|
||||
Genre []string `xml:"genre,omitempty"`
|
||||
Country []string `xml:"country,omitempty"`
|
||||
Language []string `xml:"language,omitempty"`
|
||||
// UniqueIDs 让 sidecar 能把 MeBox 自己用的外部 ID 原样带回来。缺了它,
|
||||
// 「在线刮削 → 写 NFO → 重扫读 NFO」会丢掉 douban/thetvdb(成人条目借用
|
||||
// 这两个字段存番号/provider),同一部片的两条来源就会算出不同的版本键。
|
||||
UniqueIDs []nfoUniqueID `xml:"uniqueid,omitempty"`
|
||||
Genre []string `xml:"genre,omitempty"`
|
||||
Country []string `xml:"country,omitempty"`
|
||||
Language []string `xml:"language,omitempty"`
|
||||
}
|
||||
|
||||
type episodeNFO struct {
|
||||
@@ -167,17 +172,18 @@ func WriteMediaNFO(m *model.Media) (string, error) {
|
||||
}
|
||||
}
|
||||
doc = movieNFO{
|
||||
Title: title,
|
||||
Original: original,
|
||||
Year: m.Year,
|
||||
Plot: m.Overview,
|
||||
Rating: m.Rating,
|
||||
Poster: m.PosterURL,
|
||||
Fanart: m.BackdropURL,
|
||||
TMDb: m.TMDbID,
|
||||
Genre: splitNFOList(m.Genres),
|
||||
Country: splitNFOList(m.Countries),
|
||||
Language: splitNFOList(m.Languages),
|
||||
Title: title,
|
||||
Original: original,
|
||||
Year: m.Year,
|
||||
Plot: m.Overview,
|
||||
Rating: m.Rating,
|
||||
Poster: m.PosterURL,
|
||||
Fanart: m.BackdropURL,
|
||||
TMDb: m.TMDbID,
|
||||
UniqueIDs: nfoExternalUniqueIDs(m),
|
||||
Genre: splitNFOList(m.Genres),
|
||||
Country: splitNFOList(m.Countries),
|
||||
Language: splitNFOList(m.Languages),
|
||||
}
|
||||
}
|
||||
out, err := xml.MarshalIndent(doc, "", " ")
|
||||
@@ -202,3 +208,27 @@ func splitNFOList(value string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nfoExternalUniqueIDs 把 MeBox 使用的外部 ID 写进 sidecar,供自己重扫时读回
|
||||
// (metadataFromDoc 会按 type 还原成 bangumi/douban/thetvdb 字段)。
|
||||
// TMDb 走 <tmdbid>,单集 NFO 不写唯一 ID:单集 ID 属于单集,写进整剧身份会把
|
||||
// 同一部剧拆成多张卡(见 mergeEpisodeMetadata 的约束说明)。
|
||||
func nfoExternalUniqueIDs(m *model.Media) []nfoUniqueID {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]nfoUniqueID, 0, 3)
|
||||
if m.BangumiID > 0 {
|
||||
out = append(out, nfoUniqueID{Type: "bangumi", Value: strconv.Itoa(m.BangumiID)})
|
||||
}
|
||||
if id := strings.TrimSpace(m.DoubanID); id != "" {
|
||||
out = append(out, nfoUniqueID{Type: "douban", Value: id})
|
||||
}
|
||||
if id := strings.TrimSpace(m.TheTVDBID); id != "" {
|
||||
out = append(out, nfoUniqueID{Type: "thetvdb", Value: id})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -100,3 +100,51 @@ func TestWriteMediaNFOAdultTitleWithCode(t *testing.T) {
|
||||
t.Fatalf("expected originaltitle with code, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMediaNFORoundTripsExternalUniqueIDs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mediaPath := filepath.Join(root, "云下载", "sivr-270", "sivr-270-1.strm")
|
||||
if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(mediaPath, []byte("strm"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst, err := WriteMediaNFO(&model.Media{
|
||||
Title: "SIVR-270-河北彩花",
|
||||
OriginalName: "SIVR-270",
|
||||
Path: mediaPath,
|
||||
Year: 2023,
|
||||
NSFW: true,
|
||||
DoubanID: "SIVR-270",
|
||||
TheTVDBID: "JavBus",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(body)
|
||||
// 成人条目把番号/provider 借用存在 douban/thetvdb 两列;sidecar 必须把它们
|
||||
// 带回来,否则「在线刮削 → 写 NFO → 重扫读 NFO」之后同一部片会被按不同
|
||||
// 分组键拆成多张卡。
|
||||
for _, want := range []string{
|
||||
`<uniqueid type="douban">SIVR-270</uniqueid>`,
|
||||
`<uniqueid type="thetvdb">JavBus</uniqueid>`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("sidecar missing %s, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
|
||||
meta, err := ReadLocalMetadata(mediaPath, "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta == nil || meta.DoubanID != "SIVR-270" || meta.TheTVDBID != "JavBus" {
|
||||
t.Fatalf("external ids did not round trip: %#v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -42,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
|
||||
@@ -56,6 +62,24 @@ 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 = 3000
|
||||
/** VR 视角拖拽超过这个距离才算「真的在转动视角」,轻触不受影响。 */
|
||||
const VR_VIEW_DRAG_DISTANCE = 6
|
||||
|
||||
/**
|
||||
* 当前设备是否没有真正的鼠标悬停能力(手机/平板)。
|
||||
*
|
||||
* 触摸设备上「悬停保持显示」没有可靠的结束信号:手指抬起后浏览器不保证补发
|
||||
* mouseleave,标记一旦置位就再也清不掉,控制栏会永久留在画面上。这里按设备
|
||||
* 能力判断,避免依赖 MouseEvent.sourceCapabilities(实测常为 null,等于没修)。
|
||||
*/
|
||||
function isHoverlessDevice(): boolean {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return true
|
||||
return window.matchMedia('(hover: none)').matches
|
||||
}
|
||||
|
||||
type SeekHint = {
|
||||
dir: 'back' | 'forward'
|
||||
@@ -90,6 +114,13 @@ type PlayerControlsProps = {
|
||||
onPlaybackRateCommit?: (rate: number) => void
|
||||
uiVisible: boolean
|
||||
onUiVisibleChange: (visible: boolean) => void
|
||||
/** 外部浮层(VR 工具条/设置面板)正被鼠标悬停:此时控制栏不自动隐藏。 */
|
||||
uiHold?: boolean
|
||||
/**
|
||||
* 画面已被锁住(播放器左侧的锁)。锁住时操作栏整条隐藏,自动隐藏计时器
|
||||
* 也不再启动——否则计时器会在用户看不见的地方继续改写可见状态。
|
||||
*/
|
||||
uiLocked?: boolean
|
||||
subs: SubtitleTrack[]
|
||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||
subtitleIndex: number
|
||||
@@ -116,6 +147,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). */
|
||||
@@ -134,6 +170,8 @@ export function PlayerControls({
|
||||
onPlaybackRateCommit,
|
||||
uiVisible,
|
||||
onUiVisibleChange,
|
||||
uiHold = false,
|
||||
uiLocked = false,
|
||||
subs,
|
||||
subtitleIndex,
|
||||
onSelectSubtitle,
|
||||
@@ -159,6 +197,9 @@ export function PlayerControls({
|
||||
selectedQuality = '',
|
||||
onSelectQuality,
|
||||
showQuality = false,
|
||||
vr360 = null,
|
||||
vr360Detected = false,
|
||||
onToggleVr360,
|
||||
knownDuration = 0,
|
||||
streamOffset = 0,
|
||||
onSeekAbsolute,
|
||||
@@ -186,13 +227,15 @@ export function PlayerControls({
|
||||
const [speedMenuOpen, setSpeedMenuOpen] = useState(false)
|
||||
const speedMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | 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 uiLockedRef = useRef(uiLocked)
|
||||
/** VR 画布上的按下状态:按住拖动 = 转动视角,不唤出控制栏。 */
|
||||
const vrDragRef = useRef<{ x: number; y: number; moved: boolean } | null>(null)
|
||||
const pendingSeekRef = useRef<number | null>(null)
|
||||
const applySeekRef = useRef<(absolute: number) => void>(() => undefined)
|
||||
const durationRef = useRef(0)
|
||||
@@ -207,33 +250,44 @@ export function PlayerControls({
|
||||
const [playbackRateHint, setPlaybackRateHint] = useState<number | null>(null)
|
||||
const [stageEl, setStageEl] = useState<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
controlsHoveredRef.current = controlsHovered
|
||||
}, [controlsHovered])
|
||||
|
||||
useEffect(() => {
|
||||
isScrubbingRef.current = isScrubbing
|
||||
}, [isScrubbing])
|
||||
|
||||
useEffect(() => {
|
||||
subtitleMenuOpenRef.current = subtitleMenuOpen
|
||||
}, [subtitleMenuOpen])
|
||||
vr360ActiveRef.current = Boolean(vr360)
|
||||
}, [vr360])
|
||||
|
||||
useEffect(() => {
|
||||
qualityMenuOpenRef.current = qualityMenuOpen
|
||||
}, [qualityMenuOpen])
|
||||
uiLockedRef.current = uiLocked
|
||||
}, [uiLocked])
|
||||
|
||||
// 「保持显示」条件集中在这里:悬停控制栏、拖动进度条、打开任何菜单或面板。
|
||||
// 事件回调里读 ref,渲染里读派生值,两边始终一致。
|
||||
const keepControlsVisible =
|
||||
uiHold ||
|
||||
controlsHovered ||
|
||||
isScrubbing ||
|
||||
subtitleMenuOpen ||
|
||||
qualityMenuOpen ||
|
||||
speedMenuOpen ||
|
||||
danmakuOpen ||
|
||||
playlistOpen
|
||||
useEffect(() => {
|
||||
speedMenuOpenRef.current = speedMenuOpen
|
||||
}, [speedMenuOpen])
|
||||
holdControlsRef.current = keepControlsVisible
|
||||
}, [keepControlsVisible])
|
||||
|
||||
// 控制栏「悬停保持显示」在触摸设备上没有可靠的结束信号:手指抬起后浏览器不保证
|
||||
// 补发 mouseleave,标记会一直挂着,控制栏就永远不隐藏。触摸设备直接不进入悬停
|
||||
// 保持状态,改用 pointerup 兜底清理,避免个别浏览器仍派发 mouseenter。
|
||||
useEffect(() => {
|
||||
danmakuOpenRef.current = danmakuOpen
|
||||
}, [danmakuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
playlistOpenRef.current = playlistOpen
|
||||
}, [playlistOpen])
|
||||
const onPointerUp = (event: PointerEvent) => {
|
||||
if (event.pointerType !== 'touch') return
|
||||
setControlsHovered(false)
|
||||
}
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
return () => window.removeEventListener('pointerup', onPointerUp)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
durationRef.current = duration
|
||||
@@ -293,45 +347,68 @@ export function PlayerControls({
|
||||
const stage = container()
|
||||
if (!el || !stage) return
|
||||
|
||||
const holdControls = () => holdControlsRef.current
|
||||
|
||||
/** 画面锁住时,控制栏必须保持隐藏,任何「用户活动」都不应该把它唤回来。 */
|
||||
const isLocked = () => uiLockedRef.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() || isLocked()) return
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
if (holdControls()) return
|
||||
onUiVisibleChange(false)
|
||||
}, hideDelay())
|
||||
}
|
||||
|
||||
// 通用「用户活动」入口:显示控制栏并重新计时。播放/暂停等事件也走这里。
|
||||
const onMove = () => {
|
||||
if (isLocked()) return
|
||||
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)
|
||||
}
|
||||
@@ -371,8 +448,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)
|
||||
@@ -389,8 +472,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)
|
||||
@@ -412,19 +499,25 @@ 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 (uiLocked) return
|
||||
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, uiLocked, onUiVisibleChange, video])
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = video()
|
||||
@@ -503,33 +596,18 @@ export function PlayerControls({
|
||||
}
|
||||
|
||||
const revealControls = useCallback(() => {
|
||||
// 锁住时键盘操作(方向键调进度/音量)不应该把控制栏唤回来。
|
||||
if (uiLockedRef.current) return
|
||||
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) => {
|
||||
@@ -746,7 +824,10 @@ export function PlayerControls({
|
||||
className={`pointer-events-auto absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-3 pt-14 transition-opacity duration-300 ${
|
||||
uiVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
onMouseEnter={() => setControlsHovered(true)}
|
||||
onMouseEnter={() => {
|
||||
if (isHoverlessDevice()) return
|
||||
setControlsHovered(true)
|
||||
}}
|
||||
onMouseLeave={() => setControlsHovered(false)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
@@ -1090,6 +1171,24 @@ export function PlayerControls({
|
||||
{danmakuEnabled && <span className="h-1.5 w-1.5 rounded-full bg-lime-400" />}
|
||||
</button>
|
||||
|
||||
{/* VR 全景播放开关 */}
|
||||
{onToggleVr360 && (
|
||||
<button
|
||||
onClick={onToggleVr360}
|
||||
className={
|
||||
'flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-xs font-medium transition ' +
|
||||
(vr360
|
||||
? 'bg-rose-500/90 text-white hover:bg-rose-500'
|
||||
: 'bg-white/10 text-white/80 hover:bg-white/20')
|
||||
}
|
||||
title={vr360 ? '退出 VR 全景播放' : '切换到 VR 全景播放(可用鼠标或陀螺仪转动视角)'}
|
||||
>
|
||||
<Rotate3d size={15} />
|
||||
<span className="hidden sm:inline">VR</span>
|
||||
{vr360Detected && !vr360 && <span className="h-1.5 w-1.5 rounded-full bg-amber-300" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react'
|
||||
import { Check, Compass, Minus, Plus, RotateCcw, Settings2, Smartphone } from 'lucide-react'
|
||||
|
||||
import {
|
||||
VR360_DEFAULT_FOV,
|
||||
VR360_MAX_FOV,
|
||||
VR360_MIN_FOV,
|
||||
VR360_PROJECTION_OPTIONS,
|
||||
VR360_STEREO_OPTIONS,
|
||||
vr360FrameUv,
|
||||
type Vr360Profile,
|
||||
} from '../utils/vr360'
|
||||
import {
|
||||
applyViewOffset,
|
||||
cameraForward,
|
||||
cameraFromDeviceOrientation,
|
||||
cameraFromYawPitch,
|
||||
clampNumber,
|
||||
degToRad,
|
||||
forwardPitch,
|
||||
forwardYaw,
|
||||
viewMatrixFromCamera,
|
||||
type Mat3,
|
||||
} from '../utils/vr360Math'
|
||||
// 只引入类型:渲染器本体走动态 import,普通播放不会下载 WebGL 全景渲染代码。
|
||||
import type { Vr360Renderer } from '../utils/vr360Renderer'
|
||||
|
||||
// 全景播放舞台:一个覆盖整个播放区域的 WebGL 画布。视频元素本身仍在后台正常
|
||||
// 播放(音轨、进度、弹幕、字幕都不受影响),只是画面被贴到了球面上。
|
||||
//
|
||||
// 视角控制:
|
||||
// 1. 鼠标/手指拖拽 → 转动视角(画面跟随手指),滚轮/双指捏合 → 变焦;
|
||||
// 2. 手机陀螺仪 → 转动设备即转动视角。iOS 13+ 必须在用户手势里调用
|
||||
// DeviceOrientationEvent.requestPermission,所以这里做成显式按钮。
|
||||
|
||||
type ViewState = {
|
||||
/** 拖拽模式下的方位角(弧度,0 = 默认正前方)。 */
|
||||
yaw: number
|
||||
/** 拖拽模式下的俯仰角(弧度,+ 为抬头)。 */
|
||||
pitch: number
|
||||
fov: number
|
||||
/** 陀螺仪是否接管视角。 */
|
||||
gyroActive: boolean
|
||||
/** 最近一次设备姿态对应的相机旋转矩阵。 */
|
||||
deviceCamera: Mat3 | null
|
||||
gyroYawOffset: number
|
||||
gyroPitchOffset: number
|
||||
}
|
||||
|
||||
type PointerState = {
|
||||
x: number
|
||||
y: number
|
||||
startX: number
|
||||
startY: number
|
||||
startAt: number
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
type GyroState = 'off' | 'pending' | 'active' | 'denied' | 'unsupported' | 'unavailable'
|
||||
|
||||
type Vr360StageProps = {
|
||||
videoRef: RefObject<HTMLVideoElement>
|
||||
profile: Vr360Profile
|
||||
/** 控制栏是否可见:不可见时隐藏 VR 自身的浮层按钮。 */
|
||||
uiVisible: boolean
|
||||
/** 轻触画面(未发生拖拽)时回调,与普通播放一致:唤出控制栏或切换播放。 */
|
||||
onSurfaceTap: () => void
|
||||
/**
|
||||
* 第一帧真正画出来时回调,每次挂载只上报一次(改投影方式/画幅布局不会重新上报),
|
||||
* 调用方据此结束「正在启动」提示。
|
||||
*/
|
||||
onReady: () => void
|
||||
onError: (message: string) => void
|
||||
/** 用户调整投影方式/立体布局时回调,由播放页保存为下次进入的默认值。 */
|
||||
onProfileChange?: (profile: Vr360Profile) => void
|
||||
/**
|
||||
* 鼠标是否停留在 VR 工具条/设置面板上。由播放页转给控制栏:悬停这些浮层时
|
||||
* 控制栏不应该自动隐藏(否则设置面板会在点击前消失)。
|
||||
*/
|
||||
onUiHoldChange?: (hold: boolean) => void
|
||||
}
|
||||
|
||||
/** 每像素拖拽对应的转角(弧度)。视场角越小时画面越「拉近」,拖拽也应更细腻。 */
|
||||
const DRAG_RAD_PER_PIXEL = 0.0035
|
||||
const TAP_MAX_DISTANCE = 8
|
||||
const TAP_MAX_DURATION_MS = 400
|
||||
const GYRO_PROBE_MS = 1500
|
||||
const PITCH_LIMIT = Math.PI / 2 - 0.03
|
||||
const WHEEL_ZOOM_SPEED = 0.0015
|
||||
|
||||
function screenOrientationAngle(): number {
|
||||
if (
|
||||
typeof screen !== 'undefined' &&
|
||||
screen.orientation &&
|
||||
typeof screen.orientation.angle === 'number'
|
||||
) {
|
||||
return screen.orientation.angle
|
||||
}
|
||||
const legacy = (window as unknown as { orientation?: number }).orientation
|
||||
return typeof legacy === 'number' ? legacy : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前设备是否没有真正的鼠标悬停能力(手机/平板)。
|
||||
*
|
||||
* 触摸设备上「悬停保持显示」这套机制本身就是错的:手指抬起后浏览器不保证补发
|
||||
* mouseleave,一旦走进悬停状态标记就再也清不掉,控制栏和 VR 工具条会永久留在
|
||||
* 画面上。这里按设备能力判断,而不是去猜某个鼠标事件是不是触摸合成的——
|
||||
* 后者依赖 MouseEvent.sourceCapabilities,实测在不少浏览器里是 null,判断会
|
||||
* 静默失效、等于没修。
|
||||
*/
|
||||
function isHoverlessDevice(): boolean {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return true
|
||||
return window.matchMedia('(hover: none)').matches
|
||||
}
|
||||
|
||||
type DeviceOrientationEventConstructor = {
|
||||
requestPermission?: () => Promise<'granted' | 'denied' | 'default'>
|
||||
}
|
||||
|
||||
function gyroStateHint(state: GyroState): string {
|
||||
if (state === 'active') return '陀螺仪已开启:转动设备即可改变视角'
|
||||
if (state === 'pending') return '正在等待陀螺仪数据…'
|
||||
if (state === 'denied') return '陀螺仪权限被拒绝,请在浏览器设置里允许「运动与方向」访问'
|
||||
if (state === 'unavailable') return '没有收到陀螺仪数据,当前设备可能不支持'
|
||||
if (state === 'unsupported') return '当前浏览器不支持陀螺仪'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function Vr360Stage({
|
||||
videoRef,
|
||||
profile,
|
||||
uiVisible,
|
||||
onSurfaceTap,
|
||||
onReady,
|
||||
onError,
|
||||
onProfileChange,
|
||||
onUiHoldChange,
|
||||
}: Vr360StageProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const rendererRef = useRef<Vr360Renderer | null>(null)
|
||||
const viewRef = useRef<ViewState>({
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
fov: VR360_DEFAULT_FOV,
|
||||
gyroActive: false,
|
||||
deviceCamera: null,
|
||||
gyroYawOffset: 0,
|
||||
gyroPitchOffset: 0,
|
||||
})
|
||||
const dirtyRef = useRef(true)
|
||||
const readyRef = useRef(false)
|
||||
const pointersRef = useRef(new Map<number, PointerState>())
|
||||
const pinchDistanceRef = useRef<number | null>(null)
|
||||
const alignmentRef = useRef<{ yaw: number; pitch: number } | null>(null)
|
||||
const gyroProbeRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const absoluteSeenRef = useRef(false)
|
||||
const profileRef = useRef(profile)
|
||||
const [gyroState, setGyroState] = useState<GyroState>('off')
|
||||
const [fovHint, setFovHint] = useState(VR360_DEFAULT_FOV)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
// 回调放进 ref:渲染器只在挂载时创建一次,不因父组件重渲染而重建。
|
||||
const callbacksRef = useRef({ onSurfaceTap, onReady, onError })
|
||||
useEffect(() => {
|
||||
callbacksRef.current = { onSurfaceTap, onReady, onError }
|
||||
}, [onSurfaceTap, onReady, onError])
|
||||
|
||||
const markDirty = useCallback(() => {
|
||||
dirtyRef.current = true
|
||||
}, [])
|
||||
|
||||
// 当前相机姿态:陀螺仪接管时以设备姿态为基准叠加拖拽偏移,否则纯拖拽角度。
|
||||
const currentCamera = useCallback((): Mat3 => {
|
||||
const view = viewRef.current
|
||||
if (view.gyroActive && view.deviceCamera) {
|
||||
return applyViewOffset(view.deviceCamera, view.gyroYawOffset, view.gyroPitchOffset)
|
||||
}
|
||||
return cameraFromYawPitch(view.yaw, view.pitch)
|
||||
}, [])
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const renderer = rendererRef.current
|
||||
if (!renderer) return
|
||||
renderer.setView(viewMatrixFromCamera(currentCamera()))
|
||||
renderer.setFov(viewRef.current.fov)
|
||||
renderer.draw()
|
||||
if (!readyRef.current) {
|
||||
// 第一帧真正画出来之后才通知上层:渲染失败时上层会退出 VR 模式。
|
||||
readyRef.current = true
|
||||
callbacksRef.current.onReady()
|
||||
}
|
||||
}, [currentCamera])
|
||||
|
||||
const applyProfile = useCallback((renderer: Vr360Renderer, next: Vr360Profile) => {
|
||||
renderer.setProjection(next.projection)
|
||||
const { scale, offset } = vr360FrameUv(next.stereo)
|
||||
renderer.setFrameUv(scale, offset)
|
||||
dirtyRef.current = true
|
||||
}, [])
|
||||
|
||||
// 创建渲染器(懒加载 WebGL 模块,普通播放不会下载全景渲染代码)。
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const video = videoRef.current
|
||||
if (!canvas || !video) return
|
||||
let cancelled = false
|
||||
let created: Vr360Renderer | null = null
|
||||
|
||||
void import('../utils/vr360Renderer')
|
||||
.then((module) => {
|
||||
if (cancelled) return
|
||||
created = module.createVr360Renderer(canvas, video, {
|
||||
onContextLost: () => callbacksRef.current.onError('VR 渲染上下文已丢失,已退出 VR 播放'),
|
||||
onTextureError: () => callbacksRef.current.onError('无法读取视频画面用于 VR 渲染'),
|
||||
})
|
||||
if (!created) {
|
||||
callbacksRef.current.onError('当前浏览器不支持 WebGL,无法使用 VR 播放')
|
||||
return
|
||||
}
|
||||
rendererRef.current = created
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
created.resize(rect.width, rect.height)
|
||||
applyProfile(created, profileRef.current)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) callbacksRef.current.onError('VR 渲染组件加载失败')
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
rendererRef.current = null
|
||||
created?.dispose()
|
||||
}
|
||||
}, [applyProfile, videoRef])
|
||||
|
||||
// 投影方式/立体布局变化时重建网格与 UV 裁切。
|
||||
useEffect(() => {
|
||||
profileRef.current = profile
|
||||
const renderer = rendererRef.current
|
||||
if (renderer) applyProfile(renderer, profile)
|
||||
}, [applyProfile, profile])
|
||||
|
||||
// 画布尺寸跟随舞台(窗口缩放、全屏切换、首次布局)。
|
||||
// 观测父容器而不是画布自身:画布尺寸由 CSS 撑满父容器,观测自身会在
|
||||
// 「设置绘制缓冲区分辨率 → 布局变化 → 再次触发观测」之间来回放大。
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const target = canvas.parentElement ?? canvas
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
rendererRef.current?.resize(entry.contentRect.width, entry.contentRect.height)
|
||||
markDirty()
|
||||
})
|
||||
observer.observe(target)
|
||||
const rect = target.getBoundingClientRect()
|
||||
rendererRef.current?.resize(rect.width, rect.height)
|
||||
return () => observer.disconnect()
|
||||
}, [markDirty])
|
||||
|
||||
// 渲染循环:requestVideoFrameCallback 标记「有新画面」,rAF 按需绘制。
|
||||
// 暂停且无输入时不出图,避免持续占用 GPU。
|
||||
useEffect(() => {
|
||||
let handle = 0
|
||||
let cancelled = false
|
||||
const video = videoRef.current as
|
||||
| (HTMLVideoElement & { requestVideoFrameCallback?: (callback: () => void) => number })
|
||||
| null
|
||||
|
||||
if (video && typeof video.requestVideoFrameCallback === 'function') {
|
||||
const onVideoFrame = () => {
|
||||
if (cancelled) return
|
||||
dirtyRef.current = true
|
||||
video.requestVideoFrameCallback?.(onVideoFrame)
|
||||
}
|
||||
video.requestVideoFrameCallback(onVideoFrame)
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (cancelled) return
|
||||
handle = requestAnimationFrame(tick)
|
||||
if (!dirtyRef.current) return
|
||||
dirtyRef.current = false
|
||||
draw()
|
||||
}
|
||||
handle = requestAnimationFrame(tick)
|
||||
return () => {
|
||||
cancelled = true
|
||||
cancelAnimationFrame(handle)
|
||||
}
|
||||
}, [draw, videoRef])
|
||||
|
||||
// 设备姿态 → 相机姿态。第一帧有效数据到达时把视角对齐过去,避免画面跳动。
|
||||
const handleOrientation = useCallback((event: DeviceOrientationEvent) => {
|
||||
if (event.alpha === null && event.beta === null && event.gamma === null) return
|
||||
if (event.type === 'deviceorientationabsolute') absoluteSeenRef.current = true
|
||||
// 同时收到绝对与相对事件时以绝对朝向为准(方位角才有意义)。
|
||||
else if (absoluteSeenRef.current) return
|
||||
|
||||
const camera = cameraFromDeviceOrientation(
|
||||
degToRad(event.alpha ?? 0),
|
||||
degToRad(event.beta ?? 0),
|
||||
degToRad(event.gamma ?? 0),
|
||||
degToRad(screenOrientationAngle()),
|
||||
)
|
||||
const view = viewRef.current
|
||||
view.deviceCamera = camera
|
||||
|
||||
const alignment = alignmentRef.current
|
||||
if (alignment) {
|
||||
const forward = cameraForward(camera)
|
||||
view.gyroYawOffset = alignment.yaw - forwardYaw(forward)
|
||||
view.gyroPitchOffset = clampNumber(alignment.pitch - forwardPitch(forward), -1.2, 1.2)
|
||||
alignmentRef.current = null
|
||||
if (gyroProbeRef.current) {
|
||||
clearTimeout(gyroProbeRef.current)
|
||||
gyroProbeRef.current = null
|
||||
}
|
||||
setGyroState('active')
|
||||
}
|
||||
|
||||
view.gyroActive = true
|
||||
dirtyRef.current = true
|
||||
}, [])
|
||||
|
||||
const stopGyro = useCallback(() => {
|
||||
window.removeEventListener('deviceorientation', handleOrientation)
|
||||
window.removeEventListener('deviceorientationabsolute', handleOrientation)
|
||||
if (gyroProbeRef.current) {
|
||||
clearTimeout(gyroProbeRef.current)
|
||||
gyroProbeRef.current = null
|
||||
}
|
||||
alignmentRef.current = null
|
||||
absoluteSeenRef.current = false
|
||||
}, [handleOrientation])
|
||||
|
||||
const startGyro = useCallback(() => {
|
||||
alignmentRef.current = { yaw: viewRef.current.yaw, pitch: viewRef.current.pitch }
|
||||
absoluteSeenRef.current = false
|
||||
window.addEventListener('deviceorientation', handleOrientation)
|
||||
window.addEventListener('deviceorientationabsolute', handleOrientation)
|
||||
// 桌面浏览器/无陀螺仪设备会一直收不到事件,超时后提示并回退到拖拽模式。
|
||||
gyroProbeRef.current = setTimeout(() => {
|
||||
gyroProbeRef.current = null
|
||||
if (alignmentRef.current) {
|
||||
stopGyro()
|
||||
setGyroState('unavailable')
|
||||
}
|
||||
}, GYRO_PROBE_MS)
|
||||
}, [handleOrientation, stopGyro])
|
||||
|
||||
const toggleGyro = useCallback(() => {
|
||||
if (gyroState === 'active' || gyroState === 'pending') {
|
||||
// 关闭陀螺仪时用当前合成视角续上,画面不会跳。
|
||||
const forward = cameraForward(currentCamera())
|
||||
viewRef.current.yaw = forwardYaw(forward)
|
||||
viewRef.current.pitch = clampNumber(forwardPitch(forward), -PITCH_LIMIT, PITCH_LIMIT)
|
||||
viewRef.current.gyroActive = false
|
||||
viewRef.current.gyroYawOffset = 0
|
||||
viewRef.current.gyroPitchOffset = 0
|
||||
stopGyro()
|
||||
setGyroState('off')
|
||||
markDirty()
|
||||
return
|
||||
}
|
||||
|
||||
const orientationEvent = (
|
||||
window as unknown as { DeviceOrientationEvent?: DeviceOrientationEventConstructor }
|
||||
).DeviceOrientationEvent
|
||||
if (!orientationEvent) {
|
||||
setGyroState('unsupported')
|
||||
return
|
||||
}
|
||||
setGyroState('pending')
|
||||
const requestPermission = orientationEvent.requestPermission
|
||||
if (typeof requestPermission !== 'function') {
|
||||
startGyro()
|
||||
return
|
||||
}
|
||||
// iOS 13+ 必须在用户手势里申请权限,这里由按钮点击直接触发。
|
||||
void requestPermission
|
||||
.call(orientationEvent)
|
||||
.then((result) => {
|
||||
if (result === 'granted') startGyro()
|
||||
else setGyroState('denied')
|
||||
})
|
||||
.catch(() => setGyroState('denied'))
|
||||
}, [currentCamera, gyroState, markDirty, startGyro, stopGyro])
|
||||
|
||||
useEffect(() => () => stopGyro(), [stopGyro])
|
||||
|
||||
// 控制栏/浮层隐藏时收起「画面」设置面板:它属于同一套浮层,下次唤出控制栏时
|
||||
// 不应该自动弹回来挡住画面。
|
||||
useEffect(() => {
|
||||
if (uiVisible) return
|
||||
setSettingsOpen(false)
|
||||
// 浮层不可见时鼠标离开事件不会再触发,这里主动解除「悬停保持显示」。
|
||||
onUiHoldChange?.(false)
|
||||
}, [uiVisible, onUiHoldChange])
|
||||
|
||||
const zoomBy = useCallback(
|
||||
(factor: number) => {
|
||||
const view = viewRef.current
|
||||
view.fov = clampNumber(view.fov * factor, VR360_MIN_FOV, VR360_MAX_FOV)
|
||||
setFovHint(view.fov)
|
||||
markDirty()
|
||||
},
|
||||
[markDirty],
|
||||
)
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
const view = viewRef.current
|
||||
view.yaw = 0
|
||||
view.pitch = 0
|
||||
view.fov = VR360_DEFAULT_FOV
|
||||
view.gyroYawOffset = 0
|
||||
view.gyroPitchOffset = 0
|
||||
setFovHint(view.fov)
|
||||
markDirty()
|
||||
}, [markDirty])
|
||||
|
||||
const pinchDistance = useCallback((): number => {
|
||||
const pointers = [...pointersRef.current.values()]
|
||||
if (pointers.length < 2) return 0
|
||||
return Math.hypot(pointers[0].x - pointers[1].x, pointers[0].y - pointers[1].y)
|
||||
}, [])
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
// 轻触画面把视角拖动之外的浮层悬停状态清掉:触摸设备不会再发出 mouseleave,
|
||||
// 上一步点击工具条/设置面板留下的「悬停保持显示」会一直挂着,控制栏就永远
|
||||
// 不隐藏了。用户已经在点画面,说明指针早已离开浮层。
|
||||
if (event.pointerType === 'touch') onUiHoldChange?.(false)
|
||||
try {
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
} catch {
|
||||
// 某些环境(合成事件、非活跃指针)会抛 InvalidPointerId,退化为普通拖拽即可。
|
||||
}
|
||||
pointersRef.current.set(event.pointerId, {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startAt: Date.now(),
|
||||
moved: false,
|
||||
})
|
||||
if (pointersRef.current.size === 2) {
|
||||
pinchDistanceRef.current = pinchDistance()
|
||||
// 双指手势一律不算轻触,避免捏合缩放时误触发播放/暂停。
|
||||
for (const pointer of pointersRef.current.values()) pointer.moved = true
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
const pointer = pointersRef.current.get(event.pointerId)
|
||||
if (!pointer) return
|
||||
const dx = event.clientX - pointer.x
|
||||
const dy = event.clientY - pointer.y
|
||||
pointer.x = event.clientX
|
||||
pointer.y = event.clientY
|
||||
if (
|
||||
Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY) > TAP_MAX_DISTANCE
|
||||
) {
|
||||
pointer.moved = true
|
||||
}
|
||||
|
||||
if (pointersRef.current.size >= 2) {
|
||||
const distance = pinchDistance()
|
||||
const previous = pinchDistanceRef.current
|
||||
pinchDistanceRef.current = distance
|
||||
if (previous && distance > 0) zoomBy(previous / distance)
|
||||
return
|
||||
}
|
||||
|
||||
const view = viewRef.current
|
||||
const scale = DRAG_RAD_PER_PIXEL * (view.fov / VR360_DEFAULT_FOV)
|
||||
if (view.gyroActive && view.deviceCamera) {
|
||||
view.gyroYawOffset -= dx * scale
|
||||
view.gyroPitchOffset = clampNumber(
|
||||
view.gyroPitchOffset + dy * scale,
|
||||
-PITCH_LIMIT,
|
||||
PITCH_LIMIT,
|
||||
)
|
||||
} else {
|
||||
view.yaw -= dx * scale
|
||||
view.pitch = clampNumber(view.pitch + dy * scale, -PITCH_LIMIT, PITCH_LIMIT)
|
||||
}
|
||||
markDirty()
|
||||
}
|
||||
|
||||
const endPointer = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
const pointer = pointersRef.current.get(event.pointerId)
|
||||
pointersRef.current.delete(event.pointerId)
|
||||
if (pointersRef.current.size < 2) pinchDistanceRef.current = null
|
||||
if (!pointer) return
|
||||
const isTap =
|
||||
!pointer.moved &&
|
||||
Date.now() - pointer.startAt <= TAP_MAX_DURATION_MS &&
|
||||
pointersRef.current.size === 0
|
||||
if (isTap) onSurfaceTap()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault()
|
||||
zoomBy(1 + event.deltaY * WHEEL_ZOOM_SPEED)
|
||||
}
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => canvas.removeEventListener('wheel', onWheel)
|
||||
}, [zoomBy])
|
||||
|
||||
const hint = gyroStateHint(gyroState)
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
data-vr360-surface
|
||||
className={`absolute inset-0 h-full w-full touch-none select-none ${
|
||||
// 浮层隐藏时连鼠标指针一起藏起来:全景画面里光标一直悬在画面中间很出戏。
|
||||
uiVisible ? 'cursor-grab active:cursor-grabbing' : 'cursor-none'
|
||||
}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={endPointer}
|
||||
onPointerCancel={endPointer}
|
||||
// 轻触由指针逻辑自己处理(拖动时不能误触发播放/暂停),
|
||||
// 同时阻止冒泡到舞台的点击逻辑,避免一次触摸切换两次播放状态。
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
<div
|
||||
// 阻止浮层内按钮/面板的点击冒泡到舞台的 handleSurfaceActivate:否则点击
|
||||
// 投影方式/画幅布局等设置项会被误判为「单击收起控制栏」,面板在切换后立即消失。
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className={`pointer-events-none absolute inset-x-0 top-2 z-20 flex flex-col items-center gap-1.5 px-2 transition-opacity duration-300 ${
|
||||
uiVisible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-wrap items-center justify-center gap-1.5 rounded-2xl border border-white/15 bg-black/65 px-2 py-1.5 text-white shadow-2xl backdrop-blur ${
|
||||
// 浮层隐藏时必须彻底不接收指针事件:否则看不见的按钮既会在画面顶部
|
||||
// 抢走拖动转视角的手势,也可能被误点(例如误开陀螺仪、误开设置面板)。
|
||||
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
|
||||
}`}
|
||||
// 触摸设备不参与「悬停保持显示」:手指抬起后不会补发 mouseleave,
|
||||
// 标记会永久挂在打开状态,控制栏和 VR 工具条就再也不自动隐藏。
|
||||
onMouseEnter={() => {
|
||||
if (isHoverlessDevice()) return
|
||||
onUiHoldChange?.(true)
|
||||
}}
|
||||
onMouseLeave={() => onUiHoldChange?.(false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleGyro}
|
||||
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition ${
|
||||
gyroState === 'active'
|
||||
? 'bg-rose-500/90 text-white hover:bg-rose-500'
|
||||
: 'bg-white/10 text-white/85 hover:bg-white/20'
|
||||
}`}
|
||||
title="用手机陀螺仪转动视角"
|
||||
>
|
||||
<Smartphone size={14} />
|
||||
陀螺仪
|
||||
{gyroState === 'active' && <Compass size={13} className="text-white/85" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetView}
|
||||
className="flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-xs font-medium text-white/85 transition hover:bg-white/20"
|
||||
title="恢复默认视角与视场角"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen((open) => !open)}
|
||||
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition ${
|
||||
settingsOpen
|
||||
? 'bg-rose-500/90 text-white hover:bg-rose-500'
|
||||
: 'bg-white/10 text-white/85 hover:bg-white/20'
|
||||
}`}
|
||||
title="投影方式与画幅布局"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
画面
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomBy(1.15)}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title="视野拉远"
|
||||
>
|
||||
<Minus size={15} />
|
||||
</button>
|
||||
<span className="w-9 text-center text-[10px] tabular-nums text-white/75">
|
||||
{(VR360_DEFAULT_FOV / fovHint).toFixed(1)}×
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomBy(1 / 1.15)}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title="视野拉近"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="pointer-events-none rounded-full bg-black/45 px-3 py-1 text-[10px] text-white/70 backdrop-blur">
|
||||
{hint || '拖动旋转视角 · 滚轮或双指缩放'}
|
||||
</p>
|
||||
{settingsOpen ? (
|
||||
<div
|
||||
className={`w-[min(92vw,420px)] rounded-2xl border border-white/15 bg-black/75 px-3 py-2.5 text-white shadow-2xl backdrop-blur ${
|
||||
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
if (isHoverlessDevice()) return
|
||||
onUiHoldChange?.(true)
|
||||
}}
|
||||
onMouseLeave={() => onUiHoldChange?.(false)}
|
||||
>
|
||||
<p className="mb-1.5 text-[10px] uppercase tracking-wide text-white/45">投影方式</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{VR360_PROJECTION_OPTIONS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onProfileChange?.({ ...profile, projection: value })}
|
||||
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[11px] transition ${
|
||||
profile.projection === value
|
||||
? 'bg-rose-500/25 text-rose-200'
|
||||
: 'bg-white/5 text-white/75 hover:bg-white/15'
|
||||
}`}
|
||||
>
|
||||
{profile.projection === value && <Check size={11} />}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mb-1.5 mt-2.5 text-[10px] uppercase tracking-wide text-white/45">
|
||||
画幅布局
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{VR360_STEREO_OPTIONS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onProfileChange?.({ ...profile, stereo: value })}
|
||||
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[11px] transition ${
|
||||
profile.stereo === value
|
||||
? 'bg-rose-500/25 text-rose-200'
|
||||
: 'bg-white/5 text-white/75 hover:bg-white/15'
|
||||
}`}
|
||||
>
|
||||
{profile.stereo === value && <Check size={11} />}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{profile.stereo !== 'mono' && (
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-white/45">
|
||||
左右/上下并排素材在当前屏幕上只显示左眼画面;真正的双眼立体画面需要 VR
|
||||
头显(WebXR)。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+214
-25
@@ -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'
|
||||
@@ -42,6 +42,14 @@ import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
||||
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
|
||||
import { mediaVersionsOf } from '../utils/mediaVersion'
|
||||
import {
|
||||
detectVr360Profile,
|
||||
loadVr360Preference,
|
||||
sameVr360Profile,
|
||||
saveVr360Preference,
|
||||
type Vr360Detection,
|
||||
type Vr360Profile,
|
||||
} from '../utils/vr360'
|
||||
|
||||
// Fullscreen, dark-themed video page.
|
||||
//
|
||||
@@ -96,6 +104,22 @@ export function PlayerPage() {
|
||||
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const [media, setMedia] = useState<Media | null>(null)
|
||||
// VR 全景播放:null = 普通播放。识别结果与用户开关只存本机,
|
||||
// 不改动数据库,换设备/换浏览器不会互相影响。
|
||||
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)
|
||||
@@ -114,6 +138,9 @@ export function PlayerPage() {
|
||||
normalizePlaybackRate(authUser?.player_playback_rate),
|
||||
)
|
||||
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
||||
// VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来,
|
||||
// 这时不弹说明,避免给老用户闪一下。
|
||||
const [vr360GuideSeen, setVr360GuideSeen] = useState<boolean | null>(null)
|
||||
const playerVolumeTouchedRef = useRef(false)
|
||||
const playerPlaybackRateTouchedRef = useRef(false)
|
||||
const playerPlaybackRateSaveSeqRef = useRef(0)
|
||||
@@ -260,6 +287,7 @@ export function PlayerPage() {
|
||||
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
||||
setDanmakuArea(Number(cfg.area) || 1)
|
||||
setDanmakuMergeSources(Boolean(cfg.merge_sources))
|
||||
setVr360GuideSeen(Boolean(cfg.vr360_guide_seen))
|
||||
applyDanmakuConnectionConfig(cfg)
|
||||
if (!playerVolumeTouchedRef.current) {
|
||||
const current = useAuthStore.getState().user
|
||||
@@ -458,6 +486,11 @@ export function PlayerPage() {
|
||||
useEffect(() => {
|
||||
setMedia(null)
|
||||
setLoadError('')
|
||||
vr360TouchedRef.current = false
|
||||
vr360TextureRetryUsedRef.current = false
|
||||
vr360TextureRetryUntilRef.current = 0
|
||||
setVr360(null)
|
||||
setVr360Detected(false)
|
||||
setHlsStartSec(0)
|
||||
setPlaybackInfo(null)
|
||||
setHlsSource('local')
|
||||
@@ -537,6 +570,97 @@ 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])
|
||||
|
||||
// 远端 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) {
|
||||
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 })
|
||||
}, [])
|
||||
|
||||
// VR 操作说明是「按用户只弹一次」:用户关掉说明时就落库,之后进入 VR 不再显示。
|
||||
// 保存失败只影响下次是否再弹一次,不打断播放,所以本地状态不回滚。
|
||||
const dismissVr360Guide = useCallback(() => {
|
||||
setVr360GuideSeen(true)
|
||||
void danmakuAPI.updateSettings({ vr360_guide_seen: true }).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
// 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.
|
||||
@@ -608,6 +732,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
|
||||
@@ -792,13 +943,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)
|
||||
}
|
||||
@@ -827,6 +995,7 @@ export function PlayerPage() {
|
||||
setPlaybackMode,
|
||||
switchToLocalHLS,
|
||||
teardownHls,
|
||||
vr360DirectProxy,
|
||||
])
|
||||
|
||||
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
|
||||
@@ -1186,38 +1355,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
|
||||
@@ -1343,7 +1523,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,
|
||||
@@ -1499,6 +1681,13 @@ export function PlayerPage() {
|
||||
showQuality={showQuality}
|
||||
waiting={cloudWaiting}
|
||||
waitingMessage={cloudWaitMessage}
|
||||
vr360={vr360}
|
||||
vr360Detected={vr360Detected}
|
||||
onToggleVr360={toggleVr360}
|
||||
onVr360ProfileChange={changeVr360Profile}
|
||||
onVr360Error={handleVr360Error}
|
||||
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
|
||||
onDismissVr360Guide={dismissVr360Guide}
|
||||
playlistPanel={
|
||||
<PlayerPlaylistPanel
|
||||
open={playlistOpen}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Hand,
|
||||
Loader2,
|
||||
Lock,
|
||||
LockOpen,
|
||||
MousePointerClick,
|
||||
Rotate3d,
|
||||
Smartphone,
|
||||
ZoomIn,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||
@@ -12,11 +21,14 @@ import {
|
||||
import { AssSubtitleStage } from '../components/AssSubtitleStage'
|
||||
import { DanmakuStage } from '../components/DanmakuStage'
|
||||
import { PlayerControls } from '../components/PlayerControls'
|
||||
import { Vr360Stage } from '../components/Vr360Stage'
|
||||
import type { Vr360Profile } from '../utils/vr360'
|
||||
import {
|
||||
subtitleTextStyle,
|
||||
type SubtitlePosition,
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
import { isPointerInLockZone } from '../utils/playerLockZone'
|
||||
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
|
||||
|
||||
type SubtitleRenderGroup = {
|
||||
@@ -25,6 +37,16 @@ type SubtitleRenderGroup = {
|
||||
style: CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁按钮每次被唤出后停留的时间(毫秒)。
|
||||
*
|
||||
* 锁本身默认隐身,只在「鼠标悬停感应区」或「点击画面」之后露一会儿。解锁的瞬间
|
||||
* 操作栏会立刻消失,如果锁也一起消失,用户就不知道锁在哪、也没法再锁回去。
|
||||
*/
|
||||
const LOCK_REVEAL_MS = 2600
|
||||
/** 首次操作说明的最长停留时间:到这里仍未点「知道了」也自动收起并记录为已看过。 */
|
||||
const VR_GUIDE_AUTO_DISMISS_MS = 20_000
|
||||
|
||||
function uniqueSubtitleCues(cues: SubtitleCue[]): SubtitleCue[] {
|
||||
const seen = new Set<string>()
|
||||
const unique: SubtitleCue[] = []
|
||||
@@ -186,6 +208,16 @@ type PlayerVideoStageProps = {
|
||||
selectedQuality?: string
|
||||
onSelectQuality?: (quality: PlaybackQuality) => void
|
||||
showQuality?: boolean
|
||||
/** VR 全景播放配置;非 null 表示当前处于 VR 模式。 */
|
||||
vr360?: Vr360Profile | null
|
||||
/** 是否由文件名/画幅自动识别出 VR 素材(用于在菜单里提示)。 */
|
||||
vr360Detected?: boolean
|
||||
onToggleVr360?: () => void
|
||||
onVr360ProfileChange?: (profile: Vr360Profile) => void
|
||||
onVr360Error?: (message: string) => void
|
||||
/** 是否展示 VR 全景播放的首次操作说明(按用户只弹一次)。 */
|
||||
showVr360Guide?: boolean
|
||||
onDismissVr360Guide?: () => void
|
||||
waiting?: boolean
|
||||
waitingMessage?: string
|
||||
}
|
||||
@@ -241,6 +273,13 @@ export function PlayerVideoStage({
|
||||
selectedQuality = '',
|
||||
onSelectQuality,
|
||||
showQuality = false,
|
||||
vr360 = null,
|
||||
vr360Detected = false,
|
||||
onToggleVr360,
|
||||
onVr360ProfileChange,
|
||||
onVr360Error,
|
||||
showVr360Guide = false,
|
||||
onDismissVr360Guide,
|
||||
waiting = false,
|
||||
waitingMessage = '',
|
||||
}: PlayerVideoStageProps) {
|
||||
@@ -248,6 +287,27 @@ export function PlayerVideoStage({
|
||||
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
||||
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
||||
const [controlsVisible, setControlsVisible] = useState(true)
|
||||
// 鼠标是否停在 VR 工具条/设置面板上:停住时控制栏不自动隐藏,否则设置面板会在点击前消失。
|
||||
const [vrUiHovered, setVrUiHovered] = useState(false)
|
||||
// 画面是否被「锁住」:锁上之后操作栏、VR 工具条、进度框全部隐藏,只剩干净的
|
||||
// 画面,轻触画面也不会把它们唤回来,也不会误触发播放/暂停;想看操作栏必须先
|
||||
// 点左边的锁解锁。VR 和普通播放共用这一套状态。
|
||||
const [locked, setLocked] = useState(false)
|
||||
// 锁按钮本身默认隐身(见下方的 lockVisible),这三个状态决定它什么时候露面:
|
||||
// lockHovered —— 桌面鼠标停在感应区里(移开就收);
|
||||
// lockShown —— 点了画面之后的临时显示窗口;
|
||||
// lockHint —— 刚解锁,顺带显示「已解锁」提示。
|
||||
const [lockHovered, setLockHovered] = useState(false)
|
||||
const [lockShown, setLockShown] = useState(false)
|
||||
const [lockHint, setLockHint] = useState(false)
|
||||
const lockRevealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const lockVisible = lockHovered || lockShown
|
||||
const vrActive = Boolean(vr360)
|
||||
// VR 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
|
||||
// 只按「媒体 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[]>([])
|
||||
@@ -389,18 +449,117 @@ export function PlayerVideoStage({
|
||||
if (video.paused) void video.play()?.catch(() => undefined)
|
||||
else video.pause()
|
||||
}
|
||||
/**
|
||||
* 临时把锁按钮唤出来(移动端点击画面、以及刚解锁时)。
|
||||
*
|
||||
* 每次都重新计时:连续点击时不会因为第一次的计时器到点而提前隐身。
|
||||
*/
|
||||
const revealLock = useCallback((withUnlockHint: boolean) => {
|
||||
setLockShown(true)
|
||||
setLockHint(withUnlockHint)
|
||||
if (lockRevealTimerRef.current) clearTimeout(lockRevealTimerRef.current)
|
||||
lockRevealTimerRef.current = setTimeout(() => {
|
||||
lockRevealTimerRef.current = null
|
||||
setLockShown(false)
|
||||
setLockHint(false)
|
||||
}, LOCK_REVEAL_MS)
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (lockRevealTimerRef.current) clearTimeout(lockRevealTimerRef.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
revealControlsOnlyRef.current = event.pointerType === 'touch' && !controlsVisible
|
||||
// 触摸端没有悬停,靠点击画面把锁唤出来(顺带给刚上锁的用户一个解锁入口)。
|
||||
if (event.pointerType === 'touch') revealLock(false)
|
||||
// 锁住时轻触画面不做任何事;VR 模式下也不负责唤出控制栏(画面默认是干净的)。
|
||||
if (locked) {
|
||||
revealControlsOnlyRef.current = false
|
||||
return
|
||||
}
|
||||
// 未锁的移动端:控制栏隐藏时首次轻触只唤出控制栏,不切换播放。
|
||||
revealControlsOnlyRef.current =
|
||||
!vrActive && event.pointerType === 'touch' && !controlsVisible
|
||||
}
|
||||
const handleStageClick = () => {
|
||||
|
||||
// 桌面端:鼠标移到画面左侧中间就把锁露出来,移开就收回去。
|
||||
const handleStagePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (event.pointerType !== 'mouse') return
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
setLockHovered(
|
||||
isPointerInLockZone(event.clientX - rect.left, event.clientY - rect.top, rect.height),
|
||||
)
|
||||
}
|
||||
|
||||
// 舞台任意位置的「激活」(轻触/单击):
|
||||
// 普通播放:桌面端直接切换播放/暂停;移动端控制栏隐藏时首次轻触只唤出控制栏,
|
||||
// 控制栏已显示时再次轻触才切换播放/暂停。
|
||||
// VR 模式:单击只负责显示/收起操作栏,不切换播放。
|
||||
// 两种情况都在锁住时完全无反应——只能点锁解锁(锁由上面的 pointerdown 唤出)。
|
||||
const handleSurfaceActivate = () => {
|
||||
if (locked) return
|
||||
if (revealControlsOnlyRef.current) {
|
||||
revealControlsOnlyRef.current = false
|
||||
setControlsVisible(true)
|
||||
return
|
||||
}
|
||||
revealControlsOnlyRef.current = false
|
||||
if (vrActive) {
|
||||
setControlsVisible((visible) => !visible)
|
||||
return
|
||||
}
|
||||
togglePlay()
|
||||
}
|
||||
|
||||
const toggleLock = () => {
|
||||
const next = !locked
|
||||
setLocked(next)
|
||||
// 锁上时立刻收起操作栏;解锁时也保持收起,这样画面在任何一侧都不会突然
|
||||
// 弹出一整条浮层。解锁那一下顺带显示「已解锁」提示。
|
||||
setControlsVisible(false)
|
||||
revealLock(!next)
|
||||
}
|
||||
|
||||
/** 复位锁定状态:切模式/换片时调用,避免上一次的锁挡住新的内容。 */
|
||||
const resetLock = useCallback(() => {
|
||||
setLocked(false)
|
||||
setLockShown(false)
|
||||
setLockHint(false)
|
||||
setLockHovered(false)
|
||||
if (lockRevealTimerRef.current) {
|
||||
clearTimeout(lockRevealTimerRef.current)
|
||||
lockRevealTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 进入 VR 全景播放时先亮一次操作栏与 VR 工具条,让用户知道入口在哪;
|
||||
// 退出 VR 时清掉 VR 浮层的悬停状态,否则控制栏会以为还有浮层被悬停而不隐藏。
|
||||
// 两种情况都要解除锁定:切模式是个明确的操作意图,不该被上一次的锁挡住。
|
||||
useEffect(() => {
|
||||
resetLock()
|
||||
if (vrActive) {
|
||||
setControlsVisible(true)
|
||||
return
|
||||
}
|
||||
setVrUiHovered(false)
|
||||
}, [resetLock, vrActive])
|
||||
|
||||
// 换集/换片时同样复位:新内容应该从干净的、可操作的状态开始。
|
||||
useEffect(() => {
|
||||
resetLock()
|
||||
}, [media?.id, resetLock])
|
||||
|
||||
// 首次操作说明只出现一次:读完了点「知道了」会立即落库;放着不管也会在
|
||||
// VR_GUIDE_AUTO_DISMISS_MS 之后自动收起并同样记录为已看过,避免每次进 VR 都弹。
|
||||
const vrGuideVisible = vrActive && vrReady && showVr360Guide
|
||||
useEffect(() => {
|
||||
if (!vrGuideVisible) return
|
||||
const timer = setTimeout(() => onDismissVr360Guide?.(), VR_GUIDE_AUTO_DISMISS_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [vrGuideVisible, onDismissVr360Guide])
|
||||
const toggleFullscreen = () => {
|
||||
const stage = stageRef.current
|
||||
if (!stage) return
|
||||
@@ -550,18 +709,25 @@ export function PlayerVideoStage({
|
||||
: true
|
||||
|
||||
const assTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
const wrapperStyle = videoRatio
|
||||
// VR 模式下画面由 WebGL 画布输出,舞台窗口本身就是「镜头」,
|
||||
// 不再按视频宽高比留黑边。
|
||||
const wrapperStyle = vr360
|
||||
? {
|
||||
aspectRatio: `${videoRatio}`,
|
||||
width: isWiderThanStage ? '100%' : 'auto',
|
||||
height: isWiderThanStage ? 'auto' : '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
}
|
||||
: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}
|
||||
: videoRatio
|
||||
? {
|
||||
aspectRatio: `${videoRatio}`,
|
||||
width: isWiderThanStage ? '100%' : 'auto',
|
||||
height: isWiderThanStage ? 'auto' : '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
}
|
||||
: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -569,7 +735,9 @@ export function PlayerVideoStage({
|
||||
data-player-stage
|
||||
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
||||
onPointerDown={handleStagePointerDown}
|
||||
onClick={handleStageClick}
|
||||
onPointerMove={handleStagePointerMove}
|
||||
onMouseLeave={() => setLockHovered(false)}
|
||||
onClick={handleSurfaceActivate}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
>
|
||||
{media ? (
|
||||
@@ -602,6 +770,19 @@ export function PlayerVideoStage({
|
||||
)
|
||||
})}
|
||||
</video>
|
||||
{vr360 ? (
|
||||
<Vr360Stage
|
||||
key={`${media.id}:vr360`}
|
||||
videoRef={videoRef}
|
||||
profile={vr360}
|
||||
uiVisible={controlsVisible}
|
||||
onSurfaceTap={handleSurfaceActivate}
|
||||
onReady={() => setVrReadyMediaId(media.id)}
|
||||
onError={(message) => onVr360Error?.(message)}
|
||||
onProfileChange={onVr360ProfileChange}
|
||||
onUiHoldChange={setVrUiHovered}
|
||||
/>
|
||||
) : null}
|
||||
<DanmakuStage
|
||||
key={media.id}
|
||||
media={media}
|
||||
@@ -662,8 +843,10 @@ export function PlayerVideoStage({
|
||||
playbackRate={playerPlaybackRate}
|
||||
onPlaybackRateChange={onPlayerPlaybackRateChange}
|
||||
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
|
||||
uiVisible={controlsVisible}
|
||||
uiVisible={controlsVisible && !locked}
|
||||
onUiVisibleChange={setControlsVisible}
|
||||
uiHold={vrUiHovered}
|
||||
uiLocked={locked}
|
||||
subs={subs}
|
||||
subtitleIndex={subtitleIndex}
|
||||
onSelectSubtitle={onSelectSubtitle}
|
||||
@@ -692,6 +875,9 @@ export function PlayerVideoStage({
|
||||
selectedQuality={selectedQuality}
|
||||
onSelectQuality={onSelectQuality}
|
||||
showQuality={showQuality}
|
||||
vr360={vr360}
|
||||
vr360Detected={vr360Detected}
|
||||
onToggleVr360={onToggleVr360}
|
||||
/>
|
||||
{danmakuPanel}
|
||||
{playlistPanel}
|
||||
@@ -701,6 +887,93 @@ export function PlayerVideoStage({
|
||||
) : (
|
||||
<p className="text-sand-500">加载中…</p>
|
||||
)}
|
||||
{vr360 && media && !vrReady ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-black/80 px-5 py-3 text-sm text-white shadow-2xl backdrop-blur">
|
||||
<Loader2 className="animate-spin text-rose-400" size={18} />
|
||||
正在启动 VR 全景渲染…
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* VR 首次操作说明:按用户只弹一次。刻意不铺全屏遮罩,用户可以一边看说明
|
||||
一边拖动画面试操作。 */}
|
||||
{vrGuideVisible ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center px-4">
|
||||
<div className="pointer-events-auto w-[min(92vw,400px)] rounded-2xl border border-white/15 bg-black/85 px-5 py-4 text-white shadow-2xl backdrop-blur">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Rotate3d size={16} className="text-rose-400" />
|
||||
VR 全景播放
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2 text-xs leading-relaxed text-white/80">
|
||||
<li className="flex items-start gap-2">
|
||||
<Hand size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||
按住拖动画面:转动视角
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ZoomIn size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||
滚轮 / 双指捏合:缩放视野
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<MousePointerClick size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||
单击画面:显示或收起操作栏(进度条、音量、画质、倍速、VR 设置)
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Lock size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||
锁默认隐身:电脑把鼠标移到左侧中间、手机点一下画面就会浮现
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Smartphone size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||
手机上点顶部「陀螺仪」:转动设备就能转动视角
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismissVr360Guide}
|
||||
className="mt-4 w-full rounded-xl bg-rose-500 px-4 py-2 text-xs font-medium text-white transition hover:bg-rose-600"
|
||||
>
|
||||
知道了,不再提示
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 锁定按钮:贴在画面左侧正中,VR 和普通播放都有,但默认隐身。
|
||||
桌面端把鼠标移到左侧中间就会浮现,移开即收;移动端点一下画面唤出,并在
|
||||
LOCK_REVEAL_MS 后自动隐身。
|
||||
未锁时点一下就把操作栏、进度框和 VR 工具条全部藏起来,同时屏蔽画面轻触
|
||||
(不会误触发播放/暂停);锁住后按钮变淡,提醒「这里是锁」。
|
||||
注意:隐身时必须 pointer-events-none——否则这块隐形按钮会吃掉画面单击,
|
||||
手机上想点画面唤出锁反而会误触上锁。 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
toggleLock()
|
||||
}}
|
||||
// 舞台在 pointerdown 上判断「是不是移动端轻触唤出控制栏」,锁按钮上的
|
||||
// 触摸不应该参与那套判断,否则点锁会被当成一次画面轻触。
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
aria-hidden={!lockVisible}
|
||||
tabIndex={lockVisible ? 0 : -1}
|
||||
aria-label={locked ? '解锁画面,恢复操作栏' : '锁定画面,隐藏操作栏和进度框'}
|
||||
title={locked ? '点一下解锁,恢复操作栏与进度框' : '锁上后画面轻触无效,只留这个锁'}
|
||||
className={`absolute left-2 top-1/2 z-30 -translate-y-1/2 rounded-full border p-3 shadow-2xl backdrop-blur transition-opacity duration-200 sm:left-4 ${
|
||||
lockVisible ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'
|
||||
} ${
|
||||
locked
|
||||
? // 锁住时按钮退到很淡的状态:既提示「这里是锁」,又不打扰观看。
|
||||
'border-white/10 bg-black/35 text-white/45 hover:bg-black/65 hover:text-white'
|
||||
: 'border-white/15 bg-black/65 text-white hover:bg-black/85'
|
||||
}`}
|
||||
>
|
||||
{locked ? <Lock size={20} /> : <LockOpen size={20} />}
|
||||
</button>
|
||||
{/* 解锁提示:操作栏此时已经藏起来了,用户点锁之后需要知道发生了什么。 */}
|
||||
{lockHint ? (
|
||||
<div className="pointer-events-none absolute left-16 top-1/2 z-30 -translate-y-1/2 rounded-full border border-white/15 bg-black/75 px-3 py-1.5 text-xs text-white/90 shadow-xl backdrop-blur sm:left-20">
|
||||
已解锁,点画面可显示操作栏
|
||||
</div>
|
||||
) : null}
|
||||
{playerError ? (
|
||||
<div className="absolute bottom-20 left-1/2 w-[min(92vw,720px)] -translate-x-1/2 rounded-2xl border border-white/15 bg-black/75 px-5 py-4 text-sm text-white shadow-2xl backdrop-blur">
|
||||
{playerError}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mediaVersionLabel } from './mediaVersion.ts'
|
||||
import type { Media } from '../types/index.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`mediaVersion: ${name}`)
|
||||
}
|
||||
|
||||
function media(partial: Partial<Media>): Media {
|
||||
return {
|
||||
id: 'm1',
|
||||
title: 'SIVR-270',
|
||||
path: '',
|
||||
height: 0,
|
||||
width: 0,
|
||||
size_bytes: 0,
|
||||
...partial,
|
||||
} as Media
|
||||
}
|
||||
|
||||
const partLabel = mediaVersionLabel(media({
|
||||
path: '/media/云下载/sivr-270/sivr-270-1.strm',
|
||||
size_bytes: 157,
|
||||
strm_url: '/api/strm/play/cloud115/video.mp4?pickcode=a',
|
||||
}))
|
||||
check('indistinct strm falls back to filename', partLabel.includes('sivr-270-1'))
|
||||
check('indistinct strm is not generic MP4 only', partLabel.toUpperCase() !== 'MP4')
|
||||
|
||||
const richLabel = mediaVersionLabel(media({
|
||||
path: '/strm/竞女01.mkv.strm',
|
||||
height: 1080,
|
||||
size_bytes: 1024 * 1024 * 1200,
|
||||
strm_url: '/api/strm/play/cloud115/video.mkv?pickcode=a',
|
||||
}))
|
||||
check('rich metadata keeps resolution label', richLabel.includes('1080p'))
|
||||
check('rich metadata keeps container', richLabel.toUpperCase().includes('MKV'))
|
||||
|
||||
const dottedPart = mediaVersionLabel(media({
|
||||
path: '/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm',
|
||||
size_bytes: 157,
|
||||
strm_url: '/api/strm/play/cloud115/video.mp4?pickcode=b',
|
||||
}))
|
||||
check('part2 filename fallback keeps part token', dottedPart.includes('part2'))
|
||||
|
||||
console.log('mediaVersion.test.ts: ok')
|
||||
@@ -107,6 +107,15 @@ function cleanFallbackFilename(raw: string): string {
|
||||
return name
|
||||
}
|
||||
|
||||
function isIndistinctVersionLabel(parts: string[]): boolean {
|
||||
if (parts.length === 0) return true
|
||||
return parts.every((part) => {
|
||||
const upper = part.toUpperCase()
|
||||
if (upper === '云端直链' || upper === 'STRM') return true
|
||||
return VIDEO_EXTENSIONS.has(part.toLowerCase())
|
||||
})
|
||||
}
|
||||
|
||||
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积 / 规格 / 来源标签。 */
|
||||
export function mediaVersionLabel(media: Media): string {
|
||||
const isStrm = isStrmMedia(media)
|
||||
@@ -153,11 +162,11 @@ export function mediaVersionLabel(media: Media): string {
|
||||
parts.push('云端直链')
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
// 只有容器 /「云端直链」时无法区分多分片,回退文件名(sivr-270-1 / …part1)
|
||||
if (parts.length > 0 && !isIndistinctVersionLabel(parts)) {
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
// 兜底:使用清理后的文件名
|
||||
const cleaned = cleanFallbackFilename(media.path || '')
|
||||
return cleaned || media.title || media.id
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
LOCK_ZONE_HEIGHT,
|
||||
LOCK_ZONE_WIDTH,
|
||||
isPointerInLockZone,
|
||||
} from './playerLockZone.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`playerLockZone: ${name}`)
|
||||
}
|
||||
|
||||
const H = 800
|
||||
const MID = H / 2
|
||||
|
||||
// 命中:紧贴左边缘、垂直正中。
|
||||
check('left edge at vertical center hits', isPointerInLockZone(0, MID, H))
|
||||
check('inside the zone near the left hits', isPointerInLockZone(LOCK_ZONE_WIDTH - 1, MID, H))
|
||||
check('top of the zone still hits', isPointerInLockZone(10, MID - LOCK_ZONE_HEIGHT / 2, H))
|
||||
check('bottom of the zone still hits', isPointerInLockZone(10, MID + LOCK_ZONE_HEIGHT / 2, H))
|
||||
|
||||
// 未命中:右侧超出宽度、上下超出高度、中心之外。
|
||||
check('beyond the width misses', !isPointerInLockZone(LOCK_ZONE_WIDTH + 1, MID, H))
|
||||
check('just past the width edge misses', !isPointerInLockZone(LOCK_ZONE_WIDTH + 0.5, MID, H))
|
||||
check('above the zone misses', !isPointerInLockZone(10, MID - LOCK_ZONE_HEIGHT / 2 - 1, H))
|
||||
check('below the zone misses', !isPointerInLockZone(10, MID + LOCK_ZONE_HEIGHT / 2 + 1, H))
|
||||
check('negative x misses', !isPointerInLockZone(-1, MID, H))
|
||||
check('top-left corner misses', !isPointerInLockZone(0, 0, H))
|
||||
check('right edge at center misses', !isPointerInLockZone(1200, MID, H))
|
||||
|
||||
// 不同画面高度下感应区跟着中心移动。
|
||||
check('tracks center on a short stage', isPointerInLockZone(10, 300, 600))
|
||||
check('short stage: off-center misses', !isPointerInLockZone(10, MID, 600))
|
||||
|
||||
// 脏数据一律不命中,避免锁在奇怪的位置冒出来。
|
||||
check('NaN x misses', !isPointerInLockZone(Number.NaN, MID, H))
|
||||
check('NaN y misses', !isPointerInLockZone(10, Number.NaN, H))
|
||||
check('zero stage height misses', !isPointerInLockZone(10, 0, 0))
|
||||
check('negative stage height misses', !isPointerInLockZone(10, 0, -100))
|
||||
check('NaN stage height misses', !isPointerInLockZone(10, 0, Number.NaN))
|
||||
|
||||
console.log('playerLockZone.test.ts ok')
|
||||
@@ -0,0 +1,27 @@
|
||||
// 播放器「锁」按钮的几何判定。
|
||||
//
|
||||
// 锁按钮默认隐身:桌面端靠鼠标移到画面左侧中间把它唤出来,移动端靠点击画面。
|
||||
// 这里用坐标判断鼠标是否落在感应区里,而不是在画面上摆一个透明的热区元素——
|
||||
// 隐形热区会吃掉全景拖动的手势,也会挡住画面本身的单击。
|
||||
|
||||
/** 感应区宽度(像素),自画面左边缘向右展开。 */
|
||||
export const LOCK_ZONE_WIDTH = 72
|
||||
/** 感应区高度(像素),以画面垂直中心为基准上下各展开一半。 */
|
||||
export const LOCK_ZONE_HEIGHT = 88
|
||||
|
||||
/**
|
||||
* 判断鼠标位置(相对画面左上角)是否落在锁按钮的感应区内。
|
||||
*
|
||||
* 参数异常(NaN、画面高度为 0 等)一律返回 false:宁可锁不出现,也不要因为
|
||||
* 拿到脏数据让它在莫名其妙的位置冒出来。
|
||||
*/
|
||||
export function isPointerInLockZone(
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
stageHeight: number,
|
||||
): boolean {
|
||||
if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) return false
|
||||
if (!Number.isFinite(stageHeight) || stageHeight <= 0) return false
|
||||
if (offsetX < 0 || offsetX > LOCK_ZONE_WIDTH) return false
|
||||
return Math.abs(offsetY - stageHeight / 2) <= LOCK_ZONE_HEIGHT / 2
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
detectVr360Profile,
|
||||
normalizeVr360Profile,
|
||||
sameVr360Profile,
|
||||
vr360FrameUv,
|
||||
type Vr360Profile,
|
||||
} from './vr360.ts'
|
||||
import {
|
||||
applyDirection,
|
||||
applyViewOffset,
|
||||
cameraForward,
|
||||
cameraFromDeviceOrientation,
|
||||
cameraFromYawPitch,
|
||||
forwardPitch,
|
||||
forwardYaw,
|
||||
multiply3,
|
||||
perspectiveMatrix,
|
||||
rotationY,
|
||||
rotationZ,
|
||||
transpose3,
|
||||
viewMatrixFromCamera,
|
||||
} from './vr360Math.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`vr360: ${name}`)
|
||||
}
|
||||
|
||||
function close(a: number, b: number, tolerance = 1e-6): boolean {
|
||||
return Math.abs(a - b) <= tolerance
|
||||
}
|
||||
|
||||
function closeAll(a: number[], b: number[], tolerance = 1e-6): boolean {
|
||||
return a.length === b.length && a.every((value, index) => close(value, b[index], tolerance))
|
||||
}
|
||||
|
||||
function profileOf(profile: Vr360Profile): string {
|
||||
return `${profile.projection}/${profile.stereo}`
|
||||
}
|
||||
|
||||
// ---------- 命名/画幅识别 ----------
|
||||
|
||||
const plain = detectVr360Profile({ title: '普通电影', path: '/media/电影/movie.mkv', width: 1920, height: 1080 })
|
||||
check('普通素材不自动进入 VR', !plain.confident)
|
||||
check('普通素材默认 360 单眼', profileOf(plain.profile) === 'equirect360/mono')
|
||||
|
||||
const pano360 = detectVr360Profile({
|
||||
title: '全景演示',
|
||||
path: '/media/360/equirect_demo.mp4',
|
||||
width: 3840,
|
||||
height: 1920,
|
||||
})
|
||||
check('360 关键词可识别', pano360.confident && pano360.profile.projection === 'equirect360')
|
||||
check('2:1 的 360 保持单眼', pano360.profile.stereo === 'mono')
|
||||
|
||||
const vr360Sbs = detectVr360Profile({ path: '/media/vr/vr360-sbs-8k.mp4', width: 7680, height: 1920 })
|
||||
check('4:1 画幅识别为左右并排', vr360Sbs.profile.stereo === 'sbs')
|
||||
|
||||
const ou360 = detectVr360Profile({ path: '/media/vr/pano360-ou.mp4', width: 3840, height: 3840 })
|
||||
check('1:1 的 360 识别为上下并排', ou360.profile.stereo === 'ou')
|
||||
|
||||
const fisheye = detectVr360Profile({ path: '/media/vr/abc-fisheye-180.mp4', width: 1920, height: 1920 })
|
||||
check('鱼眼关键词优先于其它信号', fisheye.profile.projection === 'fisheye180')
|
||||
|
||||
const vr180Sbs = detectVr360Profile({
|
||||
path: '/media/云下载/sivr-270/sivr-270-1.mp4',
|
||||
width: 3840,
|
||||
height: 1920,
|
||||
})
|
||||
check('编号里的 vr 可识别为 VR180', vr180Sbs.confident && vr180Sbs.profile.projection === 'equirect180')
|
||||
check('VR180 的 2:1 画幅为左右并排', vr180Sbs.profile.stereo === 'sbs')
|
||||
|
||||
const vr180Mono = detectVr360Profile({ path: '/media/vr/vr180-mono.mp4', width: 1920, height: 1920 })
|
||||
check('VR180 的 1:1 画幅为单眼', vr180Mono.profile.stereo === 'mono')
|
||||
|
||||
const vr180Ou = detectVr360Profile({ path: '/media/vr/vr180-tb.mp4', width: 1920, height: 3840 })
|
||||
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]))
|
||||
check('左右并排取左半张', closeAll(vr360FrameUv('sbs').scale, [0.5, 1]))
|
||||
check('上下并排取上半张', closeAll(vr360FrameUv('ou').scale, [1, 0.5]))
|
||||
check('并排画面不做偏移', closeAll(vr360FrameUv('sbs').offset, [0, 0]))
|
||||
|
||||
check('未知投影回退到 360 全景', normalizeVr360Profile({ projection: 'nope', stereo: 'nope' }).projection === 'equirect360')
|
||||
check('未知布局回退到单眼', normalizeVr360Profile({}).stereo === 'mono')
|
||||
check(
|
||||
'相同配置判定相等',
|
||||
sameVr360Profile({ projection: 'equirect180', stereo: 'sbs' }, { projection: 'equirect180', stereo: 'sbs' }),
|
||||
)
|
||||
check(
|
||||
'不同配置判定不等',
|
||||
!sameVr360Profile({ projection: 'equirect180', stereo: 'sbs' }, { projection: 'equirect180', stereo: 'ou' }),
|
||||
)
|
||||
|
||||
// ---------- 相机姿态与设备陀螺仪 ----------
|
||||
|
||||
const identityForward = cameraForward(new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]))
|
||||
check('单位姿态朝向地面(设备平放)', closeAll([...identityForward], [0, 0, -1]))
|
||||
|
||||
const level = cameraFromYawPitch(0, 0)
|
||||
check('默认视角水平看向正前方', closeAll([...cameraForward(level)], [0, 1, 0]))
|
||||
|
||||
const east = cameraFromYawPitch(Math.PI / 2, 0)
|
||||
check('方位角 90° 转向正东', closeAll([...cameraForward(east)], [1, 0, 0], 1e-6))
|
||||
|
||||
const up = cameraFromYawPitch(0, Math.PI / 2)
|
||||
check('俯仰角 90° 抬头看天', closeAll([...cameraForward(up)], [0, 0, 1], 1e-6))
|
||||
|
||||
const northEast = cameraFromYawPitch(Math.PI / 4, 0)
|
||||
check('方位角 45° 落在东北方向', close(forwardYaw(cameraForward(northEast)), Math.PI / 4))
|
||||
|
||||
const tilted = cameraFromYawPitch(0.3, -0.4)
|
||||
check('朝向角可反解出方位角', close(forwardYaw(cameraForward(tilted)), 0.3, 1e-6))
|
||||
check('朝向角可反解出俯仰角', close(forwardPitch(cameraForward(tilted)), -0.4, 1e-6))
|
||||
|
||||
check(
|
||||
'旋转矩阵转置等于反向旋转',
|
||||
closeAll([...transpose3(rotationY(0.7))], [...rotationY(-0.7)], 1e-6),
|
||||
)
|
||||
check(
|
||||
'正反旋转相乘为单位矩阵',
|
||||
closeAll(
|
||||
[...multiply3(rotationZ(1.1), rotationZ(-1.1))],
|
||||
[1, 0, 0, 0, 1, 0, 0, 0, 1],
|
||||
1e-6,
|
||||
),
|
||||
)
|
||||
|
||||
// 视图矩阵 = 相机姿态的逆(纯旋转)
|
||||
const view = viewMatrixFromCamera(east)
|
||||
check('视图矩阵为相机矩阵的转置', closeAll([...view], [...transpose3(east)], 1e-6))
|
||||
|
||||
// 设备姿态约定(与 W3C 规范一致):相机看向设备背面,
|
||||
// 因此设备平放时(alpha 任意、beta = 0)视线朝下。
|
||||
const flat = cameraFromDeviceOrientation(Math.PI / 3, 0, 0, 0)
|
||||
check('设备平放时看向地面', closeAll([...cameraForward(flat)], [0, 0, -1], 1e-6))
|
||||
|
||||
// 竖持手机(beta = 90°)时视线水平
|
||||
const portrait = cameraFromDeviceOrientation(0, Math.PI / 2, 0, 0)
|
||||
check('竖持手机时视线水平', close(cameraForward(portrait)[2], 0, 1e-6))
|
||||
check('竖持手机时视线朝北', close(forwardYaw(cameraForward(portrait)), 0, 1e-6))
|
||||
|
||||
// 横屏(屏幕旋转 90°)时相机「上方向」跟随屏幕
|
||||
const landscape = cameraFromDeviceOrientation(0, Math.PI / 2, 0, Math.PI / 2)
|
||||
check('横屏时画面随屏幕转向', closeAll([...applyDirection(landscape, 0, 1, 0)], [1, 0, 0], 1e-6))
|
||||
|
||||
// 陀螺仪开启瞬间的视角对齐:叠加偏移后朝向应等于对齐前记录的拖拽视角
|
||||
const aligned = applyViewOffset(cameraFromDeviceOrientation(0, Math.PI / 2, 0, 0), 0.5, 0.25)
|
||||
check('叠加偏移后方位角对齐', close(forwardYaw(cameraForward(aligned)), 0.5, 1e-6))
|
||||
check('叠加偏移后俯仰角对齐', close(forwardPitch(cameraForward(aligned)), 0.25, 1e-6))
|
||||
|
||||
// ---------- 透视投影 ----------
|
||||
|
||||
const projection = perspectiveMatrix(Math.PI / 2, 2, 0.1, 10)
|
||||
check('透视矩阵缩放项随视场角变化', close(projection[0], 0.5))
|
||||
check('透视矩阵写入 w = -1(右手系)', projection[11] === -1)
|
||||
|
||||
console.log('vr360.test.ts: ok')
|
||||
@@ -0,0 +1,237 @@
|
||||
// VR360(全景)播放的配置模型、命名启发式识别与本地偏好。
|
||||
//
|
||||
// 网页端只能从文件名/路径和画幅比例推断「这段视频是不是全景/VR」,任何信号
|
||||
// 都不绝对可靠,所以这里只负责给出「大概率正确」的初始配置:识别结果不写入
|
||||
// 数据库,用户随时可以在播放器菜单里改投影方式与立体布局,改动只影响本机。
|
||||
|
||||
export type Vr360Projection = 'equirect360' | 'equirect180' | 'fisheye180'
|
||||
export type Vr360Stereo = 'mono' | 'sbs' | 'ou'
|
||||
|
||||
export type Vr360Profile = {
|
||||
/** 画面投影方式:360 等距柱状、180 等距柱状、180 鱼眼。 */
|
||||
projection: Vr360Projection
|
||||
/** 立体布局:单眼、左右并排、上下并排。 */
|
||||
stereo: Vr360Stereo
|
||||
}
|
||||
|
||||
export const DEFAULT_VR360_PROFILE: Vr360Profile = {
|
||||
projection: 'equirect360',
|
||||
stereo: 'mono',
|
||||
}
|
||||
|
||||
/** 视场角可调范围(度):数值越小画面越「拉近」。 */
|
||||
export const VR360_MIN_FOV = 35
|
||||
export const VR360_MAX_FOV = 110
|
||||
export const VR360_DEFAULT_FOV = 75
|
||||
|
||||
export const VR360_PROJECTION_OPTIONS: Array<[Vr360Projection, string]> = [
|
||||
['equirect360', '360° 全景'],
|
||||
['equirect180', '180° 半景'],
|
||||
['fisheye180', '180° 鱼眼'],
|
||||
]
|
||||
|
||||
export const VR360_STEREO_OPTIONS: Array<[Vr360Stereo, string]> = [
|
||||
['mono', '单眼'],
|
||||
['sbs', '左右并排'],
|
||||
['ou', '上下并排'],
|
||||
]
|
||||
|
||||
export function vr360ProjectionLabel(projection: Vr360Projection): string {
|
||||
return VR360_PROJECTION_OPTIONS.find(([value]) => value === projection)?.[1] ?? '360° 全景'
|
||||
}
|
||||
|
||||
export function vr360StereoLabel(stereo: Vr360Stereo): string {
|
||||
return VR360_STEREO_OPTIONS.find(([value]) => value === stereo)?.[1] ?? '单眼'
|
||||
}
|
||||
|
||||
export function normalizeVr360Projection(value: unknown): Vr360Projection {
|
||||
return value === 'equirect180' || value === 'fisheye180' ? value : 'equirect360'
|
||||
}
|
||||
|
||||
export function normalizeVr360Stereo(value: unknown): Vr360Stereo {
|
||||
return value === 'sbs' || value === 'ou' ? value : 'mono'
|
||||
}
|
||||
|
||||
export function normalizeVr360Profile(value: unknown): Vr360Profile {
|
||||
const profile = (value ?? {}) as Partial<Vr360Profile>
|
||||
return {
|
||||
projection: normalizeVr360Projection(profile.projection),
|
||||
stereo: normalizeVr360Stereo(profile.stereo),
|
||||
}
|
||||
}
|
||||
|
||||
export function sameVr360Profile(a: Vr360Profile, b: Vr360Profile): boolean {
|
||||
return a.projection === b.projection && a.stereo === b.stereo
|
||||
}
|
||||
|
||||
// 立体素材(左右/上下并排)在普通屏幕上只取左眼画面:真正的双眼渲染需要
|
||||
// WebXR 头显,网页里把两半都画出来只会得到重复画面。
|
||||
export function vr360FrameUv(stereo: Vr360Stereo): {
|
||||
scale: [number, number]
|
||||
offset: [number, number]
|
||||
} {
|
||||
if (stereo === 'sbs') return { scale: [0.5, 1], offset: [0, 0] }
|
||||
if (stereo === 'ou') return { scale: [1, 0.5], offset: [0, 0] }
|
||||
return { scale: [1, 1], offset: [0, 0] }
|
||||
}
|
||||
|
||||
export type Vr360DetectInput = {
|
||||
title?: string
|
||||
originalName?: string
|
||||
path?: string
|
||||
relativePath?: string
|
||||
/** 视频画面宽度(媒体元数据或 ffprobe 结果)。 */
|
||||
width?: number
|
||||
/** 视频画面高度。 */
|
||||
height?: number
|
||||
}
|
||||
|
||||
export type Vr360Detection = {
|
||||
profile: Vr360Profile
|
||||
/** 文件名/路径里出现了明确的 VR 或全景关键词,可以放心自动进入 VR 模式。 */
|
||||
confident: boolean
|
||||
}
|
||||
|
||||
// 关键词分两类:明确的关键词按子串匹配("vr360" 里也含 "360"),
|
||||
// 容易误伤的两字母缩写按作品编号形态匹配。
|
||||
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 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))
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
// 文件名比标题可靠:标题里可能出现「VR」之类的普通单词,文件名里的 "vr" 通常
|
||||
// 是作品编号的一部分(如 sivr-270、ipvr00192)。
|
||||
function detectNameText(input: Vr360DetectInput): string {
|
||||
return [input.path, input.relativePath, input.originalName]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function detectTokens(text: string): Set<string> {
|
||||
return new Set(text.split(/[^a-z0-9]+/).filter(Boolean))
|
||||
}
|
||||
|
||||
function hasSubstring(text: string, needles: string[]): boolean {
|
||||
return needles.some((needle) => text.includes(needle))
|
||||
}
|
||||
|
||||
function hasToken(tokens: Set<string>, needles: string[]): boolean {
|
||||
return needles.some((needle) => tokens.has(needle))
|
||||
}
|
||||
|
||||
function aspectRatio(input: Vr360DetectInput): number | null {
|
||||
const width = input.width ?? 0
|
||||
const height = input.height ?? 0
|
||||
if (width <= 0 || height <= 0) return null
|
||||
const ratio = width / height
|
||||
return Number.isFinite(ratio) && ratio > 0 ? ratio : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 依据文件名/路径关键词与画幅比例推断 VR 配置。
|
||||
*
|
||||
* 关键词比画幅可靠:2:1 既可能是 360 单眼,也可能是 180 左右并排,所以比例只
|
||||
* 在没有明确关键词时补默认值(4:1 ⇒ 左右并排;约 1:1 的 360 ⇒ 上下并排)。
|
||||
*/
|
||||
export function detectVr360Profile(input: Vr360DetectInput): Vr360Detection {
|
||||
const text = detectText(input)
|
||||
const nameText = detectNameText(input)
|
||||
const tokens = detectTokens(text)
|
||||
|
||||
const fisheye = hasSubstring(text, FISHEYE_SUBSTRINGS)
|
||||
const vr180 = hasSubstring(text, VR180_SUBSTRINGS)
|
||||
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
|
||||
if (fisheye) projection = 'fisheye180'
|
||||
else if (vr180) projection = 'equirect180'
|
||||
// 只有 "VR" 一个信号、没有 360/全景关键词时,按更常见的 VR180 处理。
|
||||
else if (vrToken && !panorama) projection = 'equirect180'
|
||||
|
||||
const sbs = hasSubstring(text, SBS_SUBSTRINGS) || hasToken(tokens, SBS_TOKENS)
|
||||
const ou = hasSubstring(text, OU_SUBSTRINGS) || hasToken(tokens, OU_TOKENS)
|
||||
|
||||
let stereo: Vr360Stereo = 'mono'
|
||||
if (sbs !== ou) stereo = sbs ? 'sbs' : 'ou'
|
||||
|
||||
const ratio = aspectRatio(input)
|
||||
if (ratio !== null && stereo === 'mono') {
|
||||
if (projection === 'equirect360') {
|
||||
// 4:1 基本只可能是 360 左右并排(如 7680x1920);
|
||||
// 约 1:1 的 360 素材多为上下并排(如 3840x3840)。
|
||||
if (ratio >= 3.4) stereo = 'sbs'
|
||||
else if (ratio <= 1.05) stereo = 'ou'
|
||||
} else {
|
||||
// VR180 单眼是 1:1,左右并排是 2:1,上下并排是 1:2。
|
||||
if (ratio >= 1.8 && ratio <= 2.2) stereo = 'sbs'
|
||||
else if (ratio <= 0.6) stereo = 'ou'
|
||||
}
|
||||
}
|
||||
|
||||
return { profile: { projection, stereo }, confident }
|
||||
}
|
||||
|
||||
const VR360_PREF_KEY = 'mebox.player.vr360'
|
||||
|
||||
export type Vr360Preference = {
|
||||
/** 是否按文件名/画幅自动进入 VR 模式;用户可以关掉,避免误判干扰正常播放。 */
|
||||
autoDetect: boolean
|
||||
/** 用户上次选定的投影与布局,作为下次手动进入 VR 模式时的默认值。 */
|
||||
profile: Vr360Profile
|
||||
}
|
||||
|
||||
export const DEFAULT_VR360_PREFERENCE: Vr360Preference = {
|
||||
autoDetect: true,
|
||||
profile: DEFAULT_VR360_PROFILE,
|
||||
}
|
||||
|
||||
export function loadVr360Preference(): Vr360Preference {
|
||||
if (typeof window === 'undefined') return DEFAULT_VR360_PREFERENCE
|
||||
try {
|
||||
const raw = window.localStorage.getItem(VR360_PREF_KEY)
|
||||
if (!raw) return DEFAULT_VR360_PREFERENCE
|
||||
const parsed = JSON.parse(raw) as Partial<Vr360Preference>
|
||||
return {
|
||||
autoDetect: parsed.autoDetect !== false,
|
||||
profile: normalizeVr360Profile(parsed.profile),
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_VR360_PREFERENCE
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVr360Preference(preference: Vr360Preference): void {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
VR360_PREF_KEY,
|
||||
JSON.stringify({
|
||||
autoDetect: preference.autoDetect !== false,
|
||||
profile: normalizeVr360Profile(preference.profile),
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
// 隐私模式下 localStorage 不可用;播放器仍可正常使用 VR 模式。
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// VR360 播放用到的纯数学:3x3 旋转矩阵、设备姿态到相机姿态、透视投影。
|
||||
//
|
||||
// 坐标约定(与 W3C DeviceOrientation 规范一致,右手系):
|
||||
// x = 东(设备右侧),y = 北(设备顶部),z = 天空(设备屏幕朝外)
|
||||
// 相机看向相机局部坐标的 -z,设备平放时 -z 指向地面,因此设备平放时视角朝下,
|
||||
// 竖持手机(beta=90°)时视角水平向前 —— 与手机陀螺仪的实际手感一致。
|
||||
//
|
||||
// 所有 3x3 矩阵都是 WebGL 默认的列主序(column-major)Float32Array(9),
|
||||
// 可以直接交给 gl.uniformMatrix3fv,不需要转置。
|
||||
|
||||
export type Mat3 = Float32Array
|
||||
|
||||
export function degToRad(degrees: number): number {
|
||||
return (degrees * Math.PI) / 180
|
||||
}
|
||||
|
||||
export function clampNumber(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
export function createMat3(
|
||||
m00: number, m10: number, m20: number,
|
||||
m01: number, m11: number, m21: number,
|
||||
m02: number, m12: number, m22: number,
|
||||
): Mat3 {
|
||||
// WebGL 列主序内存布局:[col0, col1, col2]
|
||||
return new Float32Array([m00, m10, m20, m01, m11, m21, m02, m12, m22])
|
||||
}
|
||||
|
||||
export function identity3(): Mat3 {
|
||||
return createMat3(1, 0, 0, 0, 1, 0, 0, 0, 1)
|
||||
}
|
||||
|
||||
export function rotationX(radians: number): Mat3 {
|
||||
const c = Math.cos(radians)
|
||||
const s = Math.sin(radians)
|
||||
return createMat3(1, 0, 0, 0, c, s, 0, -s, c)
|
||||
}
|
||||
|
||||
export function rotationY(radians: number): Mat3 {
|
||||
const c = Math.cos(radians)
|
||||
const s = Math.sin(radians)
|
||||
return createMat3(c, 0, -s, 0, 1, 0, s, 0, c)
|
||||
}
|
||||
|
||||
export function rotationZ(radians: number): Mat3 {
|
||||
const c = Math.cos(radians)
|
||||
const s = Math.sin(radians)
|
||||
return createMat3(c, s, 0, -s, c, 0, 0, 0, 1)
|
||||
}
|
||||
|
||||
/** 先施加 b,再施加 a(即矩阵乘积 a·b)。 */
|
||||
export function multiply3(a: Mat3, b: Mat3): Mat3 {
|
||||
const out = new Float32Array(9)
|
||||
for (let col = 0; col < 3; col++) {
|
||||
for (let row = 0; row < 3; row++) {
|
||||
let sum = 0
|
||||
for (let k = 0; k < 3; k++) {
|
||||
sum += a[k * 3 + row] * b[col * 3 + k]
|
||||
}
|
||||
out[col * 3 + row] = sum
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 旋转矩阵的逆等于转置。 */
|
||||
export function transpose3(m: Mat3): Mat3 {
|
||||
const out = new Float32Array(9)
|
||||
for (let col = 0; col < 3; col++) {
|
||||
for (let row = 0; row < 3; row++) {
|
||||
out[col * 3 + row] = m[row * 3 + col]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function applyDirection(m: Mat3, x: number, y: number, z: number): [number, number, number] {
|
||||
return [
|
||||
m[0] * x + m[3] * y + m[6] * z,
|
||||
m[1] * x + m[4] * y + m[7] * z,
|
||||
m[2] * x + m[5] * y + m[8] * z,
|
||||
]
|
||||
}
|
||||
|
||||
/** 相机在世界坐标中的朝向(相机局部 -z)。 */
|
||||
export function cameraForward(camera: Mat3): [number, number, number] {
|
||||
const forward = applyDirection(camera, 0, 0, -1)
|
||||
const length = Math.hypot(forward[0], forward[1], forward[2]) || 1
|
||||
return [forward[0] / length, forward[1] / length, forward[2] / length]
|
||||
}
|
||||
|
||||
/** 相机朝向的水平方位角(0 = +y 即正北,顺时针转向 +x 即正东)。 */
|
||||
export function forwardYaw(forward: [number, number, number]): number {
|
||||
return Math.atan2(forward[0], forward[1])
|
||||
}
|
||||
|
||||
/** 相机朝向的俯仰角(+ 抬头,- 低头)。 */
|
||||
export function forwardPitch(forward: [number, number, number]): number {
|
||||
return Math.asin(clampNumber(forward[2], -1, 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 由「用户拖拽/默认视角」的方位角与俯仰角构造相机姿态。
|
||||
* 默认(yaw = 0, pitch = 0)时相机水平看向 +y,画面中的地平线保持水平。
|
||||
*/
|
||||
export function cameraFromYawPitch(yaw: number, pitch: number): Mat3 {
|
||||
return multiply3(rotationZ(-yaw), rotationX(Math.PI / 2 + pitch))
|
||||
}
|
||||
|
||||
/**
|
||||
* 由设备姿态构造相机姿态(W3C 规范:R = Rz(α)·Rx(β)·Ry(γ)),
|
||||
* 再按屏幕旋转角做一次横滚校正,使画面正向与屏幕正向一致。
|
||||
*/
|
||||
export function cameraFromDeviceOrientation(
|
||||
alpha: number,
|
||||
beta: number,
|
||||
gamma: number,
|
||||
screenAngle: number,
|
||||
): Mat3 {
|
||||
const device = multiply3(multiply3(rotationZ(alpha), rotationX(beta)), rotationY(gamma))
|
||||
return multiply3(device, rotationZ(-screenAngle))
|
||||
}
|
||||
|
||||
/**
|
||||
* 在设备姿态之上叠加用户拖拽产生的视角偏移:水平方向围绕世界 z 轴旋转,
|
||||
* 垂直方向围绕相机自身的右轴倾斜(小角度下等效于抬头/低头)。
|
||||
*/
|
||||
export function applyViewOffset(camera: Mat3, yawOffset: number, pitchOffset: number): Mat3 {
|
||||
return multiply3(rotationZ(-yawOffset), multiply3(camera, rotationX(pitchOffset)))
|
||||
}
|
||||
|
||||
/** 世界坐标 → 相机坐标的视图矩阵(纯旋转,所以就是转置)。 */
|
||||
export function viewMatrixFromCamera(camera: Mat3): Mat3 {
|
||||
return transpose3(camera)
|
||||
}
|
||||
|
||||
/** 标准透视投影矩阵(列主序 4x4)。 */
|
||||
export function perspectiveMatrix(
|
||||
fovY: number,
|
||||
aspect: number,
|
||||
near: number,
|
||||
far: number,
|
||||
): Float32Array {
|
||||
const f = 1 / Math.tan(fovY / 2)
|
||||
const rangeInv = 1 / (near - far)
|
||||
return new Float32Array([
|
||||
f / aspect, 0, 0, 0,
|
||||
0, f, 0, 0,
|
||||
0, 0, (far + near) * rangeInv, -1,
|
||||
0, 0, 2 * far * near * rangeInv, 0,
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// 用一段 WebGL 把 <video> 贴到球面/半球面上,实现 360°/180° 全景播放。
|
||||
//
|
||||
// 不引入 three.js:整个渲染器只是一段着色器 + 一个网格 + 一张视频纹理,依赖
|
||||
// 极小,同时也不会把播放页首屏体积撑大(该模块由播放器按需懒加载)。
|
||||
//
|
||||
// 视频元素始终是同一个正在播放的 <video>,这里只把它当前的帧采样到球面上,
|
||||
// 因此 HLS、直连、115 云转码等所有播放方式都能直接复用。
|
||||
|
||||
import type { Mat3 } from './vr360Math'
|
||||
import { perspectiveMatrix } from './vr360Math'
|
||||
import {
|
||||
VR360_DEFAULT_FOV,
|
||||
VR360_MAX_FOV,
|
||||
VR360_MIN_FOV,
|
||||
type Vr360Projection,
|
||||
} from './vr360'
|
||||
|
||||
export type Vr360Renderer = {
|
||||
setProjection(projection: Vr360Projection): void
|
||||
/** 立体素材裁切:只取左眼所在的半张画面。 */
|
||||
setFrameUv(scale: [number, number], offset: [number, number]): void
|
||||
setView(view: Mat3): void
|
||||
setFov(fovDeg: number): void
|
||||
resize(width: number, height: number): void
|
||||
draw(): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export type Vr360RendererOptions = {
|
||||
/** WebGL 上下文丢失(移动端切后台、系统回收 GPU 资源)时回调,由调用方退出 VR 模式。 */
|
||||
onContextLost?: () => void
|
||||
/** 视频帧无法写入纹理(跨域污染等)时回调。 */
|
||||
onTextureError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
const VERTEX_SHADER = `
|
||||
attribute vec3 aPosition;
|
||||
attribute vec2 aUv;
|
||||
uniform mat3 uView;
|
||||
uniform mat4 uProjection;
|
||||
uniform vec2 uUvScale;
|
||||
uniform vec2 uUvOffset;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = aUv * uUvScale + uUvOffset;
|
||||
gl_Position = uProjection * vec4(uView * aPosition, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAGMENT_SHADER = `
|
||||
precision mediump float;
|
||||
uniform sampler2D uTexture;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(uTexture, vUv);
|
||||
}
|
||||
`
|
||||
|
||||
// 球面半径为 1 个单位,相机固定在球心,远近裁剪面据此选取。
|
||||
const NEAR_PLANE = 0.1
|
||||
const FAR_PLANE = 10
|
||||
|
||||
type Mesh = {
|
||||
positions: Float32Array
|
||||
uvs: Float32Array
|
||||
indices: Uint16Array
|
||||
indexCount: number
|
||||
}
|
||||
|
||||
type GridPoint = {
|
||||
position: [number, number, number]
|
||||
uv: [number, number]
|
||||
}
|
||||
|
||||
function buildGrid(
|
||||
columns: number,
|
||||
rows: number,
|
||||
point: (col: number, row: number) => GridPoint,
|
||||
): Mesh {
|
||||
const positions = new Float32Array((columns + 1) * (rows + 1) * 3)
|
||||
const uvs = new Float32Array((columns + 1) * (rows + 1) * 2)
|
||||
for (let row = 0; row <= rows; row++) {
|
||||
for (let col = 0; col <= columns; col++) {
|
||||
const index = row * (columns + 1) + col
|
||||
const { position, uv } = point(col, row)
|
||||
positions[index * 3] = position[0]
|
||||
positions[index * 3 + 1] = position[1]
|
||||
positions[index * 3 + 2] = position[2]
|
||||
uvs[index * 2] = uv[0]
|
||||
uvs[index * 2 + 1] = uv[1]
|
||||
}
|
||||
}
|
||||
|
||||
const indices = new Uint16Array(columns * rows * 6)
|
||||
let cursor = 0
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < columns; col++) {
|
||||
const topLeft = row * (columns + 1) + col
|
||||
const topRight = topLeft + 1
|
||||
const bottomLeft = topLeft + columns + 1
|
||||
const bottomRight = bottomLeft + 1
|
||||
indices[cursor++] = topLeft
|
||||
indices[cursor++] = bottomLeft
|
||||
indices[cursor++] = topRight
|
||||
indices[cursor++] = topRight
|
||||
indices[cursor++] = bottomLeft
|
||||
indices[cursor++] = bottomRight
|
||||
}
|
||||
}
|
||||
|
||||
return { positions, uvs, indices, indexCount: indices.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* 等距柱状投影:水平覆盖 ±span,垂直覆盖 -90°~+90°。
|
||||
* u 从 0(左端)到 1(右端),v 从 0(画面顶部)到 1(画面底部)。
|
||||
*/
|
||||
function buildEquirectMesh(span: number): Mesh {
|
||||
const columns = 96
|
||||
const rows = 48
|
||||
return buildGrid(columns, rows, (col, row) => {
|
||||
const lambda = -span + (2 * span * col) / columns
|
||||
const phi = -Math.PI / 2 + (Math.PI * row) / rows
|
||||
const cosPhi = Math.cos(phi)
|
||||
return {
|
||||
position: [Math.sin(lambda) * cosPhi, Math.cos(lambda) * cosPhi, Math.sin(phi)],
|
||||
uv: [(lambda + span) / (2 * span), 0.5 - phi / Math.PI],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 180° 鱼眼(等距投影):画面中心是正前方,纹理半径 r 与偏角 θ 成正比。
|
||||
* 方画幅内切圆之外的区域不参与采样,标准 VR180 鱼眼素材即如此。
|
||||
*/
|
||||
function buildFisheyeMesh(): Mesh {
|
||||
const radial = 64
|
||||
const azimuth = 96
|
||||
const thetaMax = Math.PI / 2
|
||||
return buildGrid(azimuth, radial, (col, row) => {
|
||||
const psi = (2 * Math.PI * col) / azimuth
|
||||
const theta = (thetaMax * row) / radial
|
||||
const sinTheta = Math.sin(theta)
|
||||
const radius = theta / thetaMax
|
||||
return {
|
||||
position: [sinTheta * Math.cos(psi), Math.cos(theta), sinTheta * Math.sin(psi)],
|
||||
uv: [0.5 + 0.5 * radius * Math.cos(psi), 0.5 - 0.5 * radius * Math.sin(psi)],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildMesh(projection: Vr360Projection): Mesh {
|
||||
if (projection === 'fisheye180') return buildFisheyeMesh()
|
||||
if (projection === 'equirect180') return buildEquirectMesh(Math.PI / 2)
|
||||
return buildEquirectMesh(Math.PI)
|
||||
}
|
||||
|
||||
function compileShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
|
||||
const shader = gl.createShader(type)
|
||||
if (!shader) return null
|
||||
gl.shaderSource(shader, source)
|
||||
gl.compileShader(shader)
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
gl.deleteShader(shader)
|
||||
return null
|
||||
}
|
||||
return shader
|
||||
}
|
||||
|
||||
/**
|
||||
* 在给定画布上创建全景渲染器;浏览器不支持 WebGL 时返回 null,调用方据此
|
||||
* 退回普通播放,而不是给用户一个黑屏。
|
||||
*/
|
||||
export function createVr360Renderer(
|
||||
canvas: HTMLCanvasElement,
|
||||
video: HTMLVideoElement,
|
||||
options: Vr360RendererOptions = {},
|
||||
): Vr360Renderer | null {
|
||||
const attributes: WebGLContextAttributes = {
|
||||
alpha: false,
|
||||
antialias: false,
|
||||
depth: false,
|
||||
stencil: false,
|
||||
premultipliedAlpha: false,
|
||||
preserveDrawingBuffer: false,
|
||||
powerPreference: 'high-performance',
|
||||
}
|
||||
const gl = (canvas.getContext('webgl2', attributes) ??
|
||||
canvas.getContext('webgl', attributes)) as WebGLRenderingContext | null
|
||||
if (!gl) return null
|
||||
|
||||
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER)
|
||||
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER)
|
||||
if (!vertexShader || !fragmentShader) return null
|
||||
const program = gl.createProgram()
|
||||
if (!program) return null
|
||||
gl.attachShader(program, vertexShader)
|
||||
gl.attachShader(program, fragmentShader)
|
||||
gl.linkProgram(program)
|
||||
gl.deleteShader(vertexShader)
|
||||
gl.deleteShader(fragmentShader)
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
gl.deleteProgram(program)
|
||||
return null
|
||||
}
|
||||
|
||||
const positionLocation = gl.getAttribLocation(program, 'aPosition')
|
||||
const uvLocation = gl.getAttribLocation(program, 'aUv')
|
||||
const viewLocation = gl.getUniformLocation(program, 'uView')
|
||||
const projectionLocation = gl.getUniformLocation(program, 'uProjection')
|
||||
const uvScaleLocation = gl.getUniformLocation(program, 'uUvScale')
|
||||
const uvOffsetLocation = gl.getUniformLocation(program, 'uUvOffset')
|
||||
const textureLocation = gl.getUniformLocation(program, 'uTexture')
|
||||
|
||||
const positionBuffer = gl.createBuffer()
|
||||
const uvBuffer = gl.createBuffer()
|
||||
const indexBuffer = gl.createBuffer()
|
||||
const texture = gl.createTexture()
|
||||
if (!positionBuffer || !uvBuffer || !indexBuffer || !texture) return null
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
// 先放一张 1x1 黑色占位图,视频帧尚未就绪时也不会出现未定义内容。
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
new Uint8Array([0, 0, 0, 255]),
|
||||
)
|
||||
|
||||
gl.disable(gl.CULL_FACE)
|
||||
gl.disable(gl.DEPTH_TEST)
|
||||
gl.disable(gl.BLEND)
|
||||
gl.clearColor(0, 0, 0, 1)
|
||||
|
||||
let indexCount = 0
|
||||
let uvScale: [number, number] = [1, 1]
|
||||
let uvOffset: [number, number] = [0, 0]
|
||||
let view: Mat3 = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1])
|
||||
let fov = VR360_DEFAULT_FOV
|
||||
let displayWidth = 1
|
||||
let displayHeight = 1
|
||||
let disposed = false
|
||||
let contextLost = false
|
||||
|
||||
const uploadMesh = (mesh: Mesh) => {
|
||||
indexCount = mesh.indexCount
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, mesh.positions, gl.STATIC_DRAW)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, mesh.uvs, gl.STATIC_DRAW)
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.indices, gl.STATIC_DRAW)
|
||||
}
|
||||
uploadMesh(buildMesh('equirect360'))
|
||||
|
||||
const handleContextLost = (event: Event) => {
|
||||
// 必须 preventDefault,否则上下文不会被标记为可恢复;这里直接让上层退出 VR。
|
||||
event.preventDefault()
|
||||
contextLost = true
|
||||
options.onContextLost?.()
|
||||
}
|
||||
canvas.addEventListener('webglcontextlost', handleContextLost)
|
||||
|
||||
return {
|
||||
setProjection(projection: Vr360Projection) {
|
||||
if (disposed) return
|
||||
uploadMesh(buildMesh(projection))
|
||||
},
|
||||
setFrameUv(scale: [number, number], offset: [number, number]) {
|
||||
uvScale = scale
|
||||
uvOffset = offset
|
||||
},
|
||||
setView(next: Mat3) {
|
||||
view = next
|
||||
},
|
||||
setFov(next: number) {
|
||||
fov = Math.min(VR360_MAX_FOV, Math.max(VR360_MIN_FOV, next))
|
||||
},
|
||||
resize(nextWidth: number, nextHeight: number) {
|
||||
if (disposed) return
|
||||
// 画布位置与尺寸完全交给 CSS(inset-0 + 100%),绘制缓冲区只跟随它。
|
||||
// 否则「观测到画布变大 → 设置更大的缓冲区 → 布局再次变大」会指数放大。
|
||||
canvas.style.width = '100%'
|
||||
canvas.style.height = '100%'
|
||||
// 移动端按 DPR 渲染 4K 视频纹理代价很高,上限锁 2 倍。
|
||||
const dpr = Math.min(2, Math.max(1, window.devicePixelRatio || 1))
|
||||
displayWidth = Math.max(1, nextWidth)
|
||||
displayHeight = Math.max(1, nextHeight)
|
||||
const pixelWidth = Math.max(1, Math.round(displayWidth * dpr))
|
||||
const pixelHeight = Math.max(1, Math.round(displayHeight * dpr))
|
||||
if (canvas.width !== pixelWidth) canvas.width = pixelWidth
|
||||
if (canvas.height !== pixelHeight) canvas.height = pixelHeight
|
||||
},
|
||||
draw() {
|
||||
if (disposed || contextLost) return
|
||||
if (canvas.width < 1 || canvas.height < 1) return
|
||||
|
||||
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||
gl.clear(gl.COLOR_BUFFER_BIT)
|
||||
|
||||
if (video.readyState >= 2 && video.videoWidth > 0) {
|
||||
try {
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video)
|
||||
} catch (error) {
|
||||
options.onTextureError?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
gl.useProgram(program)
|
||||
gl.uniformMatrix3fv(viewLocation, false, view)
|
||||
gl.uniformMatrix4fv(
|
||||
projectionLocation,
|
||||
false,
|
||||
perspectiveMatrix(
|
||||
(fov * Math.PI) / 180,
|
||||
displayWidth / displayHeight,
|
||||
NEAR_PLANE,
|
||||
FAR_PLANE,
|
||||
),
|
||||
)
|
||||
gl.uniform2f(uvScaleLocation, uvScale[0], uvScale[1])
|
||||
gl.uniform2f(uvOffsetLocation, uvOffset[0], uvOffset[1])
|
||||
gl.uniform1i(textureLocation, 0)
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0)
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
|
||||
gl.enableVertexAttribArray(positionLocation)
|
||||
gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0)
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer)
|
||||
gl.enableVertexAttribArray(uvLocation)
|
||||
gl.vertexAttribPointer(uvLocation, 2, gl.FLOAT, false, 0, 0)
|
||||
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
|
||||
gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0)
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
canvas.removeEventListener('webglcontextlost', handleContextLost)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, null)
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null)
|
||||
gl.deleteBuffer(positionBuffer)
|
||||
gl.deleteBuffer(uvBuffer)
|
||||
gl.deleteBuffer(indexBuffer)
|
||||
gl.deleteTexture(texture)
|
||||
gl.deleteProgram(program)
|
||||
// 主动释放上下文:移动端同时存活多个 WebGL 上下文会被浏览器强制回收。
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user