mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec7be38266 | |||
| ebf791c436 | |||
| 83c0b65a0b | |||
| 1153fe4191 | |||
| 93866d169b | |||
| d283d39008 | |||
| fc89d23867 | |||
| 1f14a1e85b | |||
| f0a6b630c6 | |||
| c723664e46 |
@@ -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) {
|
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
|
||||||
svc := newDanmakuSettingsService(t)
|
svc := newDanmakuSettingsService(t)
|
||||||
|
|
||||||
|
|||||||
@@ -722,7 +722,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mount.ProxyPlay {
|
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
|
||||||
|
// 与挂载账号的 proxy_play 开关等价。
|
||||||
|
if mount.ProxyPlay || wantSameOriginProxy(c) {
|
||||||
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
||||||
if !c.Writer.Written() {
|
if !c.Writer.Written() {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
@@ -758,6 +760,15 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// ?proxy=1:把网盘/STRM 直链改为服务端同源转发(网页端读帧、VR 全景用),
|
||||||
|
// 画质与原文件一致,不触发转码。
|
||||||
|
if wantSameOriginProxy(c) {
|
||||||
|
handled, proxyErr := proxySTRMStream(c, svc, m)
|
||||||
|
if handled {
|
||||||
|
writeProxyError(c, proxyErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
||||||
if errors.Is(err, service.ErrMediaNotFound) {
|
if errors.Is(err, service.ErrMediaNotFound) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// 直连流的同源转发:网页端需要读取视频帧的场景(VR 全景渲染走 WebGL 纹理)
|
||||||
|
// 不能使用会跳到网盘 CDN 的跨域直链,这里把直链改为服务端转发。
|
||||||
|
//
|
||||||
|
// 只在客户端显式带上 ?proxy=1 时生效,普通播放仍走原来的 302 直连,
|
||||||
|
// 避免把网盘流量无谓地压到服务器上。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wantSameOriginProxy 客户端是否要求把直连流改为服务端同源转发。
|
||||||
|
func wantSameOriginProxy(c *gin.Context) bool {
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(c.Query("proxy"))) {
|
||||||
|
case "1", "true", "yes":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxySTRMStream 处理 STRM/网盘媒体的同源转发请求。返回 handled=false 表示
|
||||||
|
// 该媒体不需要(或无法)代理,调用方继续按原有逻辑处理。
|
||||||
|
func proxySTRMStream(c *gin.Context, svc *service.Container, m *model.Media) (bool, error) {
|
||||||
|
if c == nil || svc == nil || svc.Strm == nil || m == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if !service.IsStrmMediaRow(m) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := svc.Strm.ProxyMediaDirect(c.Request.Context(), c.Writer, c.Request, m); err != nil {
|
||||||
|
if errors.Is(err, service.ErrStrmProxyNotApplicable) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeProxyError 在尚未写入任何响应内容时回一个明确的网关错误。
|
||||||
|
func writeProxyError(c *gin.Context, err error) {
|
||||||
|
if c == nil || err == nil || c.Writer.Written() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
+12
-9
@@ -37,15 +37,18 @@ type User struct {
|
|||||||
// original / simplified / traditional。
|
// original / simplified / traditional。
|
||||||
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||||
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
||||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||||
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
|
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
|
||||||
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
// PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明,
|
||||||
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
// 按用户保存:看过一次之后不再弹出。
|
||||||
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"`
|
||||||
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
|
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
||||||
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
|
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
||||||
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
|
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
||||||
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
|
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 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
|
||||||
DanmakuAppKey string `gorm:"size:256" json:"-"`
|
DanmakuAppKey string `gorm:"size:256" json:"-"`
|
||||||
// ExpiredAt is the account expiry time. Nil means the account never
|
// ExpiredAt is the account expiry time. Nil means the account never
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ type DanmakuRenderConfig struct {
|
|||||||
Volume float64 `json:"volume"`
|
Volume float64 `json:"volume"`
|
||||||
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
|
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
|
||||||
PlaybackRate float64 `json:"playback_rate"`
|
PlaybackRate float64 `json:"playback_rate"`
|
||||||
|
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
|
||||||
|
Vr360GuideSeen bool `json:"vr360_guide_seen"`
|
||||||
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
||||||
MergeSources bool `json:"merge_sources"`
|
MergeSources bool `json:"merge_sources"`
|
||||||
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
|
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
|
||||||
@@ -98,6 +100,8 @@ type DanmakuSettingsPatch struct {
|
|||||||
MergeSources *bool `json:"merge_sources"`
|
MergeSources *bool `json:"merge_sources"`
|
||||||
Volume *float64 `json:"volume"`
|
Volume *float64 `json:"volume"`
|
||||||
PlaybackRate *float64 `json:"playback_rate"`
|
PlaybackRate *float64 `json:"playback_rate"`
|
||||||
|
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
|
||||||
|
Vr360GuideSeen *bool `json:"vr360_guide_seen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type danmakuUserContextKey struct{}
|
type danmakuUserContextKey struct{}
|
||||||
@@ -447,6 +451,7 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
|||||||
Area: strconv.FormatFloat(area, 'f', -1, 64),
|
Area: strconv.FormatFloat(area, 'f', -1, 64),
|
||||||
Volume: volume,
|
Volume: volume,
|
||||||
PlaybackRate: playbackRate,
|
PlaybackRate: playbackRate,
|
||||||
|
Vr360GuideSeen: user.PlayerVr360GuideSeen,
|
||||||
MergeSources: user.DanmakuMergeSources,
|
MergeSources: user.DanmakuMergeSources,
|
||||||
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
|
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
|
||||||
}
|
}
|
||||||
@@ -528,6 +533,9 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
|
|||||||
}
|
}
|
||||||
updates["player_playback_rate"] = *patch.PlaybackRate
|
updates["player_playback_rate"] = *patch.PlaybackRate
|
||||||
}
|
}
|
||||||
|
if patch.Vr360GuideSeen != nil {
|
||||||
|
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
|
||||||
|
}
|
||||||
if len(updates) == 0 {
|
if len(updates) == 0 {
|
||||||
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
|
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -179,9 +178,21 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
|
|||||||
libraryIDs = []string{media.LibraryID}
|
libraryIDs = []string{media.LibraryID}
|
||||||
}
|
}
|
||||||
filter := repository.MediaQueryFilter{IncludeNSFW: true}
|
filter := repository.MediaQueryFilter{IncludeNSFW: true}
|
||||||
candidates, narrowed, err := s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
|
// 成人条目按番号分组,而番号常常只存在于路径/标题里:本地 NFO 来源的分片
|
||||||
if err != nil {
|
// 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部
|
||||||
return nil, err
|
// 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 {
|
if !narrowed {
|
||||||
candidates, err = s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
|
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)
|
s.attachLibraryMetadata(ctx, candidates)
|
||||||
|
// 同番号只要有一个分片被确认为成人内容,尚未刮削的分片也按番号折叠,
|
||||||
|
// 与列表页的分组结果保持一致。
|
||||||
|
vouched := adultCodesVouchedByNSFW(candidates)
|
||||||
|
if vouchKey := adultVouchedGroupKey(*media, vouched); vouchKey != "" {
|
||||||
|
key = vouchKey
|
||||||
|
}
|
||||||
matched := make([]model.Media, 0, 4)
|
matched := make([]model.Media, 0, 4)
|
||||||
for _, row := range candidates {
|
for _, row := range candidates {
|
||||||
if mediaVersionGroupKey(row) == key {
|
if mediaVersionGroupKeyWithAdultVouch(row, vouched) == key {
|
||||||
matched = append(matched, row)
|
matched = append(matched, row)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(matched) == 0 {
|
if len(matched) == 0 {
|
||||||
return []model.Media{*media}, nil
|
return []model.Media{*media}, nil
|
||||||
}
|
}
|
||||||
sort.SliceStable(matched, func(i, j int) bool {
|
sortMediaVersionsForDisplay(matched)
|
||||||
if matched[i].ID == media.ID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if matched[j].ID == media.ID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return betterMediaVersion(matched[i], matched[j])
|
|
||||||
})
|
|
||||||
return matched, nil
|
return matched, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package service
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
@@ -75,35 +77,33 @@ func groupMediaVersions(items []model.Media) []MediaItem {
|
|||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// 同一番号只要有一个分片被刮削标记为成人内容,就认定整部片都是成人条目:
|
||||||
|
// 未刮削的分片(nsfw=false)也按番号折叠。否则「一个分片刮削成功、其余仍
|
||||||
|
// pending」时,已刮削的走番号分组、其余落到标题分组,整部片被拆成两张卡。
|
||||||
|
adultCodes := adultCodesVouchedByNSFW(items)
|
||||||
type group struct {
|
type group struct {
|
||||||
key string
|
key string
|
||||||
primary model.Media
|
rows []model.Media
|
||||||
rows []model.Media
|
|
||||||
}
|
}
|
||||||
groups := make([]group, 0, len(items))
|
groups := make([]group, 0, len(items))
|
||||||
byKey := make(map[string]int, len(items))
|
byKey := make(map[string]int, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
key := mediaVersionGroupKey(item)
|
key := mediaVersionGroupKeyWithAdultVouch(item, adultCodes)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
groups = append(groups, group{primary: item, rows: []model.Media{item}})
|
groups = append(groups, group{rows: []model.Media{item}})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if idx, ok := byKey[key]; ok {
|
if idx, ok := byKey[key]; ok {
|
||||||
groups[idx].rows = append(groups[idx].rows, item)
|
groups[idx].rows = append(groups[idx].rows, item)
|
||||||
if betterMediaVersion(item, groups[idx].primary) {
|
|
||||||
groups[idx].primary = item
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
byKey[key] = len(groups)
|
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))
|
out := make([]MediaItem, 0, len(groups))
|
||||||
for _, g := range groups {
|
for _, g := range groups {
|
||||||
sort.SliceStable(g.rows, func(i, j int) bool {
|
sortMediaVersionsForDisplay(g.rows)
|
||||||
return betterMediaVersion(g.rows[i], g.rows[j])
|
item := MediaItem{Media: g.rows[0]}
|
||||||
})
|
|
||||||
item := MediaItem{Media: g.primary}
|
|
||||||
if len(g.rows) > 1 {
|
if len(g.rows) > 1 {
|
||||||
item.Versions = g.rows
|
item.Versions = g.rows
|
||||||
}
|
}
|
||||||
@@ -139,16 +139,65 @@ func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem {
|
|||||||
return grouped
|
return grouped
|
||||||
}
|
}
|
||||||
|
|
||||||
func mediaVersionGroupKey(m model.Media) string {
|
// mediaVersionLibraryKey 返回版本身份里使用的库标识。
|
||||||
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
func mediaVersionLibraryKey(m model.Media) string {
|
||||||
if IsEmbyRemoteID(m.ID) {
|
|
||||||
return fmt.Sprintf("embyremote:%s", m.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
|
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
|
||||||
if libKey == "" {
|
if libKey == "" {
|
||||||
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
|
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)
|
specialKind := mediaSpecialKind(m.Path)
|
||||||
season, episode := m.SeasonNum, m.EpisodeNum
|
season, episode := m.SeasonNum, m.EpisodeNum
|
||||||
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
|
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
|
||||||
@@ -193,6 +242,18 @@ func mediaVersionGroupKey(m model.Media) string {
|
|||||||
return mediaVersionStemGroupKey(m, libKey)
|
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 {
|
switch {
|
||||||
case m.TMDbID > 0:
|
case m.TMDbID > 0:
|
||||||
if libKey != "" {
|
if libKey != "" {
|
||||||
@@ -267,6 +328,44 @@ func mediaVersionGroupKey(m model.Media) string {
|
|||||||
return fmt.Sprintf("movie:%s:%d", title, year)
|
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 按「库 + 父目录 + 文件词干」折叠多版本
|
// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
|
||||||
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
|
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
|
||||||
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
|
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
|
||||||
@@ -300,8 +399,11 @@ func mediaVersionFileStem(name string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
|
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
|
||||||
|
// 当只有容器等无法区分多版本的信息时,回退到清理后的文件名。
|
||||||
func MediaVersionLabel(m model.Media) string {
|
func MediaVersionLabel(m model.Media) string {
|
||||||
parts := make([]string, 0, 4)
|
parts := make([]string, 0, 4)
|
||||||
|
isStrm := strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") ||
|
||||||
|
strings.TrimSpace(m.STRMURL) != ""
|
||||||
if m.Height > 0 {
|
if m.Height > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("%dp", m.Height))
|
parts = append(parts, fmt.Sprintf("%dp", m.Height))
|
||||||
} else if m.Width > 0 {
|
} else if m.Width > 0 {
|
||||||
@@ -317,17 +419,91 @@ func MediaVersionLabel(m model.Media) string {
|
|||||||
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
|
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
|
||||||
parts = append(parts, strings.ToUpper(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))
|
parts = append(parts, formatMediaSize(m.SizeBytes))
|
||||||
}
|
}
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 && !mediaVersionLabelIndistinct(parts) {
|
||||||
return strings.Join(parts, " · ")
|
return strings.Join(parts, " · ")
|
||||||
}
|
}
|
||||||
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/"))
|
if cleaned := cleanMediaVersionFilename(m.Path); cleaned != "" {
|
||||||
if base == "" || base == "." {
|
return cleaned
|
||||||
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
|
|
||||||
}
|
}
|
||||||
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 {
|
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)
|
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"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -48,9 +49,13 @@ type movieNFO struct {
|
|||||||
Poster string `xml:"thumb,omitempty"`
|
Poster string `xml:"thumb,omitempty"`
|
||||||
Fanart string `xml:"fanart,omitempty"`
|
Fanart string `xml:"fanart,omitempty"`
|
||||||
TMDb int `xml:"tmdbid,omitempty"`
|
TMDb int `xml:"tmdbid,omitempty"`
|
||||||
Genre []string `xml:"genre,omitempty"`
|
// UniqueIDs 让 sidecar 能把 MeBox 自己用的外部 ID 原样带回来。缺了它,
|
||||||
Country []string `xml:"country,omitempty"`
|
// 「在线刮削 → 写 NFO → 重扫读 NFO」会丢掉 douban/thetvdb(成人条目借用
|
||||||
Language []string `xml:"language,omitempty"`
|
// 这两个字段存番号/provider),同一部片的两条来源就会算出不同的版本键。
|
||||||
|
UniqueIDs []nfoUniqueID `xml:"uniqueid,omitempty"`
|
||||||
|
Genre []string `xml:"genre,omitempty"`
|
||||||
|
Country []string `xml:"country,omitempty"`
|
||||||
|
Language []string `xml:"language,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type episodeNFO struct {
|
type episodeNFO struct {
|
||||||
@@ -167,17 +172,18 @@ func WriteMediaNFO(m *model.Media) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
doc = movieNFO{
|
doc = movieNFO{
|
||||||
Title: title,
|
Title: title,
|
||||||
Original: original,
|
Original: original,
|
||||||
Year: m.Year,
|
Year: m.Year,
|
||||||
Plot: m.Overview,
|
Plot: m.Overview,
|
||||||
Rating: m.Rating,
|
Rating: m.Rating,
|
||||||
Poster: m.PosterURL,
|
Poster: m.PosterURL,
|
||||||
Fanart: m.BackdropURL,
|
Fanart: m.BackdropURL,
|
||||||
TMDb: m.TMDbID,
|
TMDb: m.TMDbID,
|
||||||
Genre: splitNFOList(m.Genres),
|
UniqueIDs: nfoExternalUniqueIDs(m),
|
||||||
Country: splitNFOList(m.Countries),
|
Genre: splitNFOList(m.Genres),
|
||||||
Language: splitNFOList(m.Languages),
|
Country: splitNFOList(m.Countries),
|
||||||
|
Language: splitNFOList(m.Languages),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out, err := xml.MarshalIndent(doc, "", " ")
|
out, err := xml.MarshalIndent(doc, "", " ")
|
||||||
@@ -202,3 +208,27 @@ func splitNFOList(value string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
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)
|
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
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrStrmProxyNotApplicable 表示该媒体没有需要服务端转发的直链(本地文件本身同源)。
|
||||||
|
var ErrStrmProxyNotApplicable = errors.New("strm proxy not applicable")
|
||||||
|
|
||||||
|
// ProxyMediaDirect 把媒体行的网盘/STRM 直链解析为真实地址后由服务端反向代理给
|
||||||
|
// 客户端,让浏览器拿到「同源」数据。画质与原文件完全一致,不触发任何转码。
|
||||||
|
//
|
||||||
|
// 用途:VR 全景播放要把视频帧读进 WebGL 纹理,而跨域直链(网盘 302 跳到 CDN)
|
||||||
|
// 在浏览器里属于被污染的资源,WebGL 读取会抛 SecurityError;把流量经服务端转发
|
||||||
|
// 是「原画 + VR」唯一可行的办法。
|
||||||
|
func (s *StrmService) ProxyMediaDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, m *model.Media) error {
|
||||||
|
if s == nil || m == nil {
|
||||||
|
return ErrStrmProxyNotApplicable
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(m.STRMURL)
|
||||||
|
if raw == "" {
|
||||||
|
path := strings.TrimSpace(m.Path)
|
||||||
|
if !strings.HasSuffix(strings.ToLower(path), ".strm") {
|
||||||
|
return ErrStrmProxyNotApplicable
|
||||||
|
}
|
||||||
|
target, err := readLocalSTRMTarget(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw = strings.TrimSpace(target)
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
return ErrStrmProxyNotApplicable
|
||||||
|
}
|
||||||
|
result, err := s.ResolvePlayTarget(ctx, raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case result.Link != nil && result.Link.URL != "":
|
||||||
|
return s.ProxyDirect(ctx, w, r, result.Link)
|
||||||
|
case result.RedirectURL != "":
|
||||||
|
return s.ProxyDirect(ctx, w, r, &cloud.DirectLink{URL: result.RedirectURL})
|
||||||
|
default:
|
||||||
|
// 本地文件(LocalPath)由静态文件处理器提供,本身就是同源。
|
||||||
|
return ErrStrmProxyNotApplicable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
|
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
|
||||||
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
||||||
if link == nil || link.URL == "" {
|
if link == nil || link.URL == "" {
|
||||||
return errors.New("空直链")
|
return errors.New("空直链")
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil)
|
method := http.MethodGet
|
||||||
|
if r != nil && r.Method == http.MethodHead {
|
||||||
|
method = http.MethodHead
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, link.URL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for k, v := range link.Headers {
|
for k, v := range link.Headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
if r != nil {
|
||||||
req.Header.Set("Range", rangeHeader)
|
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
||||||
|
req.Header.Set("Range", rangeHeader)
|
||||||
|
}
|
||||||
|
// 部分网盘直链按 UA 防盗链;解析时未绑定 UA 的直链沿用浏览器 UA 更稳。
|
||||||
|
if ua := strings.TrimSpace(r.Header.Get("User-Agent")); ua != "" && req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", ua)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
resp, err := s.http.Do(req)
|
resp, err := s.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag"} {
|
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
|
||||||
if value := resp.Header.Get(header); value != "" {
|
if value := resp.Header.Get(header); value != "" {
|
||||||
w.Header().Set(header, value)
|
w.Header().Set(header, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
// 原样透传上游状态码:Range 请求必须回 206,改写成 200 会让浏览器误判
|
||||||
w.WriteHeader(http.StatusOK)
|
// 响应长度,拖动进度条时反复重新拉流。
|
||||||
} else {
|
w.WriteHeader(resp.StatusCode)
|
||||||
w.WriteHeader(resp.StatusCode)
|
if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if resp.StatusCode == http.StatusPartialContent || resp.StatusCode == http.StatusOK {
|
if method == http.MethodHead {
|
||||||
_, _ = io.Copy(w, resp.Body)
|
return nil
|
||||||
}
|
}
|
||||||
return nil
|
// 边转发边 flush,避免大体积视频被 net/http 的写缓冲切成一段段卡顿。
|
||||||
|
writer := io.Writer(w)
|
||||||
|
if flusher, ok := w.(http.Flusher); ok {
|
||||||
|
writer = &flushWriter{writer: w, flusher: flusher}
|
||||||
|
}
|
||||||
|
_, err = io.Copy(writer, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type flushWriter struct {
|
||||||
|
writer io.Writer
|
||||||
|
flusher http.Flusher
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *flushWriter) Write(p []byte) (int, error) {
|
||||||
|
n, err := f.writer.Write(p)
|
||||||
|
if f.flusher != nil {
|
||||||
|
f.flusher.Flush()
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 同源转发必须原样保留 Range 语义:把 206 改写成 200 会让浏览器误判响应长度,
|
||||||
|
// 拖动进度条时反复重新拉流。
|
||||||
|
func TestProxyMediaDirectForwardsRangeAndStatus(t *testing.T) {
|
||||||
|
var gotRange string
|
||||||
|
var gotUA string
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotRange = r.Header.Get("Range")
|
||||||
|
gotUA = r.Header.Get("User-Agent")
|
||||||
|
w.Header().Set("Content-Type", "video/mp4")
|
||||||
|
w.Header().Set("Accept-Ranges", "bytes")
|
||||||
|
w.Header().Set("Content-Range", "bytes 10-19/100")
|
||||||
|
w.WriteHeader(http.StatusPartialContent)
|
||||||
|
_, _ = w.Write([]byte("0123456789"))
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
svc := testStrmService(t)
|
||||||
|
media := &model.Media{STRMURL: upstream.URL + "/video.mp4"}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||||
|
req.Header.Set("Range", "bytes=10-19")
|
||||||
|
req.Header.Set("User-Agent", "MeBoxTest/1.0")
|
||||||
|
|
||||||
|
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
|
||||||
|
t.Fatalf("ProxyMediaDirect: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusPartialContent {
|
||||||
|
t.Fatalf("status = %d, want 206", rec.Code)
|
||||||
|
}
|
||||||
|
if gotRange != "bytes=10-19" {
|
||||||
|
t.Fatalf("upstream Range = %q, want bytes=10-19", gotRange)
|
||||||
|
}
|
||||||
|
if gotUA != "MeBoxTest/1.0" {
|
||||||
|
t.Fatalf("upstream User-Agent = %q, want MeBoxTest/1.0", gotUA)
|
||||||
|
}
|
||||||
|
if rec.Header().Get("Content-Range") != "bytes 10-19/100" {
|
||||||
|
t.Fatalf("Content-Range = %q", rec.Header().Get("Content-Range"))
|
||||||
|
}
|
||||||
|
if rec.Body.String() != "0123456789" {
|
||||||
|
t.Fatalf("body = %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地文件本身就是同源资源,不应该被代理(调用方按原静态文件逻辑处理)。
|
||||||
|
func TestProxyMediaDirectSkipsLocalFile(t *testing.T) {
|
||||||
|
svc := testStrmService(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "movie.mkv")
|
||||||
|
writeFile(t, path, "not-a-video")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||||
|
err := svc.ProxyMediaDirect(context.Background(), rec, req, &model.Media{Path: path})
|
||||||
|
if !errors.Is(err, ErrStrmProxyNotApplicable) {
|
||||||
|
t.Fatalf("err = %v, want ErrStrmProxyNotApplicable", err)
|
||||||
|
}
|
||||||
|
if rec.Body.Len() != 0 {
|
||||||
|
t.Fatalf("local file must not be proxied, body = %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,8 +141,13 @@ const profileQuery = () => {
|
|||||||
// streamURL returns a direct-play URL for <video src>. The JWT is added as
|
// streamURL returns a direct-play URL for <video src>. The JWT is added as
|
||||||
// a query parameter because <video> elements cannot send Authorization
|
// a query parameter because <video> elements cannot send Authorization
|
||||||
// headers.
|
// headers.
|
||||||
export function streamURL(mediaId: string): string {
|
//
|
||||||
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}`
|
// proxy=true 时由服务端把网盘/STRM 直链转发为同源数据(画质与原文件一致、
|
||||||
|
// 不转码)。VR 全景渲染需要把视频帧读进 WebGL 纹理,跨域直链会被浏览器
|
||||||
|
// 判定为污染资源而禁止读取,因此只有这种场景才需要开启。
|
||||||
|
export function streamURL(mediaId: string, options: { proxy?: boolean } = {}): string {
|
||||||
|
const proxy = options.proxy ? '&proxy=1' : ''
|
||||||
|
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}${proxy}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export interface DanmakuConfig {
|
|||||||
area: string
|
area: string
|
||||||
volume: number
|
volume: number
|
||||||
playback_rate: number
|
playback_rate: number
|
||||||
|
/** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */
|
||||||
|
vr360_guide_seen: boolean
|
||||||
/** Per-user preference: merge the same episode's multiple sources. */
|
/** Per-user preference: merge the same episode's multiple sources. */
|
||||||
merge_sources: boolean
|
merge_sources: boolean
|
||||||
}
|
}
|
||||||
@@ -89,6 +91,8 @@ export interface DanmakuSettingsPatch {
|
|||||||
merge_sources?: boolean
|
merge_sources?: boolean
|
||||||
volume?: number
|
volume?: number
|
||||||
playback_rate?: number
|
playback_rate?: number
|
||||||
|
/** 看过 VR 操作说明后置为 true,之后不再弹出。 */
|
||||||
|
vr360_guide_seen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
|
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { ArrowDown, ArrowUp, ArrowUpDown, Check } from 'lucide-react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
LIBRARY_LIST_SORT_OPTIONS,
|
||||||
|
type LibraryListSortField,
|
||||||
|
type LibraryListSortOption,
|
||||||
|
type LibraryListSortOrder,
|
||||||
|
} from '../utils/libraryListSort'
|
||||||
|
|
||||||
|
type LibraryListSortDropdownProps = {
|
||||||
|
value: LibraryListSortField
|
||||||
|
order: LibraryListSortOrder
|
||||||
|
onChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryListSortDropdown({
|
||||||
|
value,
|
||||||
|
order,
|
||||||
|
onChange,
|
||||||
|
className = '',
|
||||||
|
}: LibraryListSortDropdownProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const currentOption = LIBRARY_LIST_SORT_OPTIONS.find((opt) => opt.id === value) ?? LIBRARY_LIST_SORT_OPTIONS[0]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleSelect = (option: LibraryListSortOption) => {
|
||||||
|
if (value === option.id) {
|
||||||
|
onChange(option.id, order === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
onChange(option.id, option.defaultOrder)
|
||||||
|
}
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative inline-block text-left ${className}`} ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
className="inline-flex h-9 items-center gap-1.5 rounded-xl border border-sand-200 bg-white/90 px-3 py-1.5 text-xs font-semibold text-ink-600 shadow-sm transition-all hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700 sm:h-10 sm:text-sm"
|
||||||
|
title="更改媒体库列表排序"
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={14} className="text-sand-500" />
|
||||||
|
<span>排序: {currentOption.label}</span>
|
||||||
|
{order === 'asc' ? (
|
||||||
|
<ArrowUp size={13} className="font-bold text-brand-600" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown size={13} className="font-bold text-brand-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute left-0 z-50 mt-1.5 w-44 max-w-[calc(100vw-2rem)] origin-top-left rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95 sm:left-auto sm:right-0 sm:origin-top-right">
|
||||||
|
<div className="border-b border-sand-100 px-2.5 py-1.5 text-[11px] font-bold text-sand-500">
|
||||||
|
排序方式
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5 py-1">
|
||||||
|
{LIBRARY_LIST_SORT_OPTIONS.map((option) => {
|
||||||
|
const isSelected = value === option.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
|
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs font-medium transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-brand-50 font-semibold text-brand-700'
|
||||||
|
: 'text-ink-600 hover:bg-sand-50 hover:text-brand-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isSelected && <Check size={13} className="text-brand-600" />}
|
||||||
|
<span className={isSelected ? '' : 'pl-5'}>{option.label}</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="flex items-center text-brand-600">
|
||||||
|
{order === 'asc' ? (
|
||||||
|
<ArrowUp size={14} className="stroke-[2.5]" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown size={14} className="stroke-[2.5]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type { LibraryTagTab } from '../utils/libraryTags'
|
|||||||
/**
|
/**
|
||||||
* 媒体库标签栏:固定「全部」+ 每个标签一个页签,选中后只展示该标签下的媒体库。
|
* 媒体库标签栏:固定「全部」+ 每个标签一个页签,选中后只展示该标签下的媒体库。
|
||||||
* 只是管理员/普通用户都能用的展示层过滤,不改动媒体库本身。
|
* 只是管理员/普通用户都能用的展示层过滤,不改动媒体库本身。
|
||||||
|
* 标签顺序由「管理标签」里的拖拽决定,这里只负责展示与选中。
|
||||||
*/
|
*/
|
||||||
export function LibraryTagBar({
|
export function LibraryTagBar({
|
||||||
tabs,
|
tabs,
|
||||||
@@ -151,7 +152,7 @@ export function LibraryTagBar({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onManage}
|
onClick={onManage}
|
||||||
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500"
|
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500"
|
||||||
title="重命名 / 删除标签,调整标签下的媒体库"
|
title="重命名 / 删除标签,调整标签下的媒体库,拖拽排序"
|
||||||
>
|
>
|
||||||
<Settings2 size={13} />
|
<Settings2 size={13} />
|
||||||
<span>{manageLabel}</span>
|
<span>{manageLabel}</span>
|
||||||
@@ -161,4 +162,4 @@ export function LibraryTagBar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Check, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react'
|
import { Check, GripVertical, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
import { confirmAction } from './confirmAction'
|
import { confirmAction } from './confirmAction'
|
||||||
import type { Library } from '../types'
|
import type { Library } from '../types'
|
||||||
|
import { useDragReorder } from '../hooks/useDragReorder'
|
||||||
import {
|
import {
|
||||||
MAX_LIBRARY_TAGS,
|
MAX_LIBRARY_TAGS,
|
||||||
filterLibrariesForTagging,
|
filterLibrariesForTagging,
|
||||||
@@ -19,6 +20,8 @@ export type ManageLibraryTagsDialogProps = {
|
|||||||
onCreate: (name: string) => Promise<string>
|
onCreate: (name: string) => Promise<string>
|
||||||
onRename: (from: string, to: string) => Promise<void>
|
onRename: (from: string, to: string) => Promise<void>
|
||||||
onRemove: (name: string) => Promise<void>
|
onRemove: (name: string) => Promise<void>
|
||||||
|
/** 按给定标签名顺序重排标签(拖拽排序的落点)。 */
|
||||||
|
onReorder: (orderedNames: string[]) => Promise<void>
|
||||||
/** 单个媒体库归类;tagName 为空表示移出所有标签。 */
|
/** 单个媒体库归类;tagName 为空表示移出所有标签。 */
|
||||||
onAssign: (libraryId: string, tagName: string) => Promise<void>
|
onAssign: (libraryId: string, tagName: string) => Promise<void>
|
||||||
/** 批量归类;tagName 为空表示批量移出所有标签。一次写入。 */
|
/** 批量归类;tagName 为空表示批量移出所有标签。一次写入。 */
|
||||||
@@ -42,6 +45,7 @@ export function ManageLibraryTagsDialogView({
|
|||||||
onCreate,
|
onCreate,
|
||||||
onRename,
|
onRename,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onReorder,
|
||||||
onAssign,
|
onAssign,
|
||||||
onAssignBatch,
|
onAssignBatch,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -55,6 +59,10 @@ export function ManageLibraryTagsDialogView({
|
|||||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||||
const [batchTag, setBatchTag] = useState('')
|
const [batchTag, setBatchTag] = useState('')
|
||||||
const [applying, setApplying] = useState(false)
|
const [applying, setApplying] = useState(false)
|
||||||
|
const { draggingId, dragOverId, dragProps } = useDragReorder(
|
||||||
|
tags.map((tag) => tag.name),
|
||||||
|
onReorder,
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -254,86 +262,110 @@ export function ManageLibraryTagsDialogView({
|
|||||||
还没有标签。先创建一个,再把媒体库归入其中。
|
还没有标签。先创建一个,再把媒体库归入其中。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
tags.map((tag) => (
|
tags.map((tag) => {
|
||||||
<div
|
const drag = dragProps(tag.name)
|
||||||
key={tag.name}
|
const isDragOver = dragOverId === tag.name && draggingId !== tag.name
|
||||||
className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2"
|
return (
|
||||||
>
|
<div
|
||||||
{renaming === tag.name ? (
|
key={tag.name}
|
||||||
<>
|
onDragOver={drag.onDragOver}
|
||||||
<input
|
onDragLeave={drag.onDragLeave}
|
||||||
autoFocus
|
onDrop={drag.onDrop}
|
||||||
value={renameDraft}
|
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
|
||||||
onChange={(event) => setRenameDraft(event.target.value)}
|
isDragOver
|
||||||
onKeyDown={(event) => {
|
? 'border-brand-500/60 bg-brand-500/5'
|
||||||
if (event.key === 'Enter') {
|
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
|
||||||
event.preventDefault()
|
}`}
|
||||||
void handleRename(tag.name)
|
>
|
||||||
}
|
{renaming === tag.name ? (
|
||||||
if (event.key === 'Escape') {
|
<>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={renameDraft}
|
||||||
|
onChange={(event) => setRenameDraft(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
void handleRename(tag.name)
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setRenaming('')
|
||||||
|
setRenameDraft('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
maxLength={24}
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleRename(tag.name)}
|
||||||
|
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)]"
|
||||||
|
title="保存名称"
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
setRenaming('')
|
setRenaming('')
|
||||||
setRenameDraft('')
|
setRenameDraft('')
|
||||||
}
|
}}
|
||||||
}}
|
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
|
||||||
maxLength={24}
|
title="取消"
|
||||||
className="min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500"
|
>
|
||||||
/>
|
<X size={14} />
|
||||||
<button
|
</button>
|
||||||
type="button"
|
</>
|
||||||
onClick={() => void handleRename(tag.name)}
|
) : (
|
||||||
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)]"
|
<>
|
||||||
title="保存名称"
|
<button
|
||||||
>
|
type="button"
|
||||||
<Check size={14} />
|
draggable={drag.draggable}
|
||||||
</button>
|
onDragStart={drag.onDragStart}
|
||||||
<button
|
onDragEnd={drag.onDragEnd}
|
||||||
type="button"
|
className={`-ml-1 shrink-0 cursor-grab rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] ${
|
||||||
onClick={() => {
|
draggingId === tag.name ? 'opacity-40' : ''
|
||||||
setRenaming('')
|
}`}
|
||||||
setRenameDraft('')
|
title="拖拽调整标签顺序"
|
||||||
}}
|
aria-label="拖拽调整标签顺序"
|
||||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
|
>
|
||||||
title="取消"
|
<GripVertical size={13} />
|
||||||
>
|
</button>
|
||||||
<X size={14} />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</>
|
onClick={() => setTagFilter(tag.name)}
|
||||||
) : (
|
className="min-w-0 flex-1 truncate text-left text-sm font-bold text-[var(--app-text)] transition-colors hover:text-brand-500"
|
||||||
<>
|
title={`只看「${tag.name}」标签下的媒体库`}
|
||||||
<button
|
>
|
||||||
type="button"
|
{tag.name}
|
||||||
onClick={() => setTagFilter(tag.name)}
|
</button>
|
||||||
className="min-w-0 flex-1 truncate text-left text-sm font-bold text-[var(--app-text)] transition-colors hover:text-brand-500"
|
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
|
||||||
title={`只看「${tag.name}」标签下的媒体库`}
|
{tag.library_ids.length}
|
||||||
>
|
</span>
|
||||||
{tag.name}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
|
onClick={() => {
|
||||||
{tag.library_ids.length}
|
setRenaming(tag.name)
|
||||||
</span>
|
setRenameDraft(tag.name)
|
||||||
<button
|
}}
|
||||||
type="button"
|
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500"
|
||||||
onClick={() => {
|
title="重命名"
|
||||||
setRenaming(tag.name)
|
>
|
||||||
setRenameDraft(tag.name)
|
<Pencil size={13} />
|
||||||
}}
|
</button>
|
||||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500"
|
<button
|
||||||
title="重命名"
|
type="button"
|
||||||
>
|
onClick={() => void handleRemove(tag)}
|
||||||
<Pencil size={13} />
|
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500"
|
||||||
</button>
|
title="删除标签"
|
||||||
<button
|
>
|
||||||
type="button"
|
<Trash2 size={13} />
|
||||||
onClick={() => void handleRemove(tag)}
|
</button>
|
||||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500"
|
</>
|
||||||
title="删除标签"
|
)}
|
||||||
>
|
</div>
|
||||||
<Trash2 size={13} />
|
)
|
||||||
</button>
|
})
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
PictureInPicture,
|
PictureInPicture,
|
||||||
Play,
|
Play,
|
||||||
Rewind,
|
Rewind,
|
||||||
|
Rotate3d,
|
||||||
SkipBack,
|
SkipBack,
|
||||||
SkipForward,
|
SkipForward,
|
||||||
Timer,
|
Timer,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { SubtitleTrack } from '../api/subtitles'
|
import type { SubtitleTrack } from '../api/subtitles'
|
||||||
import type { PlaybackQuality } from '../types'
|
import type { PlaybackQuality } from '../types'
|
||||||
|
import type { Vr360Profile } from '../utils/vr360'
|
||||||
import {
|
import {
|
||||||
formatPlaybackRate,
|
formatPlaybackRate,
|
||||||
normalizePlaybackRate,
|
normalizePlaybackRate,
|
||||||
@@ -42,6 +44,10 @@ import {
|
|||||||
// controls> (which cannot host custom buttons). The danmaku toggle sits right
|
// controls> (which cannot host custom buttons). The danmaku toggle sits right
|
||||||
// next to the volume control. The bar auto-hides while playing and reappears
|
// 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.
|
// on mouse movement; it stays visible while paused or when hovering/interacting.
|
||||||
|
//
|
||||||
|
// VR 全景播放里这条规则要反过来:按住拖动就是转动视角,属于观看动作而不是
|
||||||
|
// 操作意图,所以这类鼠标/触摸移动既不唤出控制栏,还会在开始转视角时立刻把
|
||||||
|
// 浮层收掉(见 onVrPointerDown / onVrPointerMove)。
|
||||||
|
|
||||||
function formatTime(s: number): string {
|
function formatTime(s: number): string {
|
||||||
if (!Number.isFinite(s) || s < 0) s = 0
|
if (!Number.isFinite(s) || s < 0) s = 0
|
||||||
@@ -56,6 +62,12 @@ const SEEK_APPLY_MS = 220
|
|||||||
const SEEK_HINT_MS = 700
|
const SEEK_HINT_MS = 700
|
||||||
const PLAYBACK_RATE_REPEAT_MS = 140
|
const PLAYBACK_RATE_REPEAT_MS = 140
|
||||||
const PLAYBACK_RATE_HINT_MS = 900
|
const PLAYBACK_RATE_HINT_MS = 900
|
||||||
|
/** 播放中控制栏无操作自动隐藏的延时。 */
|
||||||
|
const CONTROLS_HIDE_DELAY_MS = 3000
|
||||||
|
/** VR 全景播放的隐藏延时更短:浮层由单击唤出,收得越快越不打扰观看。 */
|
||||||
|
const VR_CONTROLS_HIDE_DELAY_MS = 2000
|
||||||
|
/** VR 视角拖拽超过这个距离才算「真的在转动视角」,轻触不受影响。 */
|
||||||
|
const VR_VIEW_DRAG_DISTANCE = 6
|
||||||
|
|
||||||
type SeekHint = {
|
type SeekHint = {
|
||||||
dir: 'back' | 'forward'
|
dir: 'back' | 'forward'
|
||||||
@@ -90,6 +102,8 @@ type PlayerControlsProps = {
|
|||||||
onPlaybackRateCommit?: (rate: number) => void
|
onPlaybackRateCommit?: (rate: number) => void
|
||||||
uiVisible: boolean
|
uiVisible: boolean
|
||||||
onUiVisibleChange: (visible: boolean) => void
|
onUiVisibleChange: (visible: boolean) => void
|
||||||
|
/** 外部浮层(VR 工具条/设置面板)正被鼠标悬停:此时控制栏不自动隐藏。 */
|
||||||
|
uiHold?: boolean
|
||||||
subs: SubtitleTrack[]
|
subs: SubtitleTrack[]
|
||||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||||
subtitleIndex: number
|
subtitleIndex: number
|
||||||
@@ -116,6 +130,11 @@ type PlayerControlsProps = {
|
|||||||
selectedQuality?: string
|
selectedQuality?: string
|
||||||
onSelectQuality?: (quality: PlaybackQuality) => void
|
onSelectQuality?: (quality: PlaybackQuality) => void
|
||||||
showQuality?: boolean
|
showQuality?: boolean
|
||||||
|
/** VR 全景播放配置;非 null 表示当前处于 VR 模式。 */
|
||||||
|
vr360?: Vr360Profile | null
|
||||||
|
/** 是否由文件名/画幅自动识别为 VR 素材(按钮上加一个小圆点提示)。 */
|
||||||
|
vr360Detected?: boolean
|
||||||
|
onToggleVr360?: () => void
|
||||||
/** Media metadata duration (seconds). Used when HLS only knows transcoded length. */
|
/** Media metadata duration (seconds). Used when HLS only knows transcoded length. */
|
||||||
knownDuration?: number
|
knownDuration?: number
|
||||||
/** Absolute source offset of the current HLS session (seconds). */
|
/** Absolute source offset of the current HLS session (seconds). */
|
||||||
@@ -134,6 +153,7 @@ export function PlayerControls({
|
|||||||
onPlaybackRateCommit,
|
onPlaybackRateCommit,
|
||||||
uiVisible,
|
uiVisible,
|
||||||
onUiVisibleChange,
|
onUiVisibleChange,
|
||||||
|
uiHold = false,
|
||||||
subs,
|
subs,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
onSelectSubtitle,
|
onSelectSubtitle,
|
||||||
@@ -159,6 +179,9 @@ export function PlayerControls({
|
|||||||
selectedQuality = '',
|
selectedQuality = '',
|
||||||
onSelectQuality,
|
onSelectQuality,
|
||||||
showQuality = false,
|
showQuality = false,
|
||||||
|
vr360 = null,
|
||||||
|
vr360Detected = false,
|
||||||
|
onToggleVr360,
|
||||||
knownDuration = 0,
|
knownDuration = 0,
|
||||||
streamOffset = 0,
|
streamOffset = 0,
|
||||||
onSeekAbsolute,
|
onSeekAbsolute,
|
||||||
@@ -186,13 +209,13 @@ export function PlayerControls({
|
|||||||
const [speedMenuOpen, setSpeedMenuOpen] = useState(false)
|
const [speedMenuOpen, setSpeedMenuOpen] = useState(false)
|
||||||
const speedMenuRef = useRef<HTMLDivElement | null>(null)
|
const speedMenuRef = useRef<HTMLDivElement | null>(null)
|
||||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const controlsHoveredRef = useRef(false)
|
|
||||||
const isScrubbingRef = useRef(false)
|
const isScrubbingRef = useRef(false)
|
||||||
const subtitleMenuOpenRef = useRef(false)
|
/** 控制栏当前是否处于「必须保持显示」的状态(悬停/拖进度/菜单或面板打开)。 */
|
||||||
const qualityMenuOpenRef = useRef(false)
|
const holdControlsRef = useRef(false)
|
||||||
const speedMenuOpenRef = useRef(false)
|
/** 当前是否在 VR 全景播放中。 */
|
||||||
const danmakuOpenRef = useRef(false)
|
const vr360ActiveRef = useRef(Boolean(vr360))
|
||||||
const playlistOpenRef = useRef(false)
|
/** VR 画布上的按下状态:按住拖动 = 转动视角,不唤出控制栏。 */
|
||||||
|
const vrDragRef = useRef<{ x: number; y: number; moved: boolean } | null>(null)
|
||||||
const pendingSeekRef = useRef<number | null>(null)
|
const pendingSeekRef = useRef<number | null>(null)
|
||||||
const applySeekRef = useRef<(absolute: number) => void>(() => undefined)
|
const applySeekRef = useRef<(absolute: number) => void>(() => undefined)
|
||||||
const durationRef = useRef(0)
|
const durationRef = useRef(0)
|
||||||
@@ -207,33 +230,28 @@ export function PlayerControls({
|
|||||||
const [playbackRateHint, setPlaybackRateHint] = useState<number | null>(null)
|
const [playbackRateHint, setPlaybackRateHint] = useState<number | null>(null)
|
||||||
const [stageEl, setStageEl] = useState<HTMLElement | null>(null)
|
const [stageEl, setStageEl] = useState<HTMLElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
controlsHoveredRef.current = controlsHovered
|
|
||||||
}, [controlsHovered])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isScrubbingRef.current = isScrubbing
|
isScrubbingRef.current = isScrubbing
|
||||||
}, [isScrubbing])
|
}, [isScrubbing])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
subtitleMenuOpenRef.current = subtitleMenuOpen
|
vr360ActiveRef.current = Boolean(vr360)
|
||||||
}, [subtitleMenuOpen])
|
}, [vr360])
|
||||||
|
|
||||||
|
// 「保持显示」条件集中在这里:悬停控制栏、拖动进度条、打开任何菜单或面板。
|
||||||
|
// 事件回调里读 ref,渲染里读派生值,两边始终一致。
|
||||||
|
const keepControlsVisible =
|
||||||
|
uiHold ||
|
||||||
|
controlsHovered ||
|
||||||
|
isScrubbing ||
|
||||||
|
subtitleMenuOpen ||
|
||||||
|
qualityMenuOpen ||
|
||||||
|
speedMenuOpen ||
|
||||||
|
danmakuOpen ||
|
||||||
|
playlistOpen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
qualityMenuOpenRef.current = qualityMenuOpen
|
holdControlsRef.current = keepControlsVisible
|
||||||
}, [qualityMenuOpen])
|
}, [keepControlsVisible])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
speedMenuOpenRef.current = speedMenuOpen
|
|
||||||
}, [speedMenuOpen])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
danmakuOpenRef.current = danmakuOpen
|
|
||||||
}, [danmakuOpen])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
playlistOpenRef.current = playlistOpen
|
|
||||||
}, [playlistOpen])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
durationRef.current = duration
|
durationRef.current = duration
|
||||||
@@ -293,45 +311,64 @@ export function PlayerControls({
|
|||||||
const stage = container()
|
const stage = container()
|
||||||
if (!el || !stage) return
|
if (!el || !stage) return
|
||||||
|
|
||||||
|
const holdControls = () => holdControlsRef.current
|
||||||
|
|
||||||
|
const hideDelay = () =>
|
||||||
|
vr360ActiveRef.current ? VR_CONTROLS_HIDE_DELAY_MS : CONTROLS_HIDE_DELAY_MS
|
||||||
|
|
||||||
const resetTimer = () => {
|
const resetTimer = () => {
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||||
if (
|
if (el.paused || holdControls()) return
|
||||||
!el.paused &&
|
hideTimerRef.current = setTimeout(() => {
|
||||||
!controlsHoveredRef.current &&
|
if (holdControls()) return
|
||||||
!isScrubbingRef.current &&
|
onUiVisibleChange(false)
|
||||||
!subtitleMenuOpenRef.current &&
|
}, hideDelay())
|
||||||
!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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 通用「用户活动」入口:显示控制栏并重新计时。播放/暂停等事件也走这里。
|
||||||
const onMove = () => {
|
const onMove = () => {
|
||||||
onUiVisibleChange(true)
|
onUiVisibleChange(true)
|
||||||
resetTimer()
|
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) => {
|
const onLeave = (e: MouseEvent) => {
|
||||||
|
endVrDrag()
|
||||||
// 仅当光标真正移出 stage 容器时才处理
|
// 仅当光标真正移出 stage 容器时才处理
|
||||||
if (e.relatedTarget && stage.contains(e.relatedTarget as Node)) {
|
if (e.relatedTarget && stage.contains(e.relatedTarget as Node)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (el.paused || controlsHoveredRef.current || isScrubbingRef.current || playlistOpenRef.current) return
|
if (el.paused || holdControls()) return
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||||
onUiVisibleChange(false)
|
onUiVisibleChange(false)
|
||||||
}
|
}
|
||||||
@@ -371,8 +408,14 @@ export function PlayerControls({
|
|||||||
const syncFullscreen = () => setFullscreen(Boolean(document.fullscreenElement))
|
const syncFullscreen = () => setFullscreen(Boolean(document.fullscreenElement))
|
||||||
const syncPip = () => setPip(document.pictureInPictureElement === el)
|
const syncPip = () => setPip(document.pictureInPictureElement === el)
|
||||||
|
|
||||||
stage.addEventListener('mousemove', onMove)
|
stage.addEventListener('mousemove', onStageMouseMove)
|
||||||
stage.addEventListener('mouseleave', onLeave)
|
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('play', syncPlay)
|
||||||
el.addEventListener('playing', syncPlay)
|
el.addEventListener('playing', syncPlay)
|
||||||
@@ -389,8 +432,12 @@ export function PlayerControls({
|
|||||||
syncVolume()
|
syncVolume()
|
||||||
syncFullscreen()
|
syncFullscreen()
|
||||||
return () => {
|
return () => {
|
||||||
stage.removeEventListener('mousemove', onMove)
|
stage.removeEventListener('mousemove', onStageMouseMove)
|
||||||
stage.removeEventListener('mouseleave', onLeave)
|
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('play', syncPlay)
|
||||||
el.removeEventListener('playing', syncPlay)
|
el.removeEventListener('playing', syncPlay)
|
||||||
el.removeEventListener('pause', syncPlay)
|
el.removeEventListener('pause', syncPlay)
|
||||||
@@ -412,19 +459,23 @@ export function PlayerControls({
|
|||||||
setDuration(Math.max(knownDuration || 0, streamOffset + streamDur))
|
setDuration(Math.max(knownDuration || 0, streamOffset + streamDur))
|
||||||
}, [knownDuration, streamOffset, video])
|
}, [knownDuration, streamOffset, video])
|
||||||
|
|
||||||
// 当悬停或菜单状态改变时,更新控制栏计时器
|
// 控制栏的自动隐藏计时器:只要控制栏处于显示状态,就始终安排一次隐藏。
|
||||||
|
// 之前只在「悬停/菜单状态变化」时才安排,用轻触或点击唤出控制栏后如果没有
|
||||||
|
// 鼠标移动,计时器永远不会被安排,控制栏就会一直留在画面上。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (controlsHovered || isScrubbing || subtitleMenuOpen || qualityMenuOpen || speedMenuOpen || danmakuOpen || playlistOpen) {
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||||
|
if (keepControlsVisible) {
|
||||||
onUiVisibleChange(true)
|
onUiVisibleChange(true)
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
return
|
||||||
} else {
|
|
||||||
const el = video()
|
|
||||||
if (el && !el.paused) {
|
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
|
||||||
hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [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, onUiVisibleChange, video])
|
||||||
|
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
const el = video()
|
const el = video()
|
||||||
@@ -506,30 +557,13 @@ export function PlayerControls({
|
|||||||
onUiVisibleChange(true)
|
onUiVisibleChange(true)
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||||
const el = video()
|
const el = video()
|
||||||
if (
|
if (!el || el.paused || holdControlsRef.current) {
|
||||||
!el ||
|
|
||||||
el.paused ||
|
|
||||||
controlsHoveredRef.current ||
|
|
||||||
isScrubbingRef.current ||
|
|
||||||
subtitleMenuOpenRef.current ||
|
|
||||||
qualityMenuOpenRef.current ||
|
|
||||||
danmakuOpenRef.current ||
|
|
||||||
playlistOpenRef.current
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hideTimerRef.current = setTimeout(() => {
|
hideTimerRef.current = setTimeout(() => {
|
||||||
if (
|
if (holdControlsRef.current) return
|
||||||
!controlsHoveredRef.current &&
|
onUiVisibleChange(false)
|
||||||
!isScrubbingRef.current &&
|
}, vr360ActiveRef.current ? VR_CONTROLS_HIDE_DELAY_MS : CONTROLS_HIDE_DELAY_MS)
|
||||||
!subtitleMenuOpenRef.current &&
|
|
||||||
!qualityMenuOpenRef.current &&
|
|
||||||
!danmakuOpenRef.current &&
|
|
||||||
!playlistOpenRef.current
|
|
||||||
) {
|
|
||||||
onUiVisibleChange(false)
|
|
||||||
}
|
|
||||||
}, 3000)
|
|
||||||
}, [onUiVisibleChange, video])
|
}, [onUiVisibleChange, video])
|
||||||
|
|
||||||
const queueRelativeSeek = useCallback((delta: number, immediate: boolean) => {
|
const queueRelativeSeek = useCallback((delta: number, immediate: boolean) => {
|
||||||
@@ -1090,6 +1124,24 @@ export function PlayerControls({
|
|||||||
{danmakuEnabled && <span className="h-1.5 w-1.5 rounded-full bg-lime-400" />}
|
{danmakuEnabled && <span className="h-1.5 w-1.5 rounded-full bg-lime-400" />}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={toggleMute}
|
onClick={toggleMute}
|
||||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||||
|
|||||||
@@ -0,0 +1,649 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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>) => {
|
||||||
|
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
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => 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={() => 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useCallback, useState, type DragEvent } from 'react'
|
||||||
|
|
||||||
|
/** 一组可直接展开到可拖拽元素上的事件处理器。 */
|
||||||
|
export type DragReorderHandlers = {
|
||||||
|
draggable: boolean
|
||||||
|
onDragStart: (event: DragEvent) => void
|
||||||
|
onDragOver: (event: DragEvent) => void
|
||||||
|
onDragLeave: (event: DragEvent) => void
|
||||||
|
onDrop: (event: DragEvent) => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 `fromId` 移动到 `toId` 当前所在的位置,返回新数组;不改动入参。
|
||||||
|
* 任一 id 不存在,或两者相同时返回 null,表示无需重排。
|
||||||
|
*/
|
||||||
|
export function moveIdToPosition(ids: string[], fromId: string, toId: string): string[] | null {
|
||||||
|
const fromIndex = ids.indexOf(fromId)
|
||||||
|
const toIndex = ids.indexOf(toId)
|
||||||
|
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null
|
||||||
|
const next = [...ids]
|
||||||
|
const [moved] = next.splice(fromIndex, 1)
|
||||||
|
next.splice(toIndex, 0, moved)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表拖拽排序的通用状态机:只负责「一串稳定 id」的拖拽与重排计算,
|
||||||
|
* 不关心渲染。调用方把 `dragProps(id)` 展开到拖拽柄上,并用返回的
|
||||||
|
* `draggingId` / `dragOverId` 决定高亮样式。
|
||||||
|
*
|
||||||
|
* 落点语义:把被拖项插入到目标项所在的位置。未传 `onReorder` 时整体禁用。
|
||||||
|
*/
|
||||||
|
export function useDragReorder(
|
||||||
|
ids: string[],
|
||||||
|
onReorder?: (orderedIds: string[]) => void | Promise<void>,
|
||||||
|
): {
|
||||||
|
draggingId: string | null
|
||||||
|
dragOverId: string | null
|
||||||
|
dragProps: (id: string) => DragReorderHandlers
|
||||||
|
} {
|
||||||
|
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||||
|
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const enabled = Boolean(onReorder)
|
||||||
|
|
||||||
|
const dragProps = useCallback(
|
||||||
|
(id: string): DragReorderHandlers => ({
|
||||||
|
draggable: enabled,
|
||||||
|
onDragStart: (event) => {
|
||||||
|
if (!enabled) return
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
event.dataTransfer.setData('text/plain', id)
|
||||||
|
setDraggingId(id)
|
||||||
|
setDragOverId(null)
|
||||||
|
},
|
||||||
|
onDragOver: (event) => {
|
||||||
|
if (!enabled || draggingId === id) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
setDragOverId((prev) => (prev === id ? prev : id))
|
||||||
|
},
|
||||||
|
onDragLeave: () => {
|
||||||
|
setDragOverId((prev) => (prev === id ? null : prev))
|
||||||
|
},
|
||||||
|
onDrop: (event) => {
|
||||||
|
if (!enabled) return
|
||||||
|
event.preventDefault()
|
||||||
|
// 拖拽期间 state 可能尚未提交,优先信任 dataTransfer 里的来源 id。
|
||||||
|
const fromId = draggingId ?? event.dataTransfer.getData('text/plain')
|
||||||
|
setDraggingId(null)
|
||||||
|
setDragOverId(null)
|
||||||
|
if (!fromId || fromId === id) return
|
||||||
|
const next = moveIdToPosition(ids, fromId, id)
|
||||||
|
if (next) void onReorder?.(next)
|
||||||
|
},
|
||||||
|
onDragEnd: () => {
|
||||||
|
setDraggingId(null)
|
||||||
|
setDragOverId(null)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[enabled, draggingId, ids, onReorder],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { draggingId, dragOverId, dragProps }
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
readLibraryListSort,
|
||||||
|
writeLibraryListSort,
|
||||||
|
type LibraryListSortField,
|
||||||
|
type LibraryListSortOrder,
|
||||||
|
} from '../utils/libraryListSort'
|
||||||
|
|
||||||
|
export type UseLibraryListSortResult = {
|
||||||
|
field: LibraryListSortField
|
||||||
|
order: LibraryListSortOrder
|
||||||
|
setSort: (field: LibraryListSortField, order: LibraryListSortOrder) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 媒体库列表的排序偏好(localStorage 持久化)。
|
||||||
|
*
|
||||||
|
* 首页和 /libraries 读的是同一份偏好,这样在 /libraries 改了排序后回到首页
|
||||||
|
* 顺序也一致;默认「库名倒序」。另外监听 storage 事件,多标签页同时打开时
|
||||||
|
* 一边改动另一边也能跟上。
|
||||||
|
*/
|
||||||
|
export function useLibraryListSort(): UseLibraryListSortResult {
|
||||||
|
const [sort, setSort] = useState(() => readLibraryListSort())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => setSort(readLibraryListSort())
|
||||||
|
window.addEventListener('storage', sync)
|
||||||
|
return () => window.removeEventListener('storage', sync)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const update = useCallback((field: LibraryListSortField, order: LibraryListSortOrder) => {
|
||||||
|
writeLibraryListSort(field, order)
|
||||||
|
setSort({ field, order })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { field: sort.field, order: sort.order, setSort: update }
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
normalizeTagName,
|
normalizeTagName,
|
||||||
readCachedLibraryTags,
|
readCachedLibraryTags,
|
||||||
readSelectedTagId,
|
readSelectedTagId,
|
||||||
|
reorderLibraryTags,
|
||||||
resolveSelectedTagId,
|
resolveSelectedTagId,
|
||||||
saveLibraryTags,
|
saveLibraryTags,
|
||||||
writeCachedLibraryTags,
|
writeCachedLibraryTags,
|
||||||
@@ -32,6 +33,8 @@ export type UseLibraryTagsResult = {
|
|||||||
setSelectedTagId: (tagId: string) => void
|
setSelectedTagId: (tagId: string) => void
|
||||||
createTag: (name: string) => Promise<string>
|
createTag: (name: string) => Promise<string>
|
||||||
renameTag: (from: string, to: string) => Promise<void>
|
renameTag: (from: string, to: string) => Promise<void>
|
||||||
|
/** 按给定标签名顺序重排标签栏(拖拽排序的落点)。 */
|
||||||
|
reorderTags: (orderedNames: string[]) => Promise<void>
|
||||||
removeTag: (name: string) => Promise<void>
|
removeTag: (name: string) => Promise<void>
|
||||||
/** 覆盖某个标签下的媒体库集合(保持传入顺序)。 */
|
/** 覆盖某个标签下的媒体库集合(保持传入顺序)。 */
|
||||||
setTagLibraries: (name: string, libraryIds: string[]) => Promise<void>
|
setTagLibraries: (name: string, libraryIds: string[]) => Promise<void>
|
||||||
@@ -103,13 +106,16 @@ export function useLibraryTags(): UseLibraryTagsResult {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 选中的标签可能被删除,落回「全部」以免标签栏空白。
|
// 选中的标签可能被删除,落回「全部」以免标签栏空白。
|
||||||
|
// 注意:标签首次加载完成前 tags 为空集合,此时不能据此判定标签已被删除,
|
||||||
|
// 否则从子页面返回时,持久化的选中标签会被误重置为「全部」。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (loading) return
|
||||||
const resolved = resolveSelectedTagId(tags, selectedTagId)
|
const resolved = resolveSelectedTagId(tags, selectedTagId)
|
||||||
if (resolved !== selectedTagId) {
|
if (resolved !== selectedTagId) {
|
||||||
selectedRef.current = resolved
|
selectedRef.current = resolved
|
||||||
setSelectedTagIdState(resolved)
|
setSelectedTagIdState(resolved)
|
||||||
}
|
}
|
||||||
}, [tags, selectedTagId])
|
}, [loading, tags, selectedTagId])
|
||||||
|
|
||||||
const setSelectedTagId = useCallback((tagId: string) => {
|
const setSelectedTagId = useCallback((tagId: string) => {
|
||||||
const next = tagId && tagId !== ALL_TAG_ID ? tagId : ALL_TAG_ID
|
const next = tagId && tagId !== ALL_TAG_ID ? tagId : ALL_TAG_ID
|
||||||
@@ -193,6 +199,13 @@ export function useLibraryTags(): UseLibraryTagsResult {
|
|||||||
[mutate, setSelectedTagId],
|
[mutate, setSelectedTagId],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const reorderTags = useCallback(
|
||||||
|
async (orderedNames: string[]) => {
|
||||||
|
await mutate((current) => reorderLibraryTags(current, orderedNames))
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
|
)
|
||||||
|
|
||||||
const removeTag = useCallback(
|
const removeTag = useCallback(
|
||||||
async (name: string) => {
|
async (name: string) => {
|
||||||
await mutate((current) => current.filter((tag) => tag.name !== name))
|
await mutate((current) => current.filter((tag) => tag.name !== name))
|
||||||
@@ -239,6 +252,7 @@ export function useLibraryTags(): UseLibraryTagsResult {
|
|||||||
setSelectedTagId,
|
setSelectedTagId,
|
||||||
createTag,
|
createTag,
|
||||||
renameTag,
|
renameTag,
|
||||||
|
reorderTags,
|
||||||
removeTag,
|
removeTag,
|
||||||
setTagLibraries,
|
setTagLibraries,
|
||||||
assignLibrary,
|
assignLibrary,
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { LibraryTagBar } from '../components/LibraryTagBar'
|
|||||||
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
|
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
|
||||||
import { useLibraryTags } from '../hooks/useLibraryTags'
|
import { useLibraryTags } from '../hooks/useLibraryTags'
|
||||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||||
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
import { useLibraryListSort } from '../hooks/useLibraryListSort'
|
||||||
|
import { sortLibrariesByField } from '../utils/libraryListSort'
|
||||||
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||||
import {
|
import {
|
||||||
ContinueWatchingSection,
|
ContinueWatchingSection,
|
||||||
@@ -34,6 +35,7 @@ export function HomePage() {
|
|||||||
const [librariesLoading, setLibrariesLoading] = useState(true)
|
const [librariesLoading, setLibrariesLoading] = useState(true)
|
||||||
const [historyLoading, setHistoryLoading] = useState(true)
|
const [historyLoading, setHistoryLoading] = useState(true)
|
||||||
const { pinnedIds } = usePinnedLibraries()
|
const { pinnedIds } = usePinnedLibraries()
|
||||||
|
const { field: sortField, order: sortOrder } = useLibraryListSort()
|
||||||
const libraryTags = useLibraryTags()
|
const libraryTags = useLibraryTags()
|
||||||
|
|
||||||
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
||||||
@@ -90,7 +92,12 @@ export function HomePage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
// 与 /libraries 共用同一份排序偏好(默认库名倒序):置顶库始终在前,
|
||||||
|
// 组内按偏好排序字段排。这样在媒体库页改过排序后,首页顺序也一致。
|
||||||
|
const sortedLibraries = useMemo(
|
||||||
|
() => sortLibrariesByField(libraries, pinnedIds, sortField, sortOrder),
|
||||||
|
[libraries, pinnedIds, sortField, sortOrder],
|
||||||
|
)
|
||||||
|
|
||||||
// 标签过滤只作用于展示层:选中的标签栏决定哪部分媒体库参与轮播/入口网格/内容行。
|
// 标签过滤只作用于展示层:选中的标签栏决定哪部分媒体库参与轮播/入口网格/内容行。
|
||||||
const effectiveTagId = libraryTags.selectedTagId
|
const effectiveTagId = libraryTags.selectedTagId
|
||||||
@@ -351,6 +358,7 @@ export function HomePage() {
|
|||||||
onCreate={libraryTags.createTag}
|
onCreate={libraryTags.createTag}
|
||||||
onRename={libraryTags.renameTag}
|
onRename={libraryTags.renameTag}
|
||||||
onRemove={libraryTags.removeTag}
|
onRemove={libraryTags.removeTag}
|
||||||
|
onReorder={libraryTags.reorderTags}
|
||||||
onAssign={libraryTags.assignLibrary}
|
onAssign={libraryTags.assignLibrary}
|
||||||
onAssignBatch={libraryTags.assignLibraries}
|
onAssignBatch={libraryTags.assignLibraries}
|
||||||
onClose={() => setLibraryTagsOpen(false)}
|
onClose={() => setLibraryTagsOpen(false)}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
|||||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||||
import { useLibraryTags } from '../hooks/useLibraryTags'
|
import { useLibraryTags } from '../hooks/useLibraryTags'
|
||||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||||
|
import { useLibraryListSort } from '../hooks/useLibraryListSort'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import {
|
import {
|
||||||
LibrariesContent,
|
LibrariesContent,
|
||||||
@@ -17,22 +18,29 @@ import {
|
|||||||
import type { LibraryPreview } from './librariesPageModel'
|
import type { LibraryPreview } from './librariesPageModel'
|
||||||
import type { Library } from '../types'
|
import type { Library } from '../types'
|
||||||
import type { SeriesCard } from '../utils/groupSeries'
|
import type { SeriesCard } from '../utils/groupSeries'
|
||||||
|
import {
|
||||||
|
sortLibraryPreviewsByField,
|
||||||
|
type LibraryListSortField,
|
||||||
|
type LibraryListSortOrder,
|
||||||
|
} from '../utils/libraryListSort'
|
||||||
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
||||||
import { ALL_TAG_ID, buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
|
import { ALL_TAG_ID, buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
|
||||||
import type { LibraryTag } from '../utils/libraryTags'
|
import type { LibraryTag } from '../utils/libraryTags'
|
||||||
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
|
||||||
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||||
|
|
||||||
export function LibrariesPage() {
|
export function LibrariesPage() {
|
||||||
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
|
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
// 会话缓存命中时首屏即用缓存渲染(避免先闪一帧占位再被撑高),
|
||||||
|
// 这样 useScrollMemory 的布局期恢复才能在返回列表时立刻落到原位置。
|
||||||
|
const [libraries, setLibraries] = useState<Library[]>(() => peekLibraries() ?? [])
|
||||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
|
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
|
||||||
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
const { pinnedIds, togglePin } = usePinnedLibraries()
|
||||||
const libraryTags = useLibraryTags()
|
const libraryTags = useLibraryTags()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(() => peekLibraries() === null)
|
||||||
const [repairing, setRepairing] = useState(false)
|
const [repairing, setRepairing] = useState(false)
|
||||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
|
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||||
const [repairMsg, setRepairMsg] = useState('')
|
const [repairMsg, setRepairMsg] = useState('')
|
||||||
|
const { field: sortField, order: sortOrder, setSort: setLibrarySort } = useLibraryListSort()
|
||||||
|
|
||||||
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
|
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
|
||||||
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
||||||
@@ -146,7 +154,14 @@ export function LibrariesPage() {
|
|||||||
})
|
})
|
||||||
}, [libraries, libraryData])
|
}, [libraries, libraryData])
|
||||||
|
|
||||||
const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds])
|
const sortedPreviews = useMemo(
|
||||||
|
() => sortLibraryPreviewsByField(previews, pinnedIds, sortField, sortOrder),
|
||||||
|
[previews, pinnedIds, sortField, sortOrder],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSortChange = useCallback((field: LibraryListSortField, order: LibraryListSortOrder) => {
|
||||||
|
setLibrarySort(field, order)
|
||||||
|
}, [setLibrarySort])
|
||||||
|
|
||||||
const handleTogglePin = useCallback((libraryId: string) => {
|
const handleTogglePin = useCallback((libraryId: string) => {
|
||||||
void togglePin(libraryId)
|
void togglePin(libraryId)
|
||||||
@@ -167,7 +182,7 @@ export function LibrariesPage() {
|
|||||||
[taggedPreviews],
|
[taggedPreviews],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (loading || pinnedLoading) {
|
if (loading) {
|
||||||
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
|
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +195,9 @@ export function LibrariesPage() {
|
|||||||
repairMsg={repairMsg}
|
repairMsg={repairMsg}
|
||||||
repairEpisodeArtwork={repairEpisodeArtwork}
|
repairEpisodeArtwork={repairEpisodeArtwork}
|
||||||
repairing={repairing}
|
repairing={repairing}
|
||||||
|
sortField={sortField}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
onRepairEpisodeArtworkChange={setRepairEpisodeArtwork}
|
onRepairEpisodeArtworkChange={setRepairEpisodeArtwork}
|
||||||
onRepairRescrape={handleRepairRescrape}
|
onRepairRescrape={handleRepairRescrape}
|
||||||
onManageLibraries={handleManageLibraries}
|
onManageLibraries={handleManageLibraries}
|
||||||
@@ -204,6 +222,7 @@ export function LibrariesPage() {
|
|||||||
onCreate={libraryTags.createTag}
|
onCreate={libraryTags.createTag}
|
||||||
onRename={libraryTags.renameTag}
|
onRename={libraryTags.renameTag}
|
||||||
onRemove={libraryTags.removeTag}
|
onRemove={libraryTags.removeTag}
|
||||||
|
onReorder={libraryTags.reorderTags}
|
||||||
onAssign={libraryTags.assignLibrary}
|
onAssign={libraryTags.assignLibrary}
|
||||||
onAssignBatch={libraryTags.assignLibraries}
|
onAssignBatch={libraryTags.assignLibraries}
|
||||||
onClose={() => setLibraryTagsOpen(false)}
|
onClose={() => setLibraryTagsOpen(false)}
|
||||||
@@ -228,19 +247,17 @@ export function LibrariesPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按选中标签过滤媒体库预览(ALL_TAG_ID 时原样返回,保留置顶/手动排序)。 */
|
/**
|
||||||
|
* 按选中标签过滤媒体库预览:复用共享的成员筛选(保持输入顺序),
|
||||||
|
* 这样排序下拉(含倒序)在标签页与「全部」页行为一致。
|
||||||
|
*/
|
||||||
function filterPreviewsByTag(previews: LibraryPreview[], tags: LibraryTag[], tagId: string): LibraryPreview[] {
|
function filterPreviewsByTag(previews: LibraryPreview[], tags: LibraryTag[], tagId: string): LibraryPreview[] {
|
||||||
if (!tagId || tagId === ALL_TAG_ID) return previews
|
if (!tagId || tagId === ALL_TAG_ID) return previews
|
||||||
const ordered = filterLibrariesByTag(
|
|
||||||
previews.map((preview) => ({ id: preview.library.id })),
|
|
||||||
tags,
|
|
||||||
tagId,
|
|
||||||
)
|
|
||||||
const byId = new Map(previews.map((preview) => [preview.library.id, preview]))
|
const byId = new Map(previews.map((preview) => [preview.library.id, preview]))
|
||||||
const out: LibraryPreview[] = []
|
const kept: LibraryPreview[] = []
|
||||||
for (const item of ordered) {
|
for (const library of filterLibrariesByTag(previews.map((preview) => preview.library), tags, tagId)) {
|
||||||
const preview = byId.get(item.id)
|
const preview = byId.get(library.id)
|
||||||
if (preview) out.push(preview)
|
if (preview) kept.push(preview)
|
||||||
}
|
}
|
||||||
return out
|
return kept
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { Link } from 'react-router-dom'
|
|||||||
import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react'
|
import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||||
|
import { LibraryListSortDropdown } from '../components/LibraryListSortDropdown'
|
||||||
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
|
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
|
||||||
|
import type { LibraryListSortField, LibraryListSortOrder } from '../utils/libraryListSort'
|
||||||
import { HomeLibrariesSection, HomeLibraryRowSection } from './HomePageSections'
|
import { HomeLibrariesSection, HomeLibraryRowSection } from './HomePageSections'
|
||||||
import type { LibraryPreview } from './librariesPageModel'
|
import type { LibraryPreview } from './librariesPageModel'
|
||||||
|
|
||||||
@@ -18,6 +20,9 @@ export function LibrariesHeader({
|
|||||||
repairMsg,
|
repairMsg,
|
||||||
repairEpisodeArtwork,
|
repairEpisodeArtwork,
|
||||||
repairing,
|
repairing,
|
||||||
|
sortField,
|
||||||
|
sortOrder,
|
||||||
|
onSortChange,
|
||||||
onRepairEpisodeArtworkChange,
|
onRepairEpisodeArtworkChange,
|
||||||
onRepairRescrape,
|
onRepairRescrape,
|
||||||
onManageLibraries,
|
onManageLibraries,
|
||||||
@@ -28,6 +33,9 @@ export function LibrariesHeader({
|
|||||||
repairMsg: string
|
repairMsg: string
|
||||||
repairEpisodeArtwork: boolean
|
repairEpisodeArtwork: boolean
|
||||||
repairing: boolean
|
repairing: boolean
|
||||||
|
sortField: LibraryListSortField
|
||||||
|
sortOrder: LibraryListSortOrder
|
||||||
|
onSortChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
|
||||||
onRepairEpisodeArtworkChange: (value: boolean) => void
|
onRepairEpisodeArtworkChange: (value: boolean) => void
|
||||||
onRepairRescrape: () => void
|
onRepairRescrape: () => void
|
||||||
onManageLibraries: () => void
|
onManageLibraries: () => void
|
||||||
@@ -40,42 +48,45 @@ export function LibrariesHeader({
|
|||||||
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
|
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
<LibraryListSortDropdown value={sortField} order={sortOrder} onChange={onSortChange} />
|
||||||
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
{isAdmin && (
|
||||||
<EpisodeArtworkToggle
|
<>
|
||||||
checked={repairEpisodeArtwork}
|
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
||||||
onChange={onRepairEpisodeArtworkChange}
|
<EpisodeArtworkToggle
|
||||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
checked={repairEpisodeArtwork}
|
||||||
className="h-9 sm:h-10 text-xs sm:text-sm"
|
onChange={onRepairEpisodeArtworkChange}
|
||||||
/>
|
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||||
<button
|
className="h-9 sm:h-10 text-xs sm:text-sm"
|
||||||
type="button"
|
/>
|
||||||
onClick={onRepairRescrape}
|
<button
|
||||||
disabled={repairing}
|
type="button"
|
||||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
onClick={onRepairRescrape}
|
||||||
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
|
disabled={repairing}
|
||||||
>
|
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
|
||||||
{repairing ? '正在启动…' : '全库修复+重刮'}
|
>
|
||||||
</button>
|
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
||||||
<Link
|
{repairing ? '正在启动…' : '全库修复+重刮'}
|
||||||
to="/scraper/queue"
|
</button>
|
||||||
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
<Link
|
||||||
title="查看正在进行的刮削任务与进度"
|
to="/scraper/queue"
|
||||||
>
|
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||||
<Sparkles size={14} className="text-brand-500" />
|
title="查看正在进行的刮削任务与进度"
|
||||||
<span>刮削队列</span>
|
>
|
||||||
</Link>
|
<Sparkles size={14} className="text-brand-500" />
|
||||||
<button
|
<span>刮削队列</span>
|
||||||
type="button"
|
</Link>
|
||||||
onClick={onManageLibraries}
|
<button
|
||||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
type="button"
|
||||||
>
|
onClick={onManageLibraries}
|
||||||
管理媒体库
|
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||||
</button>
|
>
|
||||||
</div>
|
管理媒体库
|
||||||
)}
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+214
-25
@@ -33,7 +33,7 @@ import {
|
|||||||
type SubtitlePosition,
|
type SubtitlePosition,
|
||||||
type SubtitleStylePreset,
|
type SubtitleStylePreset,
|
||||||
} from '../utils/subtitleDisplay'
|
} from '../utils/subtitleDisplay'
|
||||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
|
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel'
|
||||||
import { classifyDirectPlayError } from './directPlayError'
|
import { classifyDirectPlayError } from './directPlayError'
|
||||||
import { apiErrorMessage } from './StrmManagePage'
|
import { apiErrorMessage } from './StrmManagePage'
|
||||||
import { PlayerTopBar } from './PlayerTopBar'
|
import { PlayerTopBar } from './PlayerTopBar'
|
||||||
@@ -42,6 +42,14 @@ import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
|||||||
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
||||||
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
|
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
|
||||||
import { mediaVersionsOf } from '../utils/mediaVersion'
|
import { mediaVersionsOf } from '../utils/mediaVersion'
|
||||||
|
import {
|
||||||
|
detectVr360Profile,
|
||||||
|
loadVr360Preference,
|
||||||
|
sameVr360Profile,
|
||||||
|
saveVr360Preference,
|
||||||
|
type Vr360Detection,
|
||||||
|
type Vr360Profile,
|
||||||
|
} from '../utils/vr360'
|
||||||
|
|
||||||
// Fullscreen, dark-themed video page.
|
// Fullscreen, dark-themed video page.
|
||||||
//
|
//
|
||||||
@@ -96,6 +104,22 @@ export function PlayerPage() {
|
|||||||
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const [media, setMedia] = useState<Media | 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 [mode, setMode] = useState<PlayerMode>('direct')
|
||||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||||
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
|
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
|
||||||
@@ -114,6 +138,9 @@ export function PlayerPage() {
|
|||||||
normalizePlaybackRate(authUser?.player_playback_rate),
|
normalizePlaybackRate(authUser?.player_playback_rate),
|
||||||
)
|
)
|
||||||
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
||||||
|
// VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来,
|
||||||
|
// 这时不弹说明,避免给老用户闪一下。
|
||||||
|
const [vr360GuideSeen, setVr360GuideSeen] = useState<boolean | null>(null)
|
||||||
const playerVolumeTouchedRef = useRef(false)
|
const playerVolumeTouchedRef = useRef(false)
|
||||||
const playerPlaybackRateTouchedRef = useRef(false)
|
const playerPlaybackRateTouchedRef = useRef(false)
|
||||||
const playerPlaybackRateSaveSeqRef = useRef(0)
|
const playerPlaybackRateSaveSeqRef = useRef(0)
|
||||||
@@ -260,6 +287,7 @@ export function PlayerPage() {
|
|||||||
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
||||||
setDanmakuArea(Number(cfg.area) || 1)
|
setDanmakuArea(Number(cfg.area) || 1)
|
||||||
setDanmakuMergeSources(Boolean(cfg.merge_sources))
|
setDanmakuMergeSources(Boolean(cfg.merge_sources))
|
||||||
|
setVr360GuideSeen(Boolean(cfg.vr360_guide_seen))
|
||||||
applyDanmakuConnectionConfig(cfg)
|
applyDanmakuConnectionConfig(cfg)
|
||||||
if (!playerVolumeTouchedRef.current) {
|
if (!playerVolumeTouchedRef.current) {
|
||||||
const current = useAuthStore.getState().user
|
const current = useAuthStore.getState().user
|
||||||
@@ -458,6 +486,11 @@ export function PlayerPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMedia(null)
|
setMedia(null)
|
||||||
setLoadError('')
|
setLoadError('')
|
||||||
|
vr360TouchedRef.current = false
|
||||||
|
vr360TextureRetryUsedRef.current = false
|
||||||
|
vr360TextureRetryUntilRef.current = 0
|
||||||
|
setVr360(null)
|
||||||
|
setVr360Detected(false)
|
||||||
setHlsStartSec(0)
|
setHlsStartSec(0)
|
||||||
setPlaybackInfo(null)
|
setPlaybackInfo(null)
|
||||||
setHlsSource('local')
|
setHlsSource('local')
|
||||||
@@ -537,6 +570,97 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
}, [id, modeParam, directOnly])
|
}, [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.
|
// Wire up the actual <video> element when we know the mode.
|
||||||
// Depend on media.id (not the media object): refreshing duration after
|
// Depend on media.id (not the media object): refreshing duration after
|
||||||
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
||||||
@@ -608,6 +732,33 @@ export function PlayerPage() {
|
|||||||
[mediaId, setPlaybackMode, switchToLocalHLS],
|
[mediaId, setPlaybackMode, switchToLocalHLS],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 切到 HLS 播放(优先 115 云端,其次本地转码)。toggleMode 与「VR 需要同源帧」
|
||||||
|
// 的自愈逻辑共用,避免两处各写一遍云端/本地选择。
|
||||||
|
const enterHlsPlayback = useCallback(
|
||||||
|
(startSec: number) => {
|
||||||
|
setHlsStartSec(startSec)
|
||||||
|
const preferred =
|
||||||
|
playbackInfo?.provider === 'cloud115'
|
||||||
|
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
|
||||||
|
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
|
||||||
|
: undefined
|
||||||
|
if (preferred?.source === 'cloud') {
|
||||||
|
setHlsSource('cloud')
|
||||||
|
if (preferred.available) {
|
||||||
|
setCloudWaiting(false)
|
||||||
|
} else {
|
||||||
|
setCloudWaiting(true)
|
||||||
|
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
|
||||||
|
void startCloudTranscode(Number(preferred.id) || 4)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switchToLocalHLS(startSec)
|
||||||
|
}
|
||||||
|
setPlaybackMode('hls')
|
||||||
|
},
|
||||||
|
[playbackInfo, selectedQuality, setPlaybackMode, startCloudTranscode, switchToLocalHLS],
|
||||||
|
)
|
||||||
|
|
||||||
// 云端转码等待:按后端建议间隔轮询,完成后自动切到云 HLS。
|
// 云端转码等待:按后端建议间隔轮询,完成后自动切到云 HLS。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cloudWaiting || !mediaId) return
|
if (!cloudWaiting || !mediaId) return
|
||||||
@@ -792,13 +943,30 @@ export function PlayerPage() {
|
|||||||
setPlaybackMode('direct')
|
setPlaybackMode('direct')
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const url = streamURL(mediaId)
|
// VR 全景需要浏览器能读帧:STRM/网盘直链会被 302 到跨域 CDN,此时改为
|
||||||
|
// 服务端同源转发(原画,不转码);普通播放仍走 302 直连,省服务器流量。
|
||||||
|
const url = streamURL(mediaId, { proxy: vr360DirectProxyRef.current })
|
||||||
const absoluteURL = new URL(url, window.location.href).href
|
const absoluteURL = new URL(url, window.location.href).href
|
||||||
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
|
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
|
||||||
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
|
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
|
||||||
if (video.src !== absoluteURL) {
|
if (video.src !== absoluteURL) {
|
||||||
directRetryRef.current = false
|
directRetryRef.current = false
|
||||||
clearFallbackTimer()
|
clearFallbackTimer()
|
||||||
|
// 进出 VR 会切换播放源地址,这里保住当前播放位置。
|
||||||
|
const resumeAt = video.currentTime > 2 ? video.currentTime : 0
|
||||||
|
if (resumeAt > 0) {
|
||||||
|
video.addEventListener(
|
||||||
|
'loadedmetadata',
|
||||||
|
() => {
|
||||||
|
try {
|
||||||
|
video.currentTime = resumeAt
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
video.src = url
|
video.src = url
|
||||||
void video.play().catch(() => undefined)
|
void video.play().catch(() => undefined)
|
||||||
}
|
}
|
||||||
@@ -827,6 +995,7 @@ export function PlayerPage() {
|
|||||||
setPlaybackMode,
|
setPlaybackMode,
|
||||||
switchToLocalHLS,
|
switchToLocalHLS,
|
||||||
teardownHls,
|
teardownHls,
|
||||||
|
vr360DirectProxy,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
|
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
|
||||||
@@ -1186,38 +1355,49 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||||
if (next === 'hls') {
|
if (next === 'hls') {
|
||||||
setHlsStartSec(0)
|
enterHlsPlayback(0)
|
||||||
const preferred =
|
|
||||||
playbackInfo?.provider === 'cloud115'
|
|
||||||
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
|
|
||||||
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
|
|
||||||
: undefined
|
|
||||||
if (preferred?.source === 'cloud') {
|
|
||||||
setHlsSource('cloud')
|
|
||||||
if (preferred.available) {
|
|
||||||
setCloudWaiting(false)
|
|
||||||
} else {
|
|
||||||
setCloudWaiting(true)
|
|
||||||
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
|
|
||||||
void startCloudTranscode(Number(preferred.id) || 4)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
switchToLocalHLS()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setCloudWaiting(false)
|
setCloudWaiting(false)
|
||||||
}
|
}
|
||||||
setPlaybackMode(next)
|
setPlaybackMode(next)
|
||||||
}, [
|
}, [
|
||||||
|
enterHlsPlayback,
|
||||||
isDirectStream,
|
isDirectStream,
|
||||||
mode,
|
mode,
|
||||||
playbackInfo,
|
|
||||||
selectedQuality,
|
|
||||||
setPlaybackMode,
|
setPlaybackMode,
|
||||||
startCloudTranscode,
|
|
||||||
switchToLocalHLS,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// VR 渲染取不到帧时:网盘/STRM 的直连会 302 跳到 CDN,视频就变成跨域资源,
|
||||||
|
// 浏览器禁止 WebGL 读取(texImage2D 抛 SecurityError)。此时自动切到同源的
|
||||||
|
// HLS(云端优先)再继续 VR,只有确实做不到时才如实报错并退出 VR。
|
||||||
|
const handleVr360Error = useCallback(
|
||||||
|
(message: string) => {
|
||||||
|
const now = Date.now()
|
||||||
|
// 切换 HLS 的过程中旧的直连帧还会继续上报失败,先忽略这一窗口内的重复错误。
|
||||||
|
if (now < vr360TextureRetryUntilRef.current) return
|
||||||
|
const canRetryWithHls =
|
||||||
|
!vr360TextureRetryUsedRef.current &&
|
||||||
|
modeRef.current === 'direct' &&
|
||||||
|
!directOnly &&
|
||||||
|
!isRemoteEmbyID(mediaRef.current?.id)
|
||||||
|
if (canRetryWithHls) {
|
||||||
|
vr360TextureRetryUsedRef.current = true
|
||||||
|
vr360TextureRetryUntilRef.current = now + 15_000
|
||||||
|
setPlayerError('')
|
||||||
|
toast('直连源跨域,浏览器无法取帧;正在切到 HLS 转码后继续 VR 全景')
|
||||||
|
enterHlsPlayback(ref.current?.currentTime || 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vr360TouchedRef.current = true
|
||||||
|
setVr360(null)
|
||||||
|
setPlayerError(
|
||||||
|
`${message}。若当前是网盘/STRM 直连(会 302 跳转到 CDN),浏览器不允许 WebGL 读取跨域画面,请改用 HLS 播放后再开启 VR。`,
|
||||||
|
)
|
||||||
|
toast.error(message)
|
||||||
|
},
|
||||||
|
[directOnly, enterHlsPlayback],
|
||||||
|
)
|
||||||
|
|
||||||
const handleSeekAbsolute = useCallback(
|
const handleSeekAbsolute = useCallback(
|
||||||
(absoluteSec: number) => {
|
(absoluteSec: number) => {
|
||||||
if (mode !== 'hls') return false
|
if (mode !== 'hls') return false
|
||||||
@@ -1343,7 +1523,9 @@ export function PlayerPage() {
|
|||||||
|
|
||||||
if (retryingDirectRef.current) return
|
if (retryingDirectRef.current) return
|
||||||
|
|
||||||
const expectedSrc = mediaId ? new URL(streamURL(mediaId), window.location.href).href : ''
|
const expectedSrc = mediaId
|
||||||
|
? new URL(streamURL(mediaId, { proxy: vr360DirectProxyRef.current }), window.location.href).href
|
||||||
|
: ''
|
||||||
const action = classifyDirectPlayError({
|
const action = classifyDirectPlayError({
|
||||||
errorCode: video?.error?.code,
|
errorCode: video?.error?.code,
|
||||||
readyState: video?.readyState ?? 0,
|
readyState: video?.readyState ?? 0,
|
||||||
@@ -1499,6 +1681,13 @@ export function PlayerPage() {
|
|||||||
showQuality={showQuality}
|
showQuality={showQuality}
|
||||||
waiting={cloudWaiting}
|
waiting={cloudWaiting}
|
||||||
waitingMessage={cloudWaitMessage}
|
waitingMessage={cloudWaitMessage}
|
||||||
|
vr360={vr360}
|
||||||
|
vr360Detected={vr360Detected}
|
||||||
|
onToggleVr360={toggleVr360}
|
||||||
|
onVr360ProfileChange={changeVr360Profile}
|
||||||
|
onVr360Error={handleVr360Error}
|
||||||
|
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
|
||||||
|
onDismissVr360Guide={dismissVr360Guide}
|
||||||
playlistPanel={
|
playlistPanel={
|
||||||
<PlayerPlaylistPanel
|
<PlayerPlaylistPanel
|
||||||
open={playlistOpen}
|
open={playlistOpen}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Hand, Loader2, MousePointerClick, Rotate3d, Settings2, Smartphone, ZoomIn } from 'lucide-react'
|
||||||
|
|
||||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||||
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
import { AssSubtitleStage } from '../components/AssSubtitleStage'
|
import { AssSubtitleStage } from '../components/AssSubtitleStage'
|
||||||
import { DanmakuStage } from '../components/DanmakuStage'
|
import { DanmakuStage } from '../components/DanmakuStage'
|
||||||
import { PlayerControls } from '../components/PlayerControls'
|
import { PlayerControls } from '../components/PlayerControls'
|
||||||
|
import { Vr360Stage } from '../components/Vr360Stage'
|
||||||
|
import type { Vr360Profile } from '../utils/vr360'
|
||||||
import {
|
import {
|
||||||
subtitleTextStyle,
|
subtitleTextStyle,
|
||||||
type SubtitlePosition,
|
type SubtitlePosition,
|
||||||
@@ -25,6 +27,13 @@ type SubtitleRenderGroup = {
|
|||||||
style: CSSProperties
|
style: CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** VR 模式下必须连续点击这么多次才唤出控制栏:单击太容易在转视角时误触。 */
|
||||||
|
const VR_TAP_COUNT = 3
|
||||||
|
/** 相邻两次点击间隔超过该毫秒数就重新开始计数。 */
|
||||||
|
const VR_TAP_GAP_MS = 900
|
||||||
|
/** 首次操作说明的最长停留时间:到这里仍未点「知道了」也自动收起并记录为已看过。 */
|
||||||
|
const VR_GUIDE_AUTO_DISMISS_MS = 20_000
|
||||||
|
|
||||||
function uniqueSubtitleCues(cues: SubtitleCue[]): SubtitleCue[] {
|
function uniqueSubtitleCues(cues: SubtitleCue[]): SubtitleCue[] {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const unique: SubtitleCue[] = []
|
const unique: SubtitleCue[] = []
|
||||||
@@ -186,6 +195,16 @@ type PlayerVideoStageProps = {
|
|||||||
selectedQuality?: string
|
selectedQuality?: string
|
||||||
onSelectQuality?: (quality: PlaybackQuality) => void
|
onSelectQuality?: (quality: PlaybackQuality) => void
|
||||||
showQuality?: boolean
|
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
|
waiting?: boolean
|
||||||
waitingMessage?: string
|
waitingMessage?: string
|
||||||
}
|
}
|
||||||
@@ -241,6 +260,13 @@ export function PlayerVideoStage({
|
|||||||
selectedQuality = '',
|
selectedQuality = '',
|
||||||
onSelectQuality,
|
onSelectQuality,
|
||||||
showQuality = false,
|
showQuality = false,
|
||||||
|
vr360 = null,
|
||||||
|
vr360Detected = false,
|
||||||
|
onToggleVr360,
|
||||||
|
onVr360ProfileChange,
|
||||||
|
onVr360Error,
|
||||||
|
showVr360Guide = false,
|
||||||
|
onDismissVr360Guide,
|
||||||
waiting = false,
|
waiting = false,
|
||||||
waitingMessage = '',
|
waitingMessage = '',
|
||||||
}: PlayerVideoStageProps) {
|
}: PlayerVideoStageProps) {
|
||||||
@@ -248,6 +274,18 @@ export function PlayerVideoStage({
|
|||||||
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
||||||
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
||||||
const [controlsVisible, setControlsVisible] = useState(true)
|
const [controlsVisible, setControlsVisible] = useState(true)
|
||||||
|
// 鼠标是否停在 VR 工具条/设置面板上:停住时控制栏不自动隐藏,否则设置面板会在点击前消失。
|
||||||
|
const [vrUiHovered, setVrUiHovered] = useState(false)
|
||||||
|
// VR 下「连续点击唤出控制栏」已累计的次数(0 表示没在计数)。
|
||||||
|
const [vrTapCount, setVrTapCount] = useState(0)
|
||||||
|
const vrTapCountRef = useRef(0)
|
||||||
|
const vrTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const vrActive = Boolean(vr360)
|
||||||
|
// VR 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
|
||||||
|
// 只按「媒体 ID」记录:切换投影方式/画幅布局不会重建画布,也就不能清掉这个
|
||||||
|
// 状态——渲染器只在挂载后上报一次就绪,清掉之后提示会一直停在「正在启动」。
|
||||||
|
const [vrReadyMediaId, setVrReadyMediaId] = useState<string | null>(null)
|
||||||
|
const vrReady = Boolean(media && vrReadyMediaId === media.id)
|
||||||
const revealControlsOnlyRef = useRef(false)
|
const revealControlsOnlyRef = useRef(false)
|
||||||
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
||||||
const [activeCues, setActiveCues] = useState<SubtitleCue[]>([])
|
const [activeCues, setActiveCues] = useState<SubtitleCue[]>([])
|
||||||
@@ -390,17 +428,84 @@ export function PlayerVideoStage({
|
|||||||
else video.pause()
|
else video.pause()
|
||||||
}
|
}
|
||||||
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
revealControlsOnlyRef.current = event.pointerType === 'touch' && !controlsVisible
|
// VR 模式下单击不再负责唤出控制栏(改成连续 3 次点击),所以这里不预置开关。
|
||||||
|
revealControlsOnlyRef.current = !vrActive && event.pointerType === 'touch' && !controlsVisible
|
||||||
}
|
}
|
||||||
const handleStageClick = () => {
|
|
||||||
|
const resetVrTapCount = useCallback(() => {
|
||||||
|
vrTapCountRef.current = 0
|
||||||
|
setVrTapCount(0)
|
||||||
|
if (vrTapTimerRef.current) {
|
||||||
|
clearTimeout(vrTapTimerRef.current)
|
||||||
|
vrTapTimerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 舞台任意位置的「激活」(轻触/单击):移动端控制栏隐藏时首次轻触只唤出
|
||||||
|
// 控制栏,控制栏已显示时再次轻触才切换播放/暂停。
|
||||||
|
// VR 模式下单击不切换播放,也不唤出控制栏:单击太容易误触,必须连续点击
|
||||||
|
// VR_TAP_COUNT 次才会显示控制栏;控制栏已显示时单击即收起。
|
||||||
|
const handleSurfaceActivate = () => {
|
||||||
if (revealControlsOnlyRef.current) {
|
if (revealControlsOnlyRef.current) {
|
||||||
revealControlsOnlyRef.current = false
|
revealControlsOnlyRef.current = false
|
||||||
setControlsVisible(true)
|
setControlsVisible(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
revealControlsOnlyRef.current = false
|
revealControlsOnlyRef.current = false
|
||||||
|
if (vrActive) {
|
||||||
|
if (controlsVisible) {
|
||||||
|
resetVrTapCount()
|
||||||
|
setControlsVisible(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = vrTapCountRef.current + 1
|
||||||
|
vrTapCountRef.current = next
|
||||||
|
setVrTapCount(next)
|
||||||
|
if (vrTapTimerRef.current) {
|
||||||
|
clearTimeout(vrTapTimerRef.current)
|
||||||
|
vrTapTimerRef.current = null
|
||||||
|
}
|
||||||
|
if (next >= VR_TAP_COUNT) {
|
||||||
|
resetVrTapCount()
|
||||||
|
setControlsVisible(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 相邻两次点击间隔超过 VR_TAP_GAP_MS 就重新计数,避免把零散点击累加起来。
|
||||||
|
vrTapTimerRef.current = setTimeout(() => {
|
||||||
|
vrTapTimerRef.current = null
|
||||||
|
vrTapCountRef.current = 0
|
||||||
|
setVrTapCount(0)
|
||||||
|
}, VR_TAP_GAP_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
togglePlay()
|
togglePlay()
|
||||||
}
|
}
|
||||||
|
// 进入 VR 全景播放时先亮一次控制栏与 VR 工具条,让用户知道入口在哪;
|
||||||
|
// 之后完全由自动隐藏和「连点画面」控制。退出 VR 时要清掉 VR 浮层的悬停状态,
|
||||||
|
// 否则控制栏会以为还有浮层被悬停而一直不隐藏。
|
||||||
|
useEffect(() => {
|
||||||
|
resetVrTapCount()
|
||||||
|
if (vrActive) {
|
||||||
|
setControlsVisible(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setVrUiHovered(false)
|
||||||
|
}, [vrActive, resetVrTapCount])
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (vrTapTimerRef.current) clearTimeout(vrTapTimerRef.current)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 首次操作说明只出现一次:读完了点「知道了」会立即落库;放着不管也会在
|
||||||
|
// 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 toggleFullscreen = () => {
|
||||||
const stage = stageRef.current
|
const stage = stageRef.current
|
||||||
if (!stage) return
|
if (!stage) return
|
||||||
@@ -550,18 +655,25 @@ export function PlayerVideoStage({
|
|||||||
: true
|
: true
|
||||||
|
|
||||||
const assTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
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%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
}
|
}
|
||||||
|
: videoRatio
|
||||||
|
? {
|
||||||
|
aspectRatio: `${videoRatio}`,
|
||||||
|
width: isWiderThanStage ? '100%' : 'auto',
|
||||||
|
height: isWiderThanStage ? 'auto' : '100%',
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -569,7 +681,7 @@ export function PlayerVideoStage({
|
|||||||
data-player-stage
|
data-player-stage
|
||||||
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
||||||
onPointerDown={handleStagePointerDown}
|
onPointerDown={handleStagePointerDown}
|
||||||
onClick={handleStageClick}
|
onClick={handleSurfaceActivate}
|
||||||
onDoubleClick={toggleFullscreen}
|
onDoubleClick={toggleFullscreen}
|
||||||
>
|
>
|
||||||
{media ? (
|
{media ? (
|
||||||
@@ -602,6 +714,19 @@ export function PlayerVideoStage({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</video>
|
</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
|
<DanmakuStage
|
||||||
key={media.id}
|
key={media.id}
|
||||||
media={media}
|
media={media}
|
||||||
@@ -664,6 +789,7 @@ export function PlayerVideoStage({
|
|||||||
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
|
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
|
||||||
uiVisible={controlsVisible}
|
uiVisible={controlsVisible}
|
||||||
onUiVisibleChange={setControlsVisible}
|
onUiVisibleChange={setControlsVisible}
|
||||||
|
uiHold={vrUiHovered}
|
||||||
subs={subs}
|
subs={subs}
|
||||||
subtitleIndex={subtitleIndex}
|
subtitleIndex={subtitleIndex}
|
||||||
onSelectSubtitle={onSelectSubtitle}
|
onSelectSubtitle={onSelectSubtitle}
|
||||||
@@ -692,6 +818,9 @@ export function PlayerVideoStage({
|
|||||||
selectedQuality={selectedQuality}
|
selectedQuality={selectedQuality}
|
||||||
onSelectQuality={onSelectQuality}
|
onSelectQuality={onSelectQuality}
|
||||||
showQuality={showQuality}
|
showQuality={showQuality}
|
||||||
|
vr360={vr360}
|
||||||
|
vr360Detected={vr360Detected}
|
||||||
|
onToggleVr360={onToggleVr360}
|
||||||
/>
|
/>
|
||||||
{danmakuPanel}
|
{danmakuPanel}
|
||||||
{playlistPanel}
|
{playlistPanel}
|
||||||
@@ -701,6 +830,61 @@ export function PlayerVideoStage({
|
|||||||
) : (
|
) : (
|
||||||
<p className="text-sand-500">加载中…</p>
|
<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_TAP_COUNT} 次:显示控制栏(进度条、音量、画质、倍速、VR 设置)
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Settings2 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}
|
||||||
|
{/* 连点计数反馈:没有反馈时用户连点两次会觉得没反应。 */}
|
||||||
|
{vrActive && !vrGuideVisible && !controlsVisible && vrTapCount > 0 ? (
|
||||||
|
<div className="pointer-events-none absolute bottom-24 left-1/2 z-30 -translate-x-1/2 rounded-full border border-white/15 bg-black/75 px-4 py-1.5 text-xs text-white/90 shadow-xl backdrop-blur">
|
||||||
|
再连点 {VR_TAP_COUNT - vrTapCount} 次显示控制栏
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{playerError ? (
|
{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">
|
<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}
|
{playerError}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_LIBRARY_LIST_SORT_FIELD,
|
||||||
|
DEFAULT_LIBRARY_LIST_SORT_ORDER,
|
||||||
|
readLibraryListSort,
|
||||||
|
sortLibrariesByField,
|
||||||
|
sortLibraryPreviewsByField,
|
||||||
|
} from './libraryListSort.ts'
|
||||||
|
import type { Library } from '../types/index.ts'
|
||||||
|
|
||||||
|
function check(name: string, condition: boolean) {
|
||||||
|
if (!condition) throw new Error(`libraryListSort: ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function lib(id: string, name: string, sortOrder = 0): Library {
|
||||||
|
return { id, name, sort_order: sortOrder } as Library
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = (libs: Library[]) => libs.map((item) => item.id).join(',')
|
||||||
|
|
||||||
|
// 首页默认就是「库名倒序」,所以没写过偏好时要拿到 name/desc。
|
||||||
|
const fallback = readLibraryListSort()
|
||||||
|
check(
|
||||||
|
'default preference is name descending',
|
||||||
|
fallback.field === 'name' && fallback.order === 'desc',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'default constants stay in sync with the fallback',
|
||||||
|
DEFAULT_LIBRARY_LIST_SORT_FIELD === 'name' && DEFAULT_LIBRARY_LIST_SORT_ORDER === 'desc',
|
||||||
|
)
|
||||||
|
|
||||||
|
const alpha = [lib('a', '动画'), lib('b', '电影'), lib('c', '纪录片')]
|
||||||
|
// zh-CN 拼音升序是 电影(b) → 动画(a) → 纪录片(c)。
|
||||||
|
check(
|
||||||
|
'name descending is the default order',
|
||||||
|
ordered(sortLibrariesByField(alpha, [], 'name', 'desc')) === 'c,a,b',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'name ascending reverses it',
|
||||||
|
ordered(sortLibrariesByField(alpha, [], 'name', 'asc')) === 'b,a,c',
|
||||||
|
)
|
||||||
|
|
||||||
|
const manual = [lib('a', '动画', 2), lib('b', '电影', 0), lib('c', '纪录片', 1)]
|
||||||
|
check(
|
||||||
|
'manual order ascending follows sort_order',
|
||||||
|
ordered(sortLibrariesByField(manual, [], 'sort_order', 'asc')) === 'b,c,a',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'manual order descending flips sort_order',
|
||||||
|
ordered(sortLibrariesByField(manual, [], 'sort_order', 'desc')) === 'a,c,b',
|
||||||
|
)
|
||||||
|
|
||||||
|
// 置顶只决定分组:置顶库整体在前,组内仍然按当前字段/方向排。
|
||||||
|
check(
|
||||||
|
'pinned libraries always come first',
|
||||||
|
ordered(sortLibrariesByField(alpha, ['c'], 'name', 'desc')) === 'c,a,b',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'pinned group is sorted by the active field too',
|
||||||
|
ordered(sortLibrariesByField(alpha, ['a', 'c'], 'name', 'desc')) === 'c,a,b',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'pinned ids that no longer exist are ignored',
|
||||||
|
ordered(sortLibrariesByField(alpha, ['missing'], 'name', 'desc')) === 'c,a,b',
|
||||||
|
)
|
||||||
|
|
||||||
|
// 同名时按 sort_order 兜底,再按 id 兜底,保证顺序稳定。
|
||||||
|
const sameName = [lib('b', '动画', 1), lib('a', '动画', 1), lib('c', '动画', 0)]
|
||||||
|
check(
|
||||||
|
'equal names fall back to sort_order then id',
|
||||||
|
ordered(sortLibrariesByField(sameName, [], 'name', 'desc')) === 'c,a,b',
|
||||||
|
)
|
||||||
|
|
||||||
|
check('single library is returned as-is', sortLibrariesByField([lib('a', '动画')], [], 'name', 'desc').length === 1)
|
||||||
|
|
||||||
|
const previews = [
|
||||||
|
{ library: lib('a', '动画'), total: 1 },
|
||||||
|
{ library: lib('b', '电影'), total: 2 },
|
||||||
|
]
|
||||||
|
check(
|
||||||
|
'previews follow the library order',
|
||||||
|
sortLibraryPreviewsByField(previews, [], 'name', 'desc').map((item) => item.library.id).join(',') === 'a,b',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'previews keep their payload after sorting',
|
||||||
|
sortLibraryPreviewsByField(previews, [], 'name', 'desc')[0].total === 1,
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'previews respect pinning',
|
||||||
|
sortLibraryPreviewsByField(previews, ['b'], 'name', 'desc').map((item) => item.library.id).join(',') === 'b,a',
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('libraryListSort.test.ts ok')
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { Library } from '../types'
|
||||||
|
|
||||||
|
export type LibraryListSortField = 'name' | 'sort_order'
|
||||||
|
export type LibraryListSortOrder = 'asc' | 'desc'
|
||||||
|
|
||||||
|
export type LibraryListSortOption = {
|
||||||
|
id: LibraryListSortField
|
||||||
|
label: string
|
||||||
|
defaultOrder: LibraryListSortOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LIBRARY_LIST_SORT_OPTIONS: LibraryListSortOption[] = [
|
||||||
|
{ id: 'name', label: '库名', defaultOrder: 'desc' },
|
||||||
|
{ id: 'sort_order', label: '手动顺序', defaultOrder: 'asc' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEFAULT_LIBRARY_LIST_SORT_FIELD: LibraryListSortField = 'name'
|
||||||
|
export const DEFAULT_LIBRARY_LIST_SORT_ORDER: LibraryListSortOrder = 'desc'
|
||||||
|
|
||||||
|
const FIELD_KEY = 'mebox_libraries_sort_field'
|
||||||
|
const ORDER_KEY = 'mebox_libraries_sort_order'
|
||||||
|
|
||||||
|
export function readLibraryListSort(): { field: LibraryListSortField; order: LibraryListSortOrder } {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return { field: DEFAULT_LIBRARY_LIST_SORT_FIELD, order: DEFAULT_LIBRARY_LIST_SORT_ORDER }
|
||||||
|
}
|
||||||
|
const rawField = window.localStorage.getItem(FIELD_KEY)
|
||||||
|
const rawOrder = window.localStorage.getItem(ORDER_KEY)
|
||||||
|
const field: LibraryListSortField =
|
||||||
|
rawField === 'name' || rawField === 'sort_order' ? rawField : DEFAULT_LIBRARY_LIST_SORT_FIELD
|
||||||
|
const order: LibraryListSortOrder =
|
||||||
|
rawOrder === 'asc' || rawOrder === 'desc'
|
||||||
|
? rawOrder
|
||||||
|
: field === 'name'
|
||||||
|
? DEFAULT_LIBRARY_LIST_SORT_ORDER
|
||||||
|
: 'asc'
|
||||||
|
return { field, order }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeLibraryListSort(field: LibraryListSortField, order: LibraryListSortOrder): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem(FIELD_KEY, field)
|
||||||
|
window.localStorage.setItem(ORDER_KEY, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareNames(a: string, b: string, order: LibraryListSortOrder): number {
|
||||||
|
const cmp = a.localeCompare(b, 'zh-CN', { numeric: true, sensitivity: 'base' })
|
||||||
|
return order === 'asc' ? cmp : -cmp
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSortOrder(a: number, b: number, order: LibraryListSortOrder): number {
|
||||||
|
const cmp = a - b
|
||||||
|
return order === 'asc' ? cmp : -cmp
|
||||||
|
}
|
||||||
|
|
||||||
|
function librarySortKey(library: Library): { name: string; sortOrder: number } {
|
||||||
|
return {
|
||||||
|
name: (library.name || '').trim(),
|
||||||
|
sortOrder: library.sort_order ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareLibraries(a: Library, b: Library, field: LibraryListSortField, order: LibraryListSortOrder): number {
|
||||||
|
const ka = librarySortKey(a)
|
||||||
|
const kb = librarySortKey(b)
|
||||||
|
if (field === 'name') {
|
||||||
|
const byName = compareNames(ka.name, kb.name, order)
|
||||||
|
if (byName !== 0) return byName
|
||||||
|
return compareSortOrder(ka.sortOrder, kb.sortOrder, 'asc') || a.id.localeCompare(b.id)
|
||||||
|
}
|
||||||
|
const byOrder = compareSortOrder(ka.sortOrder, kb.sortOrder, order)
|
||||||
|
if (byOrder !== 0) return byOrder
|
||||||
|
return compareNames(ka.name, kb.name, 'asc') || a.id.localeCompare(b.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 置顶组始终在前;组内按 field/order 排序。
|
||||||
|
* pinnedIds 的相对顺序只决定「是否置顶」,组内不再保留置顶拖拽序。
|
||||||
|
*
|
||||||
|
* 首页与 /libraries 共用这份排序,保证两处的媒体库顺序一致。
|
||||||
|
*/
|
||||||
|
export function sortLibrariesByField(
|
||||||
|
libraries: Library[],
|
||||||
|
pinnedIds: string[],
|
||||||
|
field: LibraryListSortField,
|
||||||
|
order: LibraryListSortOrder,
|
||||||
|
): Library[] {
|
||||||
|
if (libraries.length < 2) return libraries
|
||||||
|
|
||||||
|
const pinnedRank = new Map(pinnedIds.map((id, index) => [id, index]))
|
||||||
|
const pinned: Library[] = []
|
||||||
|
const rest: Library[] = []
|
||||||
|
for (const library of libraries) {
|
||||||
|
if (pinnedRank.has(library.id)) pinned.push(library)
|
||||||
|
else rest.push(library)
|
||||||
|
}
|
||||||
|
|
||||||
|
const byField = (a: Library, b: Library) => compareLibraries(a, b, field, order)
|
||||||
|
pinned.sort(byField)
|
||||||
|
rest.sort(byField)
|
||||||
|
return [...pinned, ...rest]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览列表按库排序:先排库,再按排好的库顺序取回对应预览。 */
|
||||||
|
export function sortLibraryPreviewsByField<T extends { library: Library }>(
|
||||||
|
items: T[],
|
||||||
|
pinnedIds: string[],
|
||||||
|
field: LibraryListSortField,
|
||||||
|
order: LibraryListSortOrder,
|
||||||
|
): T[] {
|
||||||
|
if (items.length < 2) return items
|
||||||
|
const ordered = sortLibrariesByField(
|
||||||
|
items.map((item) => item.library),
|
||||||
|
pinnedIds,
|
||||||
|
field,
|
||||||
|
order,
|
||||||
|
)
|
||||||
|
const byId = new Map(items.map((item) => [item.library.id, item]))
|
||||||
|
return ordered
|
||||||
|
.map((library) => byId.get(library.id))
|
||||||
|
.filter((item): item is T => item !== undefined)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
filterLibrariesForTagging,
|
filterLibrariesForTagging,
|
||||||
normalizeLibraryTags,
|
normalizeLibraryTags,
|
||||||
normalizeTagName,
|
normalizeTagName,
|
||||||
|
reorderLibraryTags,
|
||||||
resolveSelectedTagId,
|
resolveSelectedTagId,
|
||||||
tagNameError,
|
tagNameError,
|
||||||
} from './libraryTags.ts'
|
} from './libraryTags.ts'
|
||||||
@@ -49,10 +50,10 @@ check('tag tab keeps creation order', tabs[1].name === '动画' && tabs[2].name
|
|||||||
check('tag count ignores unavailable libraries', tabs[1].count === 1)
|
check('tag count ignores unavailable libraries', tabs[1].count === 1)
|
||||||
|
|
||||||
check(
|
check(
|
||||||
'filtering keeps tag order and drops unknown ids',
|
'filtering drops unknown ids and keeps input order',
|
||||||
filterLibrariesByTag(libs, [{ name: '动画', library_ids: ['lib-c', 'missing', 'lib-a'] }], '动画')
|
filterLibrariesByTag(libs, [{ name: '动画', library_ids: ['lib-c', 'missing', 'lib-a'] }], '动画')
|
||||||
.map((lib) => lib.id)
|
.map((lib) => lib.id)
|
||||||
.join(',') === 'lib-c,lib-a',
|
.join(',') === 'lib-a,lib-c',
|
||||||
)
|
)
|
||||||
check('全部 returns every library in original order', filterLibrariesByTag(libs, [], ALL_TAG_ID).length === 3)
|
check('全部 returns every library in original order', filterLibrariesByTag(libs, [], ALL_TAG_ID).length === 3)
|
||||||
check('unknown tag yields no libraries', filterLibrariesByTag(libs, [], 'nope').length === 0)
|
check('unknown tag yields no libraries', filterLibrariesByTag(libs, [], 'nope').length === 0)
|
||||||
@@ -135,4 +136,31 @@ check(
|
|||||||
tagNameError('动画', [{ name: '动画', library_ids: [] }], '动画') === '',
|
tagNameError('动画', [{ name: '动画', library_ids: [] }], '动画') === '',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const reorderable = [
|
||||||
|
{ name: '动画', library_ids: [] },
|
||||||
|
{ name: '电影', library_ids: [] },
|
||||||
|
{ name: '音乐', library_ids: [] },
|
||||||
|
]
|
||||||
|
check(
|
||||||
|
'reorder moves a tag to the requested slot',
|
||||||
|
reorderLibraryTags(reorderable, ['电影', '动画', '音乐']).map((tag) => tag.name).join(',') === '电影,动画,音乐',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'reorder can move a tag to the front',
|
||||||
|
reorderLibraryTags(reorderable, ['音乐', '动画', '电影']).map((tag) => tag.name).join(',') === '音乐,动画,电影',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'names missing from the order keep relative order at the end',
|
||||||
|
reorderLibraryTags(reorderable, ['电影']).map((tag) => tag.name).join(',') === '电影,动画,音乐',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'empty order leaves tags untouched',
|
||||||
|
reorderLibraryTags(reorderable, []).map((tag) => tag.name).join(',') === '动画,电影,音乐',
|
||||||
|
)
|
||||||
|
check('reorder ignores extra unknown names', reorderLibraryTags(reorderable, ['不存在', '音乐']).map((tag) => tag.name).join(',') === '音乐,动画,电影')
|
||||||
|
check(
|
||||||
|
'reordering with fewer than two tags is a no-op',
|
||||||
|
reorderLibraryTags([{ name: '动画', library_ids: [] }], []).map((tag) => tag.name).join(',') === '动画',
|
||||||
|
)
|
||||||
|
|
||||||
console.log('libraryTags.test.ts ok')
|
console.log('libraryTags.test.ts ok')
|
||||||
|
|||||||
@@ -164,8 +164,9 @@ export function isLibraryInTag(tags: LibraryTag[], tagId: string, libraryId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按标签过滤媒体库:`tagId` 为「全部」时原样返回(保持原有置顶/手动排序),
|
* 按标签过滤媒体库:`tagId` 为「全部」时原样返回。
|
||||||
* 否则只保留该标签下的库,并保持标签里记录的顺序。
|
* 只做成员筛选、保持输入顺序,展示顺序(置顶、排序下拉等)由调用方决定;
|
||||||
|
* 标签里记录的 library_ids 顺序只表示归属,不作为展示排序。
|
||||||
*/
|
*/
|
||||||
export function filterLibrariesByTag<T extends { id: string }>(
|
export function filterLibrariesByTag<T extends { id: string }>(
|
||||||
libraries: T[],
|
libraries: T[],
|
||||||
@@ -173,15 +174,9 @@ export function filterLibrariesByTag<T extends { id: string }>(
|
|||||||
tagId: string,
|
tagId: string,
|
||||||
): T[] {
|
): T[] {
|
||||||
if (!tagId || tagId === ALL_TAG_ID) return libraries
|
if (!tagId || tagId === ALL_TAG_ID) return libraries
|
||||||
const ids = tagLibraryIds(tags, tagId)
|
const ids = new Set(tagLibraryIds(tags, tagId))
|
||||||
if (ids.length === 0) return []
|
if (ids.size === 0) return []
|
||||||
const byId = new Map(libraries.map((lib) => [lib.id, lib]))
|
return libraries.filter((lib) => ids.has(lib.id))
|
||||||
const out: T[] = []
|
|
||||||
for (const id of ids) {
|
|
||||||
const lib = byId.get(id)
|
|
||||||
if (lib) out.push(lib)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 生成标签栏模型:首项固定为「全部」,其后按标签顺序追加。 */
|
/** 生成标签栏模型:首项固定为「全部」,其后按标签顺序追加。 */
|
||||||
@@ -202,6 +197,22 @@ export function buildLibraryTagTabs(libraries: Library[], tags: LibraryTag[]): L
|
|||||||
return tabs
|
return tabs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按给定标签名顺序重排标签栏:`orderedNames` 是期望的标签名顺序(通常来自拖拽后的顺序)。
|
||||||
|
* 未出现在顺序里的标签(例如新建、旧数据并发写入)保持原相对顺序,追加到末尾,绝不丢失。
|
||||||
|
*/
|
||||||
|
export function reorderLibraryTags(tags: LibraryTag[], orderedNames: string[]): LibraryTag[] {
|
||||||
|
if (tags.length < 2) return tags
|
||||||
|
const rank = new Map(orderedNames.map((name, index) => [name, index]))
|
||||||
|
const ranked: LibraryTag[] = []
|
||||||
|
const rest: LibraryTag[] = []
|
||||||
|
for (const tag of tags) {
|
||||||
|
;(rank.has(tag.name) ? ranked : rest).push(tag)
|
||||||
|
}
|
||||||
|
ranked.sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0))
|
||||||
|
return [...ranked, ...rest]
|
||||||
|
}
|
||||||
|
|
||||||
/** 选中的标签是否仍存在;被删除或被过滤掉时回落到「全部」。 */
|
/** 选中的标签是否仍存在;被删除或被过滤掉时回落到「全部」。 */
|
||||||
export function resolveSelectedTagId(tags: LibraryTag[], selectedId: string): string {
|
export function resolveSelectedTagId(tags: LibraryTag[], selectedId: string): string {
|
||||||
if (!selectedId || selectedId === ALL_TAG_ID) return ALL_TAG_ID
|
if (!selectedId || selectedId === ALL_TAG_ID) return ALL_TAG_ID
|
||||||
|
|||||||
@@ -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
|
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 {
|
export function mediaVersionLabel(media: Media): string {
|
||||||
const isStrm = isStrmMedia(media)
|
const isStrm = isStrmMedia(media)
|
||||||
@@ -153,11 +162,11 @@ export function mediaVersionLabel(media: Media): string {
|
|||||||
parts.push('云端直链')
|
parts.push('云端直链')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parts.length > 0) {
|
// 只有容器 /「云端直链」时无法区分多分片,回退文件名(sivr-270-1 / …part1)
|
||||||
|
if (parts.length > 0 && !isIndistinctVersionLabel(parts)) {
|
||||||
return parts.join(' · ')
|
return parts.join(' · ')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兜底:使用清理后的文件名
|
|
||||||
const cleaned = cleanFallbackFilename(media.path || '')
|
const cleaned = cleanFallbackFilename(media.path || '')
|
||||||
return cleaned || media.title || media.id
|
return cleaned || media.title || media.id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,31 +67,3 @@ export function togglePinnedLibraryId(current: string[], id: string): string[] {
|
|||||||
export function isLibraryPinned(id: string, pinnedIds: string[]): boolean {
|
export function isLibraryPinned(id: string, pinnedIds: string[]): boolean {
|
||||||
return pinnedIds.includes(id)
|
return pinnedIds.includes(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortByPinnedIds<T extends { id: string }>(items: T[], pinnedIds: string[]): T[] {
|
|
||||||
if (pinnedIds.length === 0) return items
|
|
||||||
const rank = new Map(pinnedIds.map((pinnedId, index) => [pinnedId, index]))
|
|
||||||
return [...items].sort((a, b) => {
|
|
||||||
const aRank = rank.get(a.id)
|
|
||||||
const bRank = rank.get(b.id)
|
|
||||||
const aPinned = aRank !== undefined
|
|
||||||
const bPinned = bRank !== undefined
|
|
||||||
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
|
||||||
if (aPinned && bPinned) return aRank - bRank
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sortLibraryPreviews<T extends { library: { id: string } }>(items: T[], pinnedIds: string[]): T[] {
|
|
||||||
if (pinnedIds.length === 0) return items
|
|
||||||
const rank = new Map(pinnedIds.map((id, index) => [id, index]))
|
|
||||||
return [...items].sort((a, b) => {
|
|
||||||
const aRank = rank.get(a.library.id)
|
|
||||||
const bRank = rank.get(b.library.id)
|
|
||||||
const aPinned = aRank !== undefined
|
|
||||||
const bPinned = bRank !== undefined
|
|
||||||
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
|
||||||
if (aPinned && bPinned) return aRank - bRank
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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