Compare commits

...

10 Commits

Author SHA1 Message Date
truewhile 83c0b65a0b 优化360播放 2026-09-17 01:08:28 +08:00
truewhile 1153fe4191 添加VR360播放功能 2026-09-17 00:31:45 +08:00
truewhile 93866d169b 优化 2026-09-16 23:59:38 +08:00
truewhile d283d39008 优化 2026-09-16 23:36:03 +08:00
truewhile fc89d23867 优化 2026-09-16 22:20:23 +08:00
truewhile 1f14a1e85b 优化 2026-09-16 21:44:42 +08:00
truewhile f0a6b630c6 优化 2026-09-16 21:19:46 +08:00
truewhile c723664e46 优化 2026-09-16 18:34:24 +08:00
truewhile 5faa6975d0 优化标签 2026-09-16 13:50:06 +08:00
truewhile cbfae0252e t添加标签功能 2026-09-16 12:30:34 +08:00
45 changed files with 5261 additions and 197 deletions
+12 -1
View File
@@ -722,7 +722,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if mount.ProxyPlay {
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
// 与挂载账号的 proxy_play 开关等价。
if mount.ProxyPlay || wantSameOriginProxy(c) {
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
@@ -758,6 +760,15 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
// ?proxy=1:把网盘/STRM 直链改为服务端同源转发(网页端读帧、VR 全景用),
// 画质与原文件一致,不触发转码。
if wantSameOriginProxy(c) {
handled, proxyErr := proxySTRMStream(c, svc, m)
if handled {
writeProxyError(c, proxyErr)
return
}
}
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+40
View File
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
@@ -13,6 +14,45 @@ type pinnedLibrariesReq struct {
LibraryIDs []string `json:"library_ids"`
}
type libraryTagsReq struct {
Tags []model.LibraryTagSet `json:"tags"`
}
func getLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
tags, err := svc.Profile.GetLibraryTags(c.Request.Context(), uid.(string))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if tags == nil {
tags = []model.LibraryTagSet{}
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
}
func setLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req libraryTagsReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
tags, err := svc.Profile.SetLibraryTags(c.Request.Context(), uid.(string), req.Tags)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if tags == nil {
tags = []model.LibraryTagSet{}
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
}
func getPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
@@ -12,6 +12,8 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
authed.PATCH("/me", updateProfileHandler(svc))
authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc))
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
authed.GET("/me/library-tags", getLibraryTagsHandler(svc))
authed.PUT("/me/library-tags", setLibraryTagsHandler(svc))
authed.POST("/me/password", changePasswordHandler(svc))
authed.POST("/me/logout", logoutHandler(svc))
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
@@ -27,6 +27,8 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
"GET /api/me",
"GET /api/me/pinned-libraries",
"PUT /api/me/pinned-libraries",
"GET /api/me/library-tags",
"PUT /api/me/library-tags",
"GET /api/auth/permissions",
"GET /api/libraries",
"GET /api/media",
+56
View File
@@ -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()})
}
+118
View File
@@ -0,0 +1,118 @@
package model
import (
"encoding/json"
"strings"
)
// MaxLibraryTags 是单个用户可创建的标签数量上限,避免恶意写入过大的 JSON。
const MaxLibraryTags = 50
// MaxLibraryTagNameLen 是单个标签名的最大字符长度(按 rune 计数)。
const MaxLibraryTagNameLen = 24
// LibraryTagSet 是用户自定义的媒体库标签分组。
// LibraryIDs 保存该标签下媒体库的 ID(含远程 Emby 挂载库的 embyremote~ 形式),
// 顺序即媒体库在该标签内的展示顺序。
type LibraryTagSet struct {
Name string `json:"name"`
LibraryIDs []string `json:"library_ids"`
}
// DecodeLibraryTags 解析 LibraryTags 字段,忽略损坏的数据。
func (u *User) DecodeLibraryTags() []LibraryTagSet {
if u == nil || strings.TrimSpace(u.LibraryTags) == "" {
return nil
}
var tags []LibraryTagSet
if err := json.Unmarshal([]byte(u.LibraryTags), &tags); err != nil {
return nil
}
out := make([]LibraryTagSet, 0, len(tags))
for _, tag := range NormalizeLibraryTags(tags) {
out = append(out, tag)
}
if len(out) == 0 {
return nil
}
return out
}
// EncodeLibraryTags 把标签集合序列化为可写入 LibraryTags 字段的 JSON 字符串。
// 空集合序列化为空字符串,便于用零值表达"没有标签"。
func EncodeLibraryTags(tags []LibraryTagSet) (string, error) {
if len(tags) == 0 {
return "", nil
}
raw, err := json.Marshal(tags)
if err != nil {
return "", err
}
return string(raw), nil
}
// NormalizeLibraryTags 清洗标签集合:去空白、丢弃空名标签、去掉标签名与
// 标签内媒体库 ID 的重复项,并把标签名重复的项合并。保持传入顺序。
func NormalizeLibraryTags(tags []LibraryTagSet) []LibraryTagSet {
if len(tags) == 0 {
return nil
}
out := make([]LibraryTagSet, 0, len(tags))
indexByName := make(map[string]int, len(tags))
for _, tag := range tags {
name := TruncateLibraryTagName(tag.Name)
if name == "" {
continue
}
if len(out) >= MaxLibraryTags {
break
}
key := strings.ToLower(name)
pos, exists := indexByName[key]
if !exists {
if len(out) >= MaxLibraryTags {
break
}
out = append(out, LibraryTagSet{Name: name, LibraryIDs: []string{}})
pos = len(out) - 1
indexByName[key] = pos
}
seen := make(map[string]struct{}, len(out[pos].LibraryIDs))
for _, id := range out[pos].LibraryIDs {
seen[id] = struct{}{}
}
for _, id := range tag.LibraryIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out[pos].LibraryIDs = append(out[pos].LibraryIDs, trimmed)
}
}
if len(out) == 0 {
return nil
}
for i := range out {
if out[i].LibraryIDs == nil {
out[i].LibraryIDs = []string{}
}
}
return out
}
// TruncateLibraryTagName 去掉首尾空白并按 rune 截断到长度上限。
func TruncateLibraryTagName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
runes := []rune(name)
if len(runes) > MaxLibraryTagNameLen {
runes = runes[:MaxLibraryTagNameLen]
}
return strings.TrimSpace(string(runes))
}
+79
View File
@@ -0,0 +1,79 @@
package model
import "testing"
func TestNormalizeLibraryTagsMergesDuplicatesAndTrims(t *testing.T) {
tags := []LibraryTagSet{
{Name: " 动画 ", LibraryIDs: []string{" a ", "", "b", "a"}},
{Name: "动画", LibraryIDs: []string{"c", "b"}},
{Name: " ", LibraryIDs: []string{"x"}},
{Name: "电影", LibraryIDs: nil},
}
got := NormalizeLibraryTags(tags)
if len(got) != 2 {
t.Fatalf("NormalizeLibraryTags len = %d, want 2 (%#v)", len(got), got)
}
if got[0].Name != "动画" {
t.Fatalf("first tag name = %q, want 动画", got[0].Name)
}
want := []string{"a", "b", "c"}
if len(got[0].LibraryIDs) != len(want) {
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
}
for i := range want {
if got[0].LibraryIDs[i] != want[i] {
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
}
}
if got[1].Name != "电影" || got[1].LibraryIDs == nil {
t.Fatalf("empty tag should survive with an empty non-nil id list: %#v", got[1])
}
}
func TestNormalizeLibraryTagsCapsCountAndNameLength(t *testing.T) {
long := make([]rune, MaxLibraryTagNameLen+10)
for i := range long {
long[i] = 'x'
}
got := NormalizeLibraryTags([]LibraryTagSet{{Name: string(long)}})
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if runes := []rune(got[0].Name); len(runes) != MaxLibraryTagNameLen {
t.Fatalf("name length = %d, want %d", len(runes), MaxLibraryTagNameLen)
}
many := make([]LibraryTagSet, 0, MaxLibraryTags+5)
for i := 0; i < MaxLibraryTags+5; i++ {
many = append(many, LibraryTagSet{Name: string(rune('a' + i%26)) + "-" + string(rune('a'+i/26))})
}
if capped := NormalizeLibraryTags(many); len(capped) > MaxLibraryTags {
t.Fatalf("capped len = %d, want <= %d", len(capped), MaxLibraryTags)
}
}
func TestEncodeDecodeLibraryTagsRoundTrip(t *testing.T) {
user := &User{}
if encoded, err := EncodeLibraryTags(nil); err != nil || encoded != "" {
t.Fatalf("EncodeLibraryTags(nil) = %q, %v; want \"\", nil", encoded, err)
}
raw, err := EncodeLibraryTags([]LibraryTagSet{{Name: "动画", LibraryIDs: []string{"lib-1"}}})
if err != nil {
t.Fatalf("EncodeLibraryTags: %v", err)
}
user.LibraryTags = raw
decoded := user.DecodeLibraryTags()
if len(decoded) != 1 || decoded[0].Name != "动画" || len(decoded[0].LibraryIDs) != 1 || decoded[0].LibraryIDs[0] != "lib-1" {
t.Fatalf("DecodeLibraryTags = %#v", decoded)
}
user.LibraryTags = "{not json"
if decoded := user.DecodeLibraryTags(); decoded != nil {
t.Fatalf("corrupt payload should decode to nil, got %#v", decoded)
}
user.PopulateComputedFields()
if user.LibraryTagList != nil {
t.Fatalf("PopulateComputedFields should mirror DecodeLibraryTags, got %#v", user.LibraryTagList)
}
}
+6
View File
@@ -28,6 +28,11 @@ type User struct {
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
// LibraryTags 存储用户自定义的媒体库标签分组(JSON 字符串),
// 形如 [{"name":"动画","library_ids":["lib-1","lib-2"]}]。标签属于用户本人,
// 用于在媒体库页把同一标签下的媒体库聚合到一起。
LibraryTags string `gorm:"type:text" json:"-"`
LibraryTagList []LibraryTagSet `gorm:"-" json:"library_tags,omitempty"`
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
// original / simplified / traditional。
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
@@ -103,4 +108,5 @@ func (u *User) PopulateComputedFields() {
}
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
u.PinnedLibraryList = u.DecodePinnedLibraryIDs()
u.LibraryTagList = u.DecodeLibraryTags()
}
+13 -13
View File
@@ -2,7 +2,6 @@ package service
import (
"context"
"sort"
"strings"
"time"
@@ -179,9 +178,18 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
libraryIDs = []string{media.LibraryID}
}
filter := repository.MediaQueryFilter{IncludeNSFW: true}
candidates, narrowed, err := s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
if err != nil {
return nil, err
// 成人条目按番号分组,而番号常常只存在于路径/标题里:本地 NFO 来源的分片
// 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部
// ID 预先收窄候选集,同番号的其它分片会被 SQL 直接排除,表现就是库里折叠
// 出了 N 个版本、详情页却只列出带 ID 的那几个。因此成人条目不预先收窄,
// 直接在该库范围内比对版本键。
var candidates []model.Media
narrowed := false
if mediaAdultGroupCode(*media) == "" {
candidates, narrowed, err = s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
if err != nil {
return nil, err
}
}
if !narrowed {
candidates, err = s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
@@ -199,14 +207,6 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
if len(matched) == 0 {
return []model.Media{*media}, nil
}
sort.SliceStable(matched, func(i, j int) bool {
if matched[i].ID == media.ID {
return true
}
if matched[j].ID == media.ID {
return false
}
return betterMediaVersion(matched[i], matched[j])
})
sortMediaVersionsForDisplay(matched)
return matched, nil
}
+141 -18
View File
@@ -3,7 +3,9 @@ package service
import (
"fmt"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/truewhile/MeBox/internal/model"
@@ -76,34 +78,28 @@ func groupMediaVersions(items []model.Media) []MediaItem {
return nil
}
type group struct {
key string
primary model.Media
rows []model.Media
key string
rows []model.Media
}
groups := make([]group, 0, len(items))
byKey := make(map[string]int, len(items))
for _, item := range items {
key := mediaVersionGroupKey(item)
if key == "" {
groups = append(groups, group{primary: item, rows: []model.Media{item}})
groups = append(groups, group{rows: []model.Media{item}})
continue
}
if idx, ok := byKey[key]; ok {
groups[idx].rows = append(groups[idx].rows, item)
if betterMediaVersion(item, groups[idx].primary) {
groups[idx].primary = item
}
continue
}
byKey[key] = len(groups)
groups = append(groups, group{key: key, primary: item, rows: []model.Media{item}})
groups = append(groups, group{key: key, rows: []model.Media{item}})
}
out := make([]MediaItem, 0, len(groups))
for _, g := range groups {
sort.SliceStable(g.rows, func(i, j int) bool {
return betterMediaVersion(g.rows[i], g.rows[j])
})
item := MediaItem{Media: g.primary}
sortMediaVersionsForDisplay(g.rows)
item := MediaItem{Media: g.rows[0]}
if len(g.rows) > 1 {
item.Versions = g.rows
}
@@ -193,6 +189,18 @@ func mediaVersionGroupKey(m model.Media) string {
return mediaVersionStemGroupKey(m, libKey)
}
// 成人条目按番号折叠。在线刮削(MetaTube)会把番号与 provider「借用」
// 存进 douban_id/thetvdb_id,本地 NFO 路径则拿不到这两个字段;一旦按
// 外部 ID 分组,同一部片的各个分片就会因为元数据来源不同(在线刮削 vs
// 本地 NFO)裂成两张标题相同的卡。番号取自原文件名/路径/标题,与元数据
// 来源无关,因此这里统一用番号做版本身份。
if code := mediaAdultGroupCode(m); code != "" {
if libKey == "" {
libKey = "_"
}
return fmt.Sprintf("adult:%s:%s", libKey, code)
}
switch {
case m.TMDbID > 0:
if libKey != "" {
@@ -267,6 +275,44 @@ func mediaVersionGroupKey(m model.Media) string {
return fmt.Sprintf("movie:%s:%d", title, year)
}
// mediaAdultGroupCode 返回成人条目的番号,作为与元数据来源无关的版本身份。
//
// 只在已经确认是成人条目(NSFW)时才返回番号:番号本身来自原文件名/路径/
// 标题,不会随「在线刮削(MetaTube 把番号与 provider 借用进 douban_id/
// thetvdb_id)」还是「本地 NFO(不带外部 ID)」而改变,因此两条来源能算出
// 同一个键。未标记 NSFW 的条目仍走标题分组,普通影视库里 "The Matrix 1999"
// 这类文件名不会被当成番号。
func mediaAdultGroupCode(m model.Media) string {
if !m.NSFW {
return ""
}
code := firstText(
normalizeAdultCode(m.OriginalName),
AdultCodeFromMediaPath(m.Path),
normalizeAdultCode(m.Title),
)
if code == "" {
return ""
}
return canonicalAdultGroupCode(code)
}
// canonicalAdultGroupCode 去掉番号数字部分的补零,让 IPVR-00192 与 IPVR-192
// 这类同一部片的不同写法落到同一个分组键上。无法解析数字的番号原样返回
// (如 FC2-PPV-4701981)。
func canonicalAdultGroupCode(code string) string {
code = strings.ToUpper(strings.TrimSpace(code))
prefix, digits, ok := strings.Cut(code, "-")
if !ok {
return code
}
value, err := strconv.Atoi(digits)
if err != nil {
return code
}
return prefix + "-" + strconv.Itoa(value)
}
// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
@@ -300,8 +346,11 @@ func mediaVersionFileStem(name string) string {
}
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
// 当只有容器等无法区分多版本的信息时,回退到清理后的文件名。
func MediaVersionLabel(m model.Media) string {
parts := make([]string, 0, 4)
isStrm := strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") ||
strings.TrimSpace(m.STRMURL) != ""
if m.Height > 0 {
parts = append(parts, fmt.Sprintf("%dp", m.Height))
} else if m.Width > 0 {
@@ -317,17 +366,91 @@ func MediaVersionLabel(m model.Media) string {
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
parts = append(parts, strings.ToUpper(codec))
}
if m.SizeBytes > 0 {
// STRM 占位体积(通常几十到几百字节)不能区分分片,跳过。
placeholderSize := isStrm && m.SizeBytes > 0 && m.SizeBytes < 1024*1024
if m.SizeBytes > 0 && !placeholderSize {
parts = append(parts, formatMediaSize(m.SizeBytes))
}
if len(parts) > 0 {
if len(parts) > 0 && !mediaVersionLabelIndistinct(parts) {
return strings.Join(parts, " · ")
}
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/"))
if base == "" || base == "." {
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
if cleaned := cleanMediaVersionFilename(m.Path); cleaned != "" {
return cleaned
}
return base
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
}
func mediaVersionLabelIndistinct(parts []string) bool {
if len(parts) == 0 {
return true
}
for _, part := range parts {
upper := strings.ToUpper(strings.TrimSpace(part))
if upper == "" || upper == "云端直链" || upper == "STRM" {
continue
}
if _, ok := videoExtensions["."+strings.ToLower(upper)]; ok {
continue
}
return false
}
return true
}
func cleanMediaVersionFilename(path string) string {
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
if base == "" || base == "." {
return ""
}
stem := mediaFileStem(base)
stem = strings.ReplaceAll(stem, "_", " ")
stem = strings.ReplaceAll(stem, ".", " ")
return strings.Join(strings.Fields(stem), " ")
}
// mediaPartNumber 从路径提取分片序号(part1 / cd2 / -3)。
// 裸后缀只认 1–2 位数字,避免把年份 2024 当成 part。
func mediaPartNumber(path string) (int, bool) {
stem := mediaFileStem(filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")))
if stem == "" {
return 0, false
}
lower := strings.ToLower(stem)
for _, re := range mediaPartNumberPatterns {
if m := re.FindStringSubmatch(lower); len(m) == 2 {
n, err := strconv.Atoi(m[1])
if err == nil && n > 0 {
return n, true
}
}
}
return 0, false
}
var mediaPartNumberPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?:^|[.\-_])part[\s._-]*(\d{1,3})$`),
regexp.MustCompile(`(?:^|[.\-_])(?:cd|disc|disk)[\s._-]*(\d{1,3})$`),
regexp.MustCompile(`[-_](\d{1,2})$`),
}
func sortMediaVersionsForDisplay(rows []model.Media) {
sort.SliceStable(rows, func(i, j int) bool {
return compareMediaVersionsForDisplay(rows[i], rows[j])
})
}
// compareMediaVersionsForDisplay:有分片号时按 1→2→3… 升序;否则按画质/体积择优。
func compareMediaVersionsForDisplay(a, b model.Media) bool {
pa, oka := mediaPartNumber(a.Path)
pb, okb := mediaPartNumber(b.Path)
if oka && okb {
if pa != pb {
return pa < pb
}
} else if oka != okb {
return oka
}
return betterMediaVersion(a, b)
}
func mediaVersionContainerFromPath(path, strmURL string) string {
+295
View File
@@ -442,3 +442,298 @@ func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) {
t.Fatalf("unexpected label %q", label)
}
}
func TestMediaPartNumberFromPath(t *testing.T) {
cases := []struct {
path string
want int
ok bool
}{
{"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm", 1, true},
{"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm", 2, true},
{"/media/云下载/sivr-270/sivr-270-1.strm", 1, true},
{"/media/云下载/sivr-270/sivr-270-7.strm", 7, true},
{"/media/云下载/fc2/FC2PPV-4701981-cd2.strm", 2, true},
{"/media/Movies/Inception.2010.1080p.BluRay.x264.mkv", 0, false},
{"/media/Movies/Movie.2024.mkv", 0, false},
}
for _, tc := range cases {
got, ok := mediaPartNumber(tc.path)
if ok != tc.ok || got != tc.want {
t.Fatalf("mediaPartNumber(%q) = (%d, %v), want (%d, %v)", tc.path, got, ok, tc.want, tc.ok)
}
}
}
func TestGroupMediaVersionsSortsPartsAscending(t *testing.T) {
rows := []model.Media{
{
Base: model.Base{ID: "p3"},
LibraryID: "cloud",
Title: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-3.strm",
Year: 2023,
NSFW: true,
SizeBytes: 300,
},
{
Base: model.Base{ID: "p1"},
LibraryID: "cloud",
Title: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
Year: 2023,
NSFW: true,
SizeBytes: 100,
},
{
Base: model.Base{ID: "p2"},
LibraryID: "cloud",
Title: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-2.strm",
Year: 2023,
NSFW: true,
SizeBytes: 200,
},
}
grouped := groupMediaVersions(rows)
if len(grouped) != 1 || len(grouped[0].Versions) != 3 {
t.Fatalf("grouped = %#v", grouped)
}
want := []string{"p1", "p2", "p3"}
for i, id := range want {
if grouped[0].Versions[i].ID != id {
t.Fatalf("versions[%d] = %q, want %q (order %#v)", i, grouped[0].Versions[i].ID, id, grouped[0].Versions)
}
}
if grouped[0].ID != "p1" {
t.Fatalf("primary = %q, want p1 (first part)", grouped[0].ID)
}
}
func TestMediaVersionLabelFallsBackToFilenameWhenIndistinct(t *testing.T) {
label := MediaVersionLabel(model.Media{
Title: "SIVR-270-【VR】河北彩花",
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
SizeBytes: 157,
STRMURL: "/api/strm/play/cloud115/video.mp4?pickcode=a",
})
if !strings.Contains(label, "sivr-270-1") {
t.Fatalf("indistinct strm label should fall back to filename, got %q", label)
}
if strings.EqualFold(label, "MP4") || strings.Contains(label, "云端") {
t.Fatalf("should not keep generic-only label %q", label)
}
}
// SIVR-270 现场:一部分分片走在线刮削(MetaTube 把番号/provider 借用进
// douban_id/thetvdb_id),另一部分只从本地 NFO 拿到标题(没有外部 ID)。
// 按外部 ID 分组会把它们裂成两张标题完全相同的卡,必须按番号折成一张。
func TestGroupMediaVersionsMergesAdultPartsAcrossMetadataSources(t *testing.T) {
online := model.Media{
Base: model.Base{ID: "sivr-270-2"},
LibraryID: "cloud",
Title: "SIVR-270-【VR】河北彩花",
OriginalName: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-2.strm",
Year: 2023,
NSFW: true,
DoubanID: "SIVR-270",
TheTVDBID: "JavBus",
ScrapeStatus: "matched",
SizeBytes: 200,
}
fromNFO := model.Media{
Base: model.Base{ID: "sivr-270-3"},
LibraryID: "cloud",
Title: "SIVR-270-【VR】河北彩花",
OriginalName: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-3.strm",
Year: 2023,
NSFW: true,
ScrapeStatus: "matched",
SizeBytes: 100,
}
grouped := groupMediaVersions([]model.Media{online, fromNFO})
if len(grouped) != 1 {
t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped)
}
if len(grouped[0].Versions) != 2 {
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
}
if grouped[0].Path != online.Path {
t.Fatalf("larger part should stay primary, got %q", grouped[0].Path)
}
}
func TestGroupMediaVersionsKeepsDifferentAdultCodesSeparate(t *testing.T) {
rows := []model.Media{
{
Base: model.Base{ID: "sivr-270"},
LibraryID: "cloud",
Title: "SIVR-270 作品",
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
Year: 2023,
NSFW: true,
},
{
Base: model.Base{ID: "sivr-271"},
LibraryID: "cloud",
Title: "SIVR-271 作品",
Path: "/media/云下载/sivr-271/sivr-271-1.strm",
Year: 2023,
NSFW: true,
},
}
grouped := groupMediaVersions(rows)
if len(grouped) != 2 {
t.Fatalf("different adult codes must stay separate: %#v", grouped)
}
}
// 同一部片的两种写法:路径里的 IPVR-00192 与刮削回来的 IPVR-192 必须归一。
func TestGroupMediaVersionsMergesAdultCodesIgnoringZeroPadding(t *testing.T) {
scraped := model.Media{
Base: model.Base{ID: "ipvr-192-part2"},
LibraryID: "cloud",
Title: "IPVR-192-相沢みなみ",
OriginalName: "IPVR-192",
Path: "/media/云下载/ipvr00192pl/scraped/part2.strm",
Year: 2022,
NSFW: true,
ScrapeStatus: "matched",
SizeBytes: 200,
}
fromPath := model.Media{
Base: model.Base{ID: "ipvr-00192-part1"},
LibraryID: "cloud",
Title: "fbzip com@ipvr00192",
Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm",
Year: 2022,
NSFW: true,
SizeBytes: 100,
}
grouped := groupMediaVersions([]model.Media{scraped, fromPath})
if len(grouped) != 1 || len(grouped[0].Versions) != 2 {
t.Fatalf("zero padded code must fold into one item: %#v", grouped)
}
}
func TestGroupMediaVersionsMergesAdultPartsWithBorrowedMetaTubeIDs(t *testing.T) {
// IPVR-00192 现场:两个 part 标题、年份完全一样,但 part2 是走 MetaTube
// 刮削的(番号/provider 被借用进 douban_id/thetvdb_id),part1 只有本地
// NFO 身份、两个字段为空。旧逻辑按外部 ID 分组会裂成两张卡。
part1 := model.Media{
Base: model.Base{ID: "ipvr-00192-part1"},
LibraryID: "cloud",
Title: "IPVR-00192-IPVR-192-【VR】相沢みなみ",
OriginalName: "IPVR-00192",
Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm",
Year: 2022,
NSFW: true,
ScrapeStatus: "matched",
SizeBytes: 100,
}
part2 := part1
part2.ID = "ipvr-00192-part2"
part2.Path = "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm"
part2.DoubanID = "ipvr00192"
part2.TheTVDBID = "JAV321"
part2.SizeBytes = 200
grouped := groupMediaVersions([]model.Media{part1, part2})
if len(grouped) != 1 {
t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped)
}
if len(grouped[0].Versions) != 2 {
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
}
if grouped[0].Versions[0].ID != "ipvr-00192-part1" || grouped[0].Versions[1].ID != "ipvr-00192-part2" {
t.Fatalf("versions should be part1 then part2, got %#v", grouped[0].Versions)
}
}
func TestGetMediaItemListsAdultVersionsAcrossMetadataSources(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "云下载", Path: "/media/云下载", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := []model.Media{
{
// 走 MetaTube 刮削的分片:番号/provider 被借用进 douban/thetvdb。
LibraryID: lib.ID,
Title: "SIVR-270-【VR】河北彩花",
OriginalName: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
Year: 2023,
NSFW: true,
DoubanID: "SIVR-270",
TheTVDBID: "JavBus",
ScrapeStatus: "matched",
SizeBytes: 200,
},
{
// 只有本地 NFO 身份的分片:两个外部 ID 都为空。
LibraryID: lib.ID,
Title: "SIVR-270-【VR】河北彩花",
OriginalName: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-4.strm",
Year: 2023,
NSFW: true,
ScrapeStatus: "matched",
SizeBytes: 100,
},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
item, err := svc.GetMediaItem(t.Context(), rows[0].ID)
if err != nil {
t.Fatal(err)
}
if item == nil || len(item.Versions) != 2 {
// 详情页版本列表不能只按外部 ID 收窄,否则没有 ID 的分片会消失。
t.Fatalf("versions = %#v, want both parts", item)
}
}
func TestMediaAdultGroupCodeRequiresNSFW(t *testing.T) {
cases := []struct {
name string
m model.Media
want string
}{
{
name: "普通影片名里的字母+数字不当番号",
m: model.Media{Title: "Spider-Man 2008 1080p", Path: "/media/Movies/Spider-Man 2008 1080p.mkv"},
want: "",
},
{
name: "未识别成人的条目即使文件名像番号也不折叠",
m: model.Media{Title: "SIVR-270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"},
want: "",
},
{
name: "已标记 NSFW 时按番号分组",
m: model.Media{NSFW: true, Title: "sivr 270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"},
want: "SIVR-270",
},
{
name: "站点前缀里带补零的番号会归一",
m: model.Media{NSFW: true, Title: "fbzip com@ipvr00192", Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm"},
want: "IPVR-192",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := mediaAdultGroupCode(tc.m); got != tc.want {
t.Fatalf("mediaAdultGroupCode = %q, want %q", got, tc.want)
}
})
}
}
+44 -14
View File
@@ -18,6 +18,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"go.uber.org/zap"
@@ -48,9 +49,13 @@ type movieNFO struct {
Poster string `xml:"thumb,omitempty"`
Fanart string `xml:"fanart,omitempty"`
TMDb int `xml:"tmdbid,omitempty"`
Genre []string `xml:"genre,omitempty"`
Country []string `xml:"country,omitempty"`
Language []string `xml:"language,omitempty"`
// UniqueIDs 让 sidecar 能把 MeBox 自己用的外部 ID 原样带回来。缺了它,
// 「在线刮削 → 写 NFO → 重扫读 NFO」会丢掉 douban/thetvdb(成人条目借用
// 这两个字段存番号/provider),同一部片的两条来源就会算出不同的版本键。
UniqueIDs []nfoUniqueID `xml:"uniqueid,omitempty"`
Genre []string `xml:"genre,omitempty"`
Country []string `xml:"country,omitempty"`
Language []string `xml:"language,omitempty"`
}
type episodeNFO struct {
@@ -167,17 +172,18 @@ func WriteMediaNFO(m *model.Media) (string, error) {
}
}
doc = movieNFO{
Title: title,
Original: original,
Year: m.Year,
Plot: m.Overview,
Rating: m.Rating,
Poster: m.PosterURL,
Fanart: m.BackdropURL,
TMDb: m.TMDbID,
Genre: splitNFOList(m.Genres),
Country: splitNFOList(m.Countries),
Language: splitNFOList(m.Languages),
Title: title,
Original: original,
Year: m.Year,
Plot: m.Overview,
Rating: m.Rating,
Poster: m.PosterURL,
Fanart: m.BackdropURL,
TMDb: m.TMDbID,
UniqueIDs: nfoExternalUniqueIDs(m),
Genre: splitNFOList(m.Genres),
Country: splitNFOList(m.Countries),
Language: splitNFOList(m.Languages),
}
}
out, err := xml.MarshalIndent(doc, "", " ")
@@ -202,3 +208,27 @@ func splitNFOList(value string) []string {
}
return out
}
// nfoExternalUniqueIDs 把 MeBox 使用的外部 ID 写进 sidecar,供自己重扫时读回
// (metadataFromDoc 会按 type 还原成 bangumi/douban/thetvdb 字段)。
// TMDb 走 <tmdbid>,单集 NFO 不写唯一 ID:单集 ID 属于单集,写进整剧身份会把
// 同一部剧拆成多张卡(见 mergeEpisodeMetadata 的约束说明)。
func nfoExternalUniqueIDs(m *model.Media) []nfoUniqueID {
if m == nil {
return nil
}
out := make([]nfoUniqueID, 0, 3)
if m.BangumiID > 0 {
out = append(out, nfoUniqueID{Type: "bangumi", Value: strconv.Itoa(m.BangumiID)})
}
if id := strings.TrimSpace(m.DoubanID); id != "" {
out = append(out, nfoUniqueID{Type: "douban", Value: id})
}
if id := strings.TrimSpace(m.TheTVDBID); id != "" {
out = append(out, nfoUniqueID{Type: "thetvdb", Value: id})
}
if len(out) == 0 {
return nil
}
return out
}
+48
View File
@@ -100,3 +100,51 @@ func TestWriteMediaNFOAdultTitleWithCode(t *testing.T) {
t.Fatalf("expected originaltitle with code, got:\n%s", text)
}
}
func TestWriteMediaNFORoundTripsExternalUniqueIDs(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "云下载", "sivr-270", "sivr-270-1.strm")
if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(mediaPath, []byte("strm"), 0o644); err != nil {
t.Fatal(err)
}
dst, err := WriteMediaNFO(&model.Media{
Title: "SIVR-270-河北彩花",
OriginalName: "SIVR-270",
Path: mediaPath,
Year: 2023,
NSFW: true,
DoubanID: "SIVR-270",
TheTVDBID: "JavBus",
})
if err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
text := string(body)
// 成人条目把番号/provider 借用存在 douban/thetvdb 两列;sidecar 必须把它们
// 带回来,否则「在线刮削 → 写 NFO → 重扫读 NFO」之后同一部片会被按不同
// 分组键拆成多张卡。
for _, want := range []string{
`<uniqueid type="douban">SIVR-270</uniqueid>`,
`<uniqueid type="thetvdb">JavBus</uniqueid>`,
} {
if !strings.Contains(text, want) {
t.Fatalf("sidecar missing %s, got:\n%s", want, text)
}
}
meta, err := ReadLocalMetadata(mediaPath, "", false)
if err != nil {
t.Fatal(err)
}
if meta == nil || meta.DoubanID != "SIVR-270" || meta.TheTVDBID != "JavBus" {
t.Fatalf("external ids did not round trip: %#v", meta)
}
}
+79
View File
@@ -144,6 +144,85 @@ func (p *ProfileService) SetPinnedLibraryIDs(ctx context.Context, userID string,
return normalized, nil
}
// GetLibraryTags returns the user's library tag groups, filtered to libraries
// the user can still access. Empty tags are kept so an editor does not lose a
// tag that was just created.
func (p *ProfileService) GetLibraryTags(ctx context.Context, userID string) ([]model.LibraryTagSet, error) {
user, err := p.repo.User.FindByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.New("user not found")
}
tags := model.NormalizeLibraryTags(user.DecodeLibraryTags())
if len(tags) == 0 {
return []model.LibraryTagSet{}, nil
}
visibility := UserDefaultMediaVisibility(ctx, p.repo, userID)
accessible, err := p.accessibleLibraryIDSet(ctx, visibility)
if err != nil {
return nil, err
}
for i := range tags {
tags[i].LibraryIDs = filterPinnedLibraryIDs(tags[i].LibraryIDs, accessible)
if tags[i].LibraryIDs == nil {
tags[i].LibraryIDs = []string{}
}
}
return tags, nil
}
// SetLibraryTags persists the user's library tag groups after dropping
// inaccessible libraries. A library belongs to at most one tag: the first tag
// that lists it wins, so the saved state always matches the tab UI.
func (p *ProfileService) SetLibraryTags(ctx context.Context, userID string, tags []model.LibraryTagSet) ([]model.LibraryTagSet, error) {
if userID == "" {
return nil, errors.New("missing user id")
}
user, err := p.repo.User.FindByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.New("user not found")
}
visibility := UserDefaultMediaVisibility(ctx, p.repo, userID)
accessible, err := p.accessibleLibraryIDSet(ctx, visibility)
if err != nil {
return nil, err
}
normalized := model.NormalizeLibraryTags(tags)
if len(normalized) > model.MaxLibraryTags {
normalized = normalized[:model.MaxLibraryTags]
}
claimed := make(map[string]struct{})
for i := range normalized {
filtered := make([]string, 0, len(normalized[i].LibraryIDs))
for _, id := range filterPinnedLibraryIDs(normalized[i].LibraryIDs, accessible) {
if _, taken := claimed[id]; taken {
continue
}
claimed[id] = struct{}{}
filtered = append(filtered, id)
}
normalized[i].LibraryIDs = filtered
}
raw, err := model.EncodeLibraryTags(normalized)
if err != nil {
return nil, err
}
if err := p.repo.User.UpdateFields(ctx, userID, map[string]any{
"library_tags": raw,
}); err != nil {
return nil, err
}
if normalized == nil {
normalized = []model.LibraryTagSet{}
}
return normalized, nil
}
func (p *ProfileService) accessibleLibraryIDSet(ctx context.Context, visibility MediaVisibility) (map[string]struct{}, error) {
libs, err := p.repo.Library.List(ctx)
if err != nil {
@@ -0,0 +1,150 @@
package service
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"go.uber.org/zap"
)
func TestProfileLibraryTagsFiltersInaccessibleAndKeepsOneTagPerLibrary(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.EmbyMount{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := NewProfileService(zap.NewNop(), repos)
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatal(err)
}
libA := &model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
libB := &model.Library{Name: "TV", Path: "/media/tv", Type: "tv", Enabled: true}
libHidden := &model.Library{Name: "Adult", Path: "/media/adult", Type: "movie", Enabled: true}
for _, lib := range []*model.Library{libA, libB, libHidden} {
if err := repos.Library.Create(t.Context(), lib); err != nil {
t.Fatal(err)
}
}
if err := repos.User.UpdateFields(t.Context(), user.ID, map[string]any{
"allowed_library_ids": `["` + libA.ID + `","` + libB.ID + `"]`,
}); err != nil {
t.Fatal(err)
}
got, err := svc.SetLibraryTags(t.Context(), user.ID, []model.LibraryTagSet{
{Name: " 热门 ", LibraryIDs: []string{libA.ID, libHidden.ID, "missing", libA.ID}},
{Name: "热门", LibraryIDs: []string{libB.ID, libA.ID}},
{Name: "", LibraryIDs: []string{libB.ID}},
})
if err != nil {
t.Fatalf("SetLibraryTags: %v", err)
}
if len(got) != 1 {
t.Fatalf("SetLibraryTags returned %d tags, want 1 (%#v)", len(got), got)
}
if got[0].Name != "热门" {
t.Fatalf("tag name = %q, want 热门", got[0].Name)
}
// libA 已在第一个标签里占位,第二个标签里的 libA 应被丢弃。
want := []string{libA.ID, libB.ID}
if len(got[0].LibraryIDs) != len(want) {
t.Fatalf("tag ids = %v, want %v", got[0].LibraryIDs, want)
}
for i := range want {
if got[0].LibraryIDs[i] != want[i] {
t.Fatalf("tag ids = %v, want %v", got[0].LibraryIDs, want)
}
}
loaded, err := svc.GetLibraryTags(t.Context(), user.ID)
if err != nil {
t.Fatalf("GetLibraryTags: %v", err)
}
if len(loaded) != 1 || loaded[0].Name != "热门" || len(loaded[0].LibraryIDs) != len(want) {
t.Fatalf("GetLibraryTags = %#v, want one tag with %v", loaded, want)
}
}
func TestProfileLibraryTagsKeepsEmptyTagAndMountedEmby(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.EmbyMount{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := NewProfileService(zap.NewNop(), repos)
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatal(err)
}
mount := &model.EmbyMount{
AccountID: "acct-1",
RemoteViewID: "view-42",
RemoteViewName: "Remote Movies",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatal(err)
}
remoteID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
got, err := svc.SetLibraryTags(t.Context(), user.ID, []model.LibraryTagSet{
{Name: "远程", LibraryIDs: []string{remoteID, "embyremote~missing~view"}},
{Name: "以后再用", LibraryIDs: []string{}},
})
if err != nil {
t.Fatalf("SetLibraryTags: %v", err)
}
if len(got) != 2 {
t.Fatalf("SetLibraryTags = %#v, want 2 tags", got)
}
if len(got[0].LibraryIDs) != 1 || got[0].LibraryIDs[0] != remoteID {
t.Fatalf("remote tag ids = %v, want [%s]", got[0].LibraryIDs, remoteID)
}
if got[1].Name != "以后再用" || len(got[1].LibraryIDs) != 0 {
t.Fatalf("empty tag should survive as-is, got %#v", got[1])
}
loaded, err := svc.GetLibraryTags(t.Context(), user.ID)
if err != nil {
t.Fatalf("GetLibraryTags: %v", err)
}
if len(loaded) != 2 || len(loaded[1].LibraryIDs) != 0 {
t.Fatalf("GetLibraryTags = %#v, want the empty tag preserved", loaded)
}
}
func TestProfileLibraryTagsEmptyStateReturnsEmptySlice(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.EmbyMount{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := NewProfileService(zap.NewNop(), repos)
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatal(err)
}
if tags, err := svc.GetLibraryTags(t.Context(), user.ID); err != nil || tags == nil || len(tags) != 0 {
t.Fatalf("GetLibraryTags = %#v, %v; want empty non-nil slice", tags, err)
}
if tags, err := svc.SetLibraryTags(t.Context(), user.ID, nil); err != nil || tags == nil || len(tags) != 0 {
t.Fatalf("SetLibraryTags(nil) = %#v, %v; want empty non-nil slice", tags, err)
}
}
+84 -11
View File
@@ -174,38 +174,111 @@ func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string
return nil, nil
}
// ErrStrmProxyNotApplicable 表示该媒体没有需要服务端转发的直链(本地文件本身同源)。
var ErrStrmProxyNotApplicable = errors.New("strm proxy not applicable")
// ProxyMediaDirect 把媒体行的网盘/STRM 直链解析为真实地址后由服务端反向代理给
// 客户端,让浏览器拿到「同源」数据。画质与原文件完全一致,不触发任何转码。
//
// 用途:VR 全景播放要把视频帧读进 WebGL 纹理,而跨域直链(网盘 302 跳到 CDN)
// 在浏览器里属于被污染的资源,WebGL 读取会抛 SecurityError;把流量经服务端转发
// 是「原画 + VR」唯一可行的办法。
func (s *StrmService) ProxyMediaDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, m *model.Media) error {
if s == nil || m == nil {
return ErrStrmProxyNotApplicable
}
raw := strings.TrimSpace(m.STRMURL)
if raw == "" {
path := strings.TrimSpace(m.Path)
if !strings.HasSuffix(strings.ToLower(path), ".strm") {
return ErrStrmProxyNotApplicable
}
target, err := readLocalSTRMTarget(path)
if err != nil {
return err
}
raw = strings.TrimSpace(target)
}
if raw == "" {
return ErrStrmProxyNotApplicable
}
result, err := s.ResolvePlayTarget(ctx, raw)
if err != nil {
return err
}
switch {
case result.Link != nil && result.Link.URL != "":
return s.ProxyDirect(ctx, w, r, result.Link)
case result.RedirectURL != "":
return s.ProxyDirect(ctx, w, r, &cloud.DirectLink{URL: result.RedirectURL})
default:
// 本地文件(LocalPath)由静态文件处理器提供,本身就是同源。
return ErrStrmProxyNotApplicable
}
}
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
if link == nil || link.URL == "" {
return errors.New("空直链")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil)
method := http.MethodGet
if r != nil && r.Method == http.MethodHead {
method = http.MethodHead
}
req, err := http.NewRequestWithContext(ctx, method, link.URL, nil)
if err != nil {
return err
}
for k, v := range link.Headers {
req.Header.Set(k, v)
}
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
if r != nil {
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
// 部分网盘直链按 UA 防盗链;解析时未绑定 UA 的直链沿用浏览器 UA 更稳。
if ua := strings.TrimSpace(r.Header.Get("User-Agent")); ua != "" && req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", ua)
}
}
resp, err := s.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag"} {
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
if value := resp.Header.Get(header); value != "" {
w.Header().Set(header, value)
}
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(resp.StatusCode)
// 原样透传上游状态码:Range 请求必须回 206,改写成 200 会让浏览器误判
// 响应长度,拖动进度条时反复重新拉流。
w.WriteHeader(resp.StatusCode)
if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK {
return nil
}
if resp.StatusCode == http.StatusPartialContent || resp.StatusCode == http.StatusOK {
_, _ = io.Copy(w, resp.Body)
if method == http.MethodHead {
return nil
}
return nil
// 边转发边 flush,避免大体积视频被 net/http 的写缓冲切成一段段卡顿。
writer := io.Writer(w)
if flusher, ok := w.(http.Flusher); ok {
writer = &flushWriter{writer: w, flusher: flusher}
}
_, err = io.Copy(writer, resp.Body)
return err
}
type flushWriter struct {
writer io.Writer
flusher http.Flusher
}
func (f *flushWriter) Write(p []byte) (int, error) {
n, err := f.writer.Write(p)
if f.flusher != nil {
f.flusher.Flush()
}
return n, err
}
+74
View File
@@ -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())
}
}
+7 -2
View File
@@ -141,8 +141,13 @@ const profileQuery = () => {
// streamURL returns a direct-play URL for <video src>. The JWT is added as
// a query parameter because <video> elements cannot send Authorization
// headers.
export function streamURL(mediaId: string): string {
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}`
//
// proxy=true 时由服务端把网盘/STRM 直链转发为同源数据(画质与原文件一致、
// 不转码)。VR 全景渲染需要把视频帧读进 WebGL 纹理,跨域直链会被浏览器
// 判定为污染资源而禁止读取,因此只有这种场景才需要开启。
export function streamURL(mediaId: string, options: { proxy?: boolean } = {}): string {
const proxy = options.proxy ? '&proxy=1' : ''
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}${proxy}`
}
// hlsURL returns the m3u8 playlist URL fed into hls.js.
+7 -1
View File
@@ -1,5 +1,5 @@
import { api } from './client'
import type { User } from '../types'
import type { LibraryTagSet, User } from '../types'
export const profileAPI = {
get: () => api.get<User>('/me').then((r) => r.data),
@@ -21,6 +21,12 @@ export const profileAPI = {
setPinnedLibraries: (libraryIds: string[]) =>
api.put<{ library_ids: string[] }>('/me/pinned-libraries', { library_ids: libraryIds }).then((r) => r.data.library_ids ?? []),
getLibraryTags: () =>
api.get<{ tags: LibraryTagSet[] | null }>('/me/library-tags').then((r) => r.data.tags ?? []),
setLibraryTags: (tags: LibraryTagSet[]) =>
api.put<{ tags: LibraryTagSet[] | null }>('/me/library-tags', { tags }).then((r) => r.data.tags ?? []),
adminUpdateRole: (id: string, role: 'admin' | 'user') =>
api.patch<User>(`/admin/users/${id}/role`, { role }).then((r) => r.data),
}
@@ -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>
)
}
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Layers, Plus, Settings2, X } from 'lucide-react'
import { MAX_LIBRARY_TAGS } from '../utils/libraryTags'
import type { LibraryTagTab } from '../utils/libraryTags'
/**
* 媒体库标签栏:固定「全部」+ 每个标签一个页签,选中后只展示该标签下的媒体库。
* 只是管理员/普通用户都能用的展示层过滤,不改动媒体库本身。
* 标签顺序由「管理标签」里的拖拽决定,这里只负责展示与选中。
*/
export function LibraryTagBar({
tabs,
selectedTagId,
onSelect,
onCreate,
onManage,
manageLabel = '管理标签',
showCreate = true,
busy = false,
}: {
tabs: LibraryTagTab[]
selectedTagId: string
onSelect: (tagId: string) => void
onCreate?: (name: string) => void
onManage?: () => void
manageLabel?: string
showCreate?: boolean
busy?: boolean
}) {
const [creating, setCreating] = useState(false)
const [draft, setDraft] = useState('')
const inputRef = useRef<HTMLInputElement | null>(null)
// 同一帧内 blur 与 Enter 都可能触发提交:用一个标记保证只提交一次。
const submittedRef = useRef(false)
useEffect(() => {
if (creating) {
submittedRef.current = false
inputRef.current?.focus()
}
}, [creating])
const hasTags = useMemo(() => tabs.some((tab) => !tab.isAll), [tabs])
const submit = () => {
if (submittedRef.current) return
submittedRef.current = true
const name = draft.trim()
setDraft('')
setCreating(false)
if (!name) return
onCreate?.(name)
}
const cancel = () => {
submittedRef.current = true
setDraft('')
setCreating(false)
}
return (
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--app-border)] pb-3">
<div className="flex flex-wrap items-center gap-1.5">
{tabs.map((tab) => {
const active = tab.id === selectedTagId
return (
<button
key={tab.id}
type="button"
onClick={() => onSelect(tab.id)}
aria-pressed={active}
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-bold transition-colors ${
active
? 'border-brand-500/60 bg-brand-500/10 text-brand-600'
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)] text-[var(--app-subtle)] hover:border-brand-500/40 hover:text-[var(--app-text)]'
}`}
title={tab.isAll ? '显示全部媒体库' : `只看「${tab.name}」标签下的媒体库`}
>
{tab.isAll && <Layers size={13} />}
<span className="max-w-[8rem] truncate">{tab.name}</span>
<span className={`font-mono text-[10px] ${active ? 'text-brand-500' : 'text-[var(--app-muted)]'}`}>
{tab.count}
</span>
</button>
)
})}
</div>
{(showCreate || onManage) && (
<div className="flex flex-wrap items-center gap-1.5">
{showCreate && onCreate && (creating || !hasTags) && (
<div className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 py-1">
<input
ref={inputRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
submit()
}
if (event.key === 'Escape') {
event.preventDefault()
setDraft('')
setCreating(false)
}
}}
onBlur={() => submit()}
maxLength={24}
placeholder="新标签名"
className="h-6 w-24 bg-transparent text-xs font-semibold text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
title={`最多 24 个字,当前最多 ${MAX_LIBRARY_TAGS} 个标签`}
/>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={submit}
disabled={busy || !draft.trim()}
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)] disabled:opacity-40"
title="创建标签"
>
<Plus size={13} />
</button>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={cancel}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
title="取消"
>
<X size={13} />
</button>
</div>
)}
{showCreate && onCreate && !creating && hasTags && (
<button
type="button"
onClick={() => setCreating(true)}
disabled={busy || tabs.length - 1 >= MAX_LIBRARY_TAGS}
className="inline-flex items-center gap-1 rounded-xl border border-dashed border-[var(--app-border)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-muted)] transition-colors hover:border-brand-500/50 hover:text-brand-500 disabled:opacity-40"
title="新建标签"
>
<Plus size={13} />
<span>新建标签</span>
</button>
)}
{onManage && (
<button
type="button"
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"
title="重命名 / 删除标签,调整标签下的媒体库,拖拽排序"
>
<Settings2 size={13} />
<span>{manageLabel}</span>
</button>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,520 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check, GripVertical, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react'
import toast from 'react-hot-toast'
import { confirmAction } from './confirmAction'
import type { Library } from '../types'
import { useDragReorder } from '../hooks/useDragReorder'
import {
MAX_LIBRARY_TAGS,
filterLibrariesForTagging,
normalizeTagName,
tagNameError,
type LibraryTag,
} from '../utils/libraryTags'
export type ManageLibraryTagsDialogProps = {
tags: LibraryTag[]
libraries: Library[]
saving: boolean
onCreate: (name: string) => Promise<string>
onRename: (from: string, to: string) => Promise<void>
onRemove: (name: string) => Promise<void>
/** 按给定标签名顺序重排标签(拖拽排序的落点)。 */
onReorder: (orderedNames: string[]) => Promise<void>
/** 单个媒体库归类;tagName 为空表示移出所有标签。 */
onAssign: (libraryId: string, tagName: string) => Promise<void>
/** 批量归类;tagName 为空表示批量移出所有标签。一次写入。 */
onAssignBatch: (libraryIds: string[], tagName: string) => Promise<void>
onClose: () => void
}
/** 「未分类」在筛选/批量目标里的哨兵值,与标签名不会冲突。 */
const UNTAGGED = '__untagged__'
/**
* 标签管理对话框:左侧维护标签(新建 / 重命名 / 删除),右侧把媒体库归入
* 某个标签。一个媒体库同时只属于一个标签,未选择即为「未分类」。
*
* 右侧支持搜索、按当前标签筛选、多选后一次性批量归类,避免逐条点击下拉框。
*/
export function ManageLibraryTagsDialogView({
tags,
libraries,
saving,
onCreate,
onRename,
onRemove,
onReorder,
onAssign,
onAssignBatch,
onClose,
}: ManageLibraryTagsDialogProps) {
const [draftName, setDraftName] = useState('')
const [renaming, setRenaming] = useState('')
const [renameDraft, setRenameDraft] = useState('')
const [busyLibrary, setBusyLibrary] = useState('')
const [keyword, setKeyword] = useState('')
const [tagFilter, setTagFilter] = useState('')
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [batchTag, setBatchTag] = useState('')
const [applying, setApplying] = useState(false)
const { draggingId, dragOverId, dragProps } = useDragReorder(
tags.map((tag) => tag.name),
onReorder,
)
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose])
const tagByLibrary = useMemo(() => {
const map = new Map<string, string>()
for (const tag of tags) {
for (const id of tag.library_ids) {
if (!map.has(id)) map.set(id, tag.name)
}
}
return map
}, [tags])
// 标签被删除/重命名后,批量目标与筛选值可能已经失效,落回默认项。
useEffect(() => {
if (batchTag && batchTag !== UNTAGGED && !tags.some((tag) => tag.name === batchTag)) {
setBatchTag('')
}
if (tagFilter && tagFilter !== UNTAGGED && !tags.some((tag) => tag.name === tagFilter)) {
setTagFilter('')
}
}, [tags, batchTag, tagFilter])
// 媒体库被删除后清掉已选项,避免批量应用时带着不存在的 ID。
useEffect(() => {
const available = new Set(libraries.map((lib) => lib.id))
setSelectedIds((prev) => {
const next = prev.filter((id) => available.has(id))
return next.length === prev.length ? prev : next
})
}, [libraries])
const visibleLibraries = useMemo(() => {
const searched = filterLibrariesForTagging(libraries, keyword)
if (!tagFilter) return searched
return searched.filter((lib) => {
const current = tagByLibrary.get(lib.id) ?? ''
return tagFilter === UNTAGGED ? current === '' : current === tagFilter
})
}, [libraries, keyword, tagFilter, tagByLibrary])
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
const visibleSelectedCount = visibleLibraries.reduce(
(sum, lib) => sum + (selectedSet.has(lib.id) ? 1 : 0),
0,
)
const allVisibleSelected = visibleLibraries.length > 0 && visibleSelectedCount === visibleLibraries.length
const toggleLibrary = useCallback((libraryId: string) => {
setSelectedIds((prev) =>
prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId],
)
}, [])
const toggleVisible = useCallback(() => {
const visibleIds = visibleLibraries.map((lib) => lib.id)
setSelectedIds((prev) => {
const current = new Set(prev)
const everySelected = visibleIds.length > 0 && visibleIds.every((id) => current.has(id))
if (everySelected) return prev.filter((id) => !visibleIds.includes(id))
for (const id of visibleIds) current.add(id)
return Array.from(current)
})
}, [visibleLibraries])
const handleCreate = async () => {
const error = tagNameError(draftName, tags)
if (error) {
toast.error(error)
return
}
if (tags.length >= MAX_LIBRARY_TAGS) {
toast.error(`最多 ${MAX_LIBRARY_TAGS} 个标签`)
return
}
const created = await onCreate(normalizeTagName(draftName))
if (created) {
setDraftName('')
toast.success(`标签「${created}」已创建`)
} else {
toast.error('标签创建失败')
}
}
const handleRename = async (from: string) => {
const error = tagNameError(renameDraft, tags, from)
if (error) {
toast.error(error)
return
}
await onRename(from, renameDraft)
setRenaming('')
setRenameDraft('')
}
const handleRemove = async (tag: LibraryTag) => {
const confirmed = await confirmAction({
title: '删除标签',
message: `确定删除标签「${tag.name}」?\n媒体库本身不会被删除,只会回到「未分类」。`,
confirmText: '删除',
})
if (!confirmed) return
await onRemove(tag.name)
}
const handleAssign = async (library: Library, tagName: string) => {
setBusyLibrary(library.id)
try {
await onAssign(library.id, tagName)
} finally {
setBusyLibrary('')
}
}
const handleApplyBatch = async () => {
if (selectedIds.length === 0 || applying) return
setApplying(true)
try {
await onAssignBatch(selectedIds, batchTag)
const count = selectedIds.length
toast.success(
batchTag ? `已将 ${count} 个媒体库归入「${batchTag}」` : `已将 ${count} 个媒体库移出标签`,
)
setSelectedIds([])
} finally {
setApplying(false)
}
}
const busy = saving || applying
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 backdrop-blur-sm sm:p-4"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
className="flex h-[calc(100dvh-1rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-white/70 bg-[var(--app-panel)] shadow-2xl sm:h-[82vh] sm:rounded-3xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-4 py-3 sm:px-6 sm:py-4">
<div className="flex items-center gap-2">
<Tag size={18} className="text-brand-500" />
<h3 className="font-display text-base font-bold text-[var(--app-text)] sm:text-lg">管理标签</h3>
{busy && <Loader2 size={14} className="animate-spin text-[var(--app-muted)]" />}
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1.5 text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
title="关闭"
>
<X size={20} />
</button>
</div>
<div className="grid flex-1 grid-cols-1 gap-4 overflow-hidden p-3 sm:p-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
<section className="flex min-h-0 flex-col gap-3">
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">标签</h4>
<div className="flex items-center gap-1.5">
<input
value={draftName}
onChange={(event) => setDraftName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void handleCreate()
}
}}
maxLength={24}
placeholder="新建标签名"
className="input-base !py-2 text-sm"
/>
<button
type="button"
onClick={() => void handleCreate()}
disabled={busy || !draftName.trim()}
className="btn-outline shrink-0 !px-3 !py-2 text-xs"
title="创建标签"
>
<Plus size={14} />
新建
</button>
</div>
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
{tags.length === 0 ? (
<p className="rounded-xl border border-dashed border-[var(--app-border)] px-3 py-6 text-center text-xs text-[var(--app-muted)]">
还没有标签。先创建一个,再把媒体库归入其中。
</p>
) : (
tags.map((tag) => {
const drag = dragProps(tag.name)
const isDragOver = dragOverId === tag.name && draggingId !== tag.name
return (
<div
key={tag.name}
onDragOver={drag.onDragOver}
onDragLeave={drag.onDragLeave}
onDrop={drag.onDrop}
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
isDragOver
? 'border-brand-500/60 bg-brand-500/5'
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
}`}
>
{renaming === tag.name ? (
<>
<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('')
setRenameDraft('')
}}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
title="取消"
>
<X size={14} />
</button>
</>
) : (
<>
<button
type="button"
draggable={drag.draggable}
onDragStart={drag.onDragStart}
onDragEnd={drag.onDragEnd}
className={`-ml-1 shrink-0 cursor-grab rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] ${
draggingId === tag.name ? 'opacity-40' : ''
}`}
title="拖拽调整标签顺序"
aria-label="拖拽调整标签顺序"
>
<GripVertical size={13} />
</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}」标签下的媒体库`}
>
{tag.name}
</button>
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
{tag.library_ids.length}
</span>
<button
type="button"
onClick={() => {
setRenaming(tag.name)
setRenameDraft(tag.name)
}}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500"
title="重命名"
>
<Pencil size={13} />
</button>
<button
type="button"
onClick={() => void handleRemove(tag)}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500"
title="删除标签"
>
<Trash2 size={13} />
</button>
</>
)}
</div>
)
})
)}
</div>
</section>
<section className="flex min-h-0 flex-col gap-3 border-t border-[var(--app-border)] pt-4 md:border-l md:border-t-0 md:pl-4 md:pt-0">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">
媒体库归类({libraries.length})
</h4>
<span className="text-xs text-[var(--app-muted)]">勾选多个后可一次性设置标签</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索名称 / 路径 / 类型"
className="input-base h-9 min-w-[10rem] flex-1 !py-0 text-xs"
/>
<select
value={tagFilter}
onChange={(event) => setTagFilter(event.target.value)}
className="input-base h-9 w-32 shrink-0 !py-0 text-xs"
title="筛选媒体库"
>
<option value="">全部标签</option>
<option value={UNTAGGED}>未分类</option>
{tags.map((tag) => (
<option key={tag.name} value={tag.name}>
{tag.name}
</option>
))}
</select>
</div>
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2">
<button
type="button"
onClick={toggleVisible}
disabled={visibleLibraries.length === 0}
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2.5 py-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500 disabled:opacity-40"
title="全选 / 取消全选当前列表中的媒体库"
>
<Check size={13} />
{allVisibleSelected ? '取消全选' : '全选'}
</button>
<span className="text-xs font-semibold text-[var(--app-subtle)]">
已选 {selectedIds.length} 个
</span>
<select
value={batchTag}
onChange={(event) => setBatchTag(event.target.value)}
className="input-base ml-auto h-8 w-36 shrink-0 !py-0 text-xs"
title="选择要批量设置的标签"
>
<option value="">未分类(移出标签)</option>
{tags.map((tag) => (
<option key={tag.name} value={tag.name}>
{tag.name}
</option>
))}
</select>
<button
type="button"
onClick={() => void handleApplyBatch()}
disabled={busy || selectedIds.length === 0}
className="btn-outline shrink-0 !px-3 !py-1.5 text-xs"
title="把选中媒体库的标签一次性设为左侧所选值"
>
{applying && <Loader2 size={13} className="animate-spin" />}
应用到已选
</button>
{selectedIds.length > 0 && (
<button
type="button"
onClick={() => setSelectedIds([])}
disabled={applying}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
title="清空选择"
>
<X size={13} />
</button>
)}
</div>
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
{visibleLibraries.length === 0 ? (
<p className="rounded-xl border border-dashed border-[var(--app-border)] px-3 py-6 text-center text-xs text-[var(--app-muted)]">
{libraries.length === 0 ? '暂无可归类的媒体库。' : '没有符合当前筛选条件的媒体库。'}
</p>
) : (
visibleLibraries.map((library) => {
const current = tagByLibrary.get(library.id) ?? ''
const checked = selectedSet.has(library.id)
return (
<div
key={library.id}
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
checked
? 'border-brand-500/50 bg-brand-500/5'
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleLibrary(library.id)}
disabled={applying}
className="h-4 w-4 shrink-0 accent-brand-400"
title="勾选后可批量设置标签"
/>
<span
className="min-w-0 flex-1 truncate text-xs font-semibold text-[var(--app-text)]"
title={library.path || library.name}
>
{library.name}
</span>
{busyLibrary === library.id && (
<Loader2 size={13} className="shrink-0 animate-spin text-[var(--app-muted)]" />
)}
<select
value={current}
disabled={busy || busyLibrary === library.id}
onChange={(event) => void handleAssign(library, event.target.value)}
className="input-base h-8 w-32 shrink-0 !py-0 text-xs"
title="选择该媒体库所属标签"
>
<option value="">未分类</option>
{tags.map((tag) => (
<option key={tag.name} value={tag.name}>
{tag.name}
</option>
))}
</select>
</div>
)
})
)}
</div>
</section>
</div>
<div className="flex items-center justify-between gap-3 border-t border-[var(--app-border)] px-4 py-3 sm:px-6">
<span className="text-xs text-[var(--app-muted)]">标签按用户保存;一个媒体库同时只属于一个标签。</span>
<button type="button" onClick={onClose} className="btn-outline !px-4 !py-2 text-sm">
完成
</button>
</div>
</div>
</div>
)
}
+28
View File
@@ -16,6 +16,7 @@ import {
PictureInPicture,
Play,
Rewind,
Rotate3d,
SkipBack,
SkipForward,
Timer,
@@ -24,6 +25,7 @@ import {
} from 'lucide-react'
import type { SubtitleTrack } from '../api/subtitles'
import type { PlaybackQuality } from '../types'
import type { Vr360Profile } from '../utils/vr360'
import {
formatPlaybackRate,
normalizePlaybackRate,
@@ -116,6 +118,11 @@ type PlayerControlsProps = {
selectedQuality?: string
onSelectQuality?: (quality: PlaybackQuality) => void
showQuality?: boolean
/** VR 全景播放配置;非 null 表示当前处于 VR 模式。 */
vr360?: Vr360Profile | null
/** 是否由文件名/画幅自动识别为 VR 素材(按钮上加一个小圆点提示)。 */
vr360Detected?: boolean
onToggleVr360?: () => void
/** Media metadata duration (seconds). Used when HLS only knows transcoded length. */
knownDuration?: number
/** Absolute source offset of the current HLS session (seconds). */
@@ -159,6 +166,9 @@ export function PlayerControls({
selectedQuality = '',
onSelectQuality,
showQuality = false,
vr360 = null,
vr360Detected = false,
onToggleVr360,
knownDuration = 0,
streamOffset = 0,
onSeekAbsolute,
@@ -1090,6 +1100,24 @@ export function PlayerControls({
{danmakuEnabled && <span className="h-1.5 w-1.5 rounded-full bg-lime-400" />}
</button>
{/* VR 全景播放开关 */}
{onToggleVr360 && (
<button
onClick={onToggleVr360}
className={
'flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-xs font-medium transition ' +
(vr360
? 'bg-rose-500/90 text-white hover:bg-rose-500'
: 'bg-white/10 text-white/80 hover:bg-white/20')
}
title={vr360 ? '退出 VR 全景播放' : '切换到 VR 全景播放(可用鼠标或陀螺仪转动视角)'}
>
<Rotate3d size={15} />
<span className="hidden sm:inline">VR</span>
{vr360Detected && !vr360 && <span className="h-1.5 w-1.5 rounded-full bg-amber-300" />}
</button>
)}
<button
onClick={toggleMute}
className="rounded-full p-1.5 transition hover:bg-white/15"
+617
View File
@@ -0,0 +1,617 @@
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
}
/** 每像素拖拽对应的转角(弧度)。视场角越小时画面越「拉近」,拖拽也应更细腻。 */
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,
}: 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])
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 cursor-grab touch-none select-none active:cursor-grabbing"
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="pointer-events-auto 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">
<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="pointer-events-auto w-[min(92vw,420px)] rounded-2xl border border-white/15 bg-black/75 px-3 py-2.5 text-white shadow-2xl backdrop-blur">
<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>
</>
)
}
+86
View File
@@ -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 }
}
+38
View File
@@ -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 }
}
+267
View File
@@ -0,0 +1,267 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import {
ALL_TAG_ID,
assignLibrariesToTag,
attachLibraryToTag,
dedupeLibraryTags,
loadLibraryTags,
normalizeLibraryTags,
normalizeTagName,
readCachedLibraryTags,
readSelectedTagId,
reorderLibraryTags,
resolveSelectedTagId,
saveLibraryTags,
writeCachedLibraryTags,
writeSelectedTagId,
type LibraryTag,
} from '../utils/libraryTags'
export type UseLibraryTagsResult = {
/** 当前用户维护的标签(顺序即标签栏顺序)。 */
tags: LibraryTag[]
/** 标签数据是否仍在首次加载中。 */
loading: boolean
/** 是否有写入在途(可用于禁用重复点击)。 */
saving: boolean
/** 接口不可用(例如旧后端)时降级为本地标签。 */
loadError: boolean
/** 当前选中的标签栏:ALL_TAG_ID 表示「全部」。 */
selectedTagId: string
setSelectedTagId: (tagId: string) => void
createTag: (name: string) => Promise<string>
renameTag: (from: string, to: string) => Promise<void>
/** 按给定标签名顺序重排标签栏(拖拽排序的落点)。 */
reorderTags: (orderedNames: string[]) => Promise<void>
removeTag: (name: string) => Promise<void>
/** 覆盖某个标签下的媒体库集合(保持传入顺序)。 */
setTagLibraries: (name: string, libraryIds: string[]) => Promise<void>
/** 把媒体库挂到标签下;tagName 为空表示移出所有标签。 */
assignLibrary: (libraryId: string, tagName: string) => Promise<void>
/** 批量把媒体库挂到标签下;tagName 为空表示批量移出所有标签。一次写入。 */
assignLibraries: (libraryIds: string[], tagName: string) => Promise<void>
}
/**
* 媒体库标签的用户级读写。
*
* 写入采用「先乐观更新、串行提交、失败回滚」:标签是整份替换(`PUT`),
* 并发请求可能让旧快照最后落库,因此所有写操作串行排队,每次发送的都是
* 排队时的最新本地状态。接口不可用(旧后端/网络异常)时退回本地存储,
* 标签栏依旧可用,不会因为一次失败就让标签凭空消失。
*/
export function useLibraryTags(): UseLibraryTagsResult {
const [tags, setTags] = useState<LibraryTag[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [loadError, setLoadError] = useState(false)
const [selectedTagId, setSelectedTagIdState] = useState(() => readSelectedTagId())
const tagsRef = useRef<LibraryTag[]>([])
const selectedRef = useRef(selectedTagId)
const savedRef = useRef<LibraryTag[]>([])
const chainRef = useRef<Promise<unknown>>(Promise.resolve())
const pendingRef = useRef(0)
// 后端没有该接口(旧版本)时退化为仅本地存储,不再反复提示保存失败。
const localOnlyRef = useRef(false)
useEffect(() => {
tagsRef.current = tags
}, [tags])
useEffect(() => {
selectedRef.current = selectedTagId
}, [selectedTagId])
useEffect(() => {
let cancelled = false
setLoading(true)
loadLibraryTags()
.then((rows) => {
if (cancelled) return
tagsRef.current = rows
savedRef.current = rows
setTags(rows)
setLoadError(false)
})
.catch((err) => {
if (cancelled) return
// 旧后端没有 /me/library-tags:用本地缓存兜底,标签栏照常可用。
const cached = readCachedLibraryTags()
tagsRef.current = cached
savedRef.current = cached
setTags(cached)
setLoadError(true)
// 接口不存在时(旧后端)只做本地读写,避免每次编辑都弹一次保存失败。
localOnlyRef.current = isMissingEndpoint(err)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
// 选中的标签可能被删除,落回「全部」以免标签栏空白。
// 注意:标签首次加载完成前 tags 为空集合,此时不能据此判定标签已被删除,
// 否则从子页面返回时,持久化的选中标签会被误重置为「全部」。
useEffect(() => {
if (loading) return
const resolved = resolveSelectedTagId(tags, selectedTagId)
if (resolved !== selectedTagId) {
selectedRef.current = resolved
setSelectedTagIdState(resolved)
}
}, [loading, tags, selectedTagId])
const setSelectedTagId = useCallback((tagId: string) => {
const next = tagId && tagId !== ALL_TAG_ID ? tagId : ALL_TAG_ID
selectedRef.current = next
writeSelectedTagId(next)
setSelectedTagIdState(next)
}, [])
const mutate = useCallback((updater: (current: LibraryTag[]) => LibraryTag[]) => {
const rollback = savedRef.current
const next = dedupeLibraryTags(normalizeLibraryTags(updater(tagsRef.current)))
tagsRef.current = next
setTags(next)
pendingRef.current += 1
setSaving(true)
const task = chainRef.current.then(async () => {
// 发送排队时的最新本地状态:链上更早的写入已经被后面的状态取代。
const snapshot = tagsRef.current
if (localOnlyRef.current) {
// 已知后端不支持标签接口:只落本地缓存,不再发请求。
if (tagsRef.current === snapshot) savedRef.current = snapshot
writeCachedLibraryTags(snapshot)
return
}
try {
const saved = await saveLibraryTags(snapshot)
savedRef.current = saved
setLoadError(false)
if (tagsRef.current === snapshot) {
tagsRef.current = saved
setTags(saved)
}
} catch (err) {
if (isMissingEndpoint(err)) {
// 旧后端不支持标签接口:本次修改保留在本地存储,标签栏照常可用。
localOnlyRef.current = true
savedRef.current = snapshot
writeCachedLibraryTags(snapshot)
setLoadError(true)
return
}
// 用最后一次成功保存的状态回滚,避免前端显示一份服务端并不存在的标签。
tagsRef.current = rollback
savedRef.current = rollback
setTags(rollback)
setLoadError(true)
toast.error('标签保存失败,请稍后重试')
}
})
chainRef.current = task.catch(() => undefined)
return task.finally(() => {
pendingRef.current = Math.max(0, pendingRef.current - 1)
if (pendingRef.current === 0) setSaving(false)
})
}, [])
const createTag = useCallback(
async (name: string) => {
const trimmed = normalizeTagName(name)
if (!trimmed) return ''
if (tagsRef.current.some((tag) => tag.name.toLowerCase() === trimmed.toLowerCase())) {
return trimmed
}
await mutate((current) => [...current, { name: trimmed, library_ids: [] }])
return tagsRef.current.some((tag) => tag.name === trimmed) ? trimmed : ''
},
[mutate],
)
const renameTag = useCallback(
async (from: string, to: string) => {
const trimmed = normalizeTagName(to)
if (!trimmed || trimmed === from) return
await mutate((current) =>
current.map((tag) => (tag.name === from ? { ...tag, name: trimmed } : tag)),
)
// 重命名后选中项要跟着换到新名字,否则标签栏会落回「全部」。
if (selectedRef.current === from) setSelectedTagId(trimmed)
},
[mutate, setSelectedTagId],
)
const reorderTags = useCallback(
async (orderedNames: string[]) => {
await mutate((current) => reorderLibraryTags(current, orderedNames))
},
[mutate],
)
const removeTag = useCallback(
async (name: string) => {
await mutate((current) => current.filter((tag) => tag.name !== name))
if (selectedRef.current === name) setSelectedTagId(ALL_TAG_ID)
},
[mutate, setSelectedTagId],
)
const setTagLibraries = useCallback(
async (name: string, libraryIds: string[]) => {
await mutate((current) =>
current.map((tag) => (tag.name === name ? { ...tag, library_ids: libraryIds } : tag)),
)
},
[mutate],
)
const assignLibrary = useCallback(
async (libraryId: string, tagName: string) => {
await mutate((current) => {
if (tagName) return attachLibraryToTag(current, libraryId, tagName)
return current.map((tag) => ({
...tag,
library_ids: tag.library_ids.filter((id) => id !== libraryId),
}))
})
},
[mutate],
)
const assignLibraries = useCallback(
async (libraryIds: string[], tagName: string) => {
await mutate((current) => assignLibrariesToTag(current, libraryIds, tagName))
},
[mutate],
)
return {
tags,
loading,
saving,
loadError,
selectedTagId,
setSelectedTagId,
createTag,
renameTag,
reorderTags,
removeTag,
setTagLibraries,
assignLibrary,
assignLibraries,
}
}
/** 判断错误是否说明后端没有这个接口(旧版本 / 反向代理未更新)。 */
function isMissingEndpoint(err: unknown): boolean {
const status = (err as { response?: { status?: number } })?.response?.status
return status === 404 || status === 405 || status === 501
}
+82 -26
View File
@@ -6,8 +6,13 @@ import type { HistoryItem } from '../api/playback'
import type { Library, Media } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
import { buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
import { LibraryTagBar } from '../components/LibraryTagBar'
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
import { useLibraryTags } from '../hooks/useLibraryTags'
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 {
ContinueWatchingSection,
@@ -30,6 +35,8 @@ export function HomePage() {
const [librariesLoading, setLibrariesLoading] = useState(true)
const [historyLoading, setHistoryLoading] = useState(true)
const { pinnedIds } = usePinnedLibraries()
const { field: sortField, order: sortOrder } = useLibraryListSort()
const libraryTags = useLibraryTags()
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
// 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。
@@ -85,7 +92,25 @@ 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 tagTabs = useMemo(
() => buildLibraryTagTabs(sortedLibraries, libraryTags.tags),
[sortedLibraries, libraryTags.tags],
)
const taggedLibraries = useMemo(
() => filterLibrariesByTag(sortedLibraries, libraryTags.tags, effectiveTagId),
[sortedLibraries, libraryTags.tags, effectiveTagId],
)
const [libraryTagsOpen, setLibraryTagsOpen] = useState(false)
// 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格,
// 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。
@@ -153,14 +178,14 @@ export function HomePage() {
// 3. 首屏只预取轮播和前三行所需的预览;入口卡片进入视口后自行按批加载。
useEffect(() => {
if (sortedLibraries.length === 0) return
const carouselLibIds = sortedLibraries
if (taggedLibraries.length === 0) return
const carouselLibIds = taggedLibraries
.filter((l) => l.carousel_enabled === true)
.map((l) => l.id)
const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id)
const topRowLibIds = taggedLibraries.slice(0, 3).map((l) => l.id)
const shelfTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds]))
void fetchPreviews(shelfTargets, 10)
}, [sortedLibraries, fetchPreviews])
}, [taggedLibraries, fetchPreviews])
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
const INITIAL_ROWS = 3
@@ -171,26 +196,26 @@ export function HomePage() {
const revealMoreLibraries = useCallback(() => {
setVisibleTargetCount((prev) => {
if (prev >= sortedLibraries.length) return prev
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
if (prev >= taggedLibraries.length) return prev
return Math.min(prev + STEP_ROWS, taggedLibraries.length)
})
}, [sortedLibraries.length])
}, [taggedLibraries.length])
// 随 visibleTargetCount 增加,按需触发后续库的预览加载
useEffect(() => {
if (sortedLibraries.length === 0) return
const currentTargets = sortedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
if (taggedLibraries.length === 0) return
const currentTargets = taggedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
void fetchPreviews(currentTargets, 10)
}, [sortedLibraries, visibleTargetCount, fetchPreviews])
}, [taggedLibraries, visibleTargetCount, fetchPreviews])
// 当前已拉取并确认有内容的媒体库行
const visibleLibraries = useMemo(() => {
return sortedLibraries
return taggedLibraries
.slice(0, visibleTargetCount)
.filter((lib) => (libraryData[lib.id]?.cards?.length ?? 0) > 0)
}, [sortedLibraries, visibleTargetCount, libraryData])
}, [taggedLibraries, visibleTargetCount, libraryData])
const hasMoreLibraries = visibleTargetCount < sortedLibraries.length
const hasMoreLibraries = visibleTargetCount < taggedLibraries.length
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库行)
useEffect(() => {
@@ -258,7 +283,7 @@ export function HomePage() {
// series-type libs (loaded via /series), so gate on cards instead.
const carouselItems = useMemo(() => {
const candidateMedia: Media[] = []
const effectiveSelectedIds = libraries
const effectiveSelectedIds = taggedLibraries
.filter((l) => l.carousel_enabled === true)
.map((l) => l.id)
@@ -275,7 +300,7 @@ export function HomePage() {
// Fallback to all loaded items with artwork
if (candidateMedia.length === 0) {
for (const lib of libraries) {
for (const lib of taggedLibraries) {
const data = libraryData[lib.id]
if (data) {
for (const card of data.cards) {
@@ -286,14 +311,13 @@ export function HomePage() {
}
return candidateMedia.slice(0, 10)
}, [libraries, libraryData])
}, [taggedLibraries, libraryData])
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
return <HomeLoadingState />
}
const empty =
libraries.length === 0 &&
history.length === 0
@@ -313,14 +337,46 @@ export function HomePage() {
{historyLoading && <ContinueWatchingSkeleton />}
{!historyLoading && history.length > 0 && <ContinueWatchingSection history={history} />}
{/* 3. 媒体库卡片区(每页展示 20 个媒体库) */}
{/* 3. 媒体库标签栏 + 卡片区(每页展示 20 个媒体库) */}
{sortedLibraries.length > 0 && (
<HomeLibrariesSection
libraries={sortedLibraries}
libraryData={libraryData}
libraryCounts={libraryCounts}
onNeedPreviews={fetchPreviews}
/>
<section className="space-y-6">
<LibraryTagBar
tabs={tagTabs}
selectedTagId={effectiveTagId}
onSelect={libraryTags.setSelectedTagId}
onCreate={(name) => {
void libraryTags.createTag(name)
}}
onManage={() => setLibraryTagsOpen(true)}
busy={libraryTags.saving}
/>
{libraryTagsOpen && (
<ManageLibraryTagsDialogView
tags={libraryTags.tags}
libraries={libraries}
saving={libraryTags.saving}
onCreate={libraryTags.createTag}
onRename={libraryTags.renameTag}
onRemove={libraryTags.removeTag}
onReorder={libraryTags.reorderTags}
onAssign={libraryTags.assignLibrary}
onAssignBatch={libraryTags.assignLibraries}
onClose={() => setLibraryTagsOpen(false)}
/>
)}
{taggedLibraries.length === 0 ? (
<p className="rounded-2xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-12 text-center text-sm text-[var(--app-muted)]">
「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。
</p>
) : (
<HomeLibrariesSection
libraries={taggedLibraries}
libraryData={libraryData}
libraryCounts={libraryCounts}
onNeedPreviews={fetchPreviews}
/>
)}
</section>
)}
{/* 4. 各媒体库内容展示行(向下滑动渐进流式加载,不受上方20个分页限制) */}
+92 -10
View File
@@ -2,9 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { libraryAPI } from '../api/library'
import { toolsAPI } from '../api/tools'
import { LibraryTagBar } from '../components/LibraryTagBar'
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
import { useLibraryTags } from '../hooks/useLibraryTags'
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
import { useLibraryListSort } from '../hooks/useLibraryListSort'
import { useAuthStore } from '../stores/auth'
import {
LibrariesContent,
@@ -14,19 +18,29 @@ import {
import type { LibraryPreview } from './librariesPageModel'
import type { Library } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
import {
sortLibraryPreviewsByField,
type LibraryListSortField,
type LibraryListSortOrder,
} from '../utils/libraryListSort'
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
import { ALL_TAG_ID, buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
import type { LibraryTag } from '../utils/libraryTags'
import { partitionPreviewIDs } from '../utils/remoteEmby'
export function LibrariesPage() {
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 { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
const [loading, setLoading] = useState(true)
const { pinnedIds, togglePin } = usePinnedLibraries()
const libraryTags = useLibraryTags()
const [loading, setLoading] = useState(() => peekLibraries() === null)
const [repairing, setRepairing] = useState(false)
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
const [repairMsg, setRepairMsg] = useState('')
const { field: sortField, order: sortOrder, setSort: setLibrarySort } = useLibraryListSort()
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
@@ -140,37 +154,90 @@ export function LibrariesPage() {
})
}, [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) => {
void togglePin(libraryId)
}, [togglePin])
const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews])
const [libraryTagsOpen, setLibraryTagsOpen] = useState(false)
const effectiveTagId = libraryTags.selectedTagId
const tagTabs = useMemo(
() => buildLibraryTagTabs(sortedPreviews.map((preview) => preview.library), libraryTags.tags),
[sortedPreviews, libraryTags.tags],
)
const taggedPreviews = useMemo(
() => filterPreviewsByTag(sortedPreviews, libraryTags.tags, effectiveTagId),
[sortedPreviews, libraryTags.tags, effectiveTagId],
)
const taggedTotal = useMemo(
() => taggedPreviews.reduce((sum, preview) => sum + preview.total, 0),
[taggedPreviews],
)
if (loading || pinnedLoading) {
if (loading) {
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
}
return (
<div className="space-y-8">
<LibrariesHeader
previewCount={previews.length}
total={total}
previewCount={taggedPreviews.length}
total={taggedTotal}
isAdmin={isAdmin}
repairMsg={repairMsg}
repairEpisodeArtwork={repairEpisodeArtwork}
repairing={repairing}
sortField={sortField}
sortOrder={sortOrder}
onSortChange={handleSortChange}
onRepairEpisodeArtworkChange={setRepairEpisodeArtwork}
onRepairRescrape={handleRepairRescrape}
onManageLibraries={handleManageLibraries}
/>
<LibraryTagBar
tabs={tagTabs}
selectedTagId={effectiveTagId}
onSelect={libraryTags.setSelectedTagId}
onCreate={(name) => {
void libraryTags.createTag(name)
}}
onManage={() => setLibraryTagsOpen(true)}
busy={libraryTags.saving}
/>
{libraryTagsOpen && (
<ManageLibraryTagsDialogView
tags={libraryTags.tags}
libraries={libraries}
saving={libraryTags.saving}
onCreate={libraryTags.createTag}
onRename={libraryTags.renameTag}
onRemove={libraryTags.removeTag}
onReorder={libraryTags.reorderTags}
onAssign={libraryTags.assignLibrary}
onAssignBatch={libraryTags.assignLibraries}
onClose={() => setLibraryTagsOpen(false)}
/>
)}
{previews.length === 0 ? (
<LibrariesEmptyState isAdmin={isAdmin} />
) : taggedPreviews.length === 0 ? (
<p className="rounded-3xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-16 text-center text-sm text-[var(--app-muted)]">
「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。
</p>
) : (
<LibrariesContent
previews={sortedPreviews}
previews={taggedPreviews}
pinnedIds={pinnedIds}
onTogglePin={handleTogglePin}
onNeedPreviews={fetchPreviews}
@@ -179,3 +246,18 @@ export function LibrariesPage() {
</div>
)
}
/**
* 按选中标签过滤媒体库预览:复用共享的成员筛选(保持输入顺序),
* 这样排序下拉(含倒序)在标签页与「全部」页行为一致。
*/
function filterPreviewsByTag(previews: LibraryPreview[], tags: LibraryTag[], tagId: string): LibraryPreview[] {
if (!tagId || tagId === ALL_TAG_ID) return previews
const byId = new Map(previews.map((preview) => [preview.library.id, preview]))
const kept: LibraryPreview[] = []
for (const library of filterLibrariesByTag(previews.map((preview) => preview.library), tags, tagId)) {
const preview = byId.get(library.id)
if (preview) kept.push(preview)
}
return kept
}
+47 -36
View File
@@ -3,7 +3,9 @@ import { Link } from 'react-router-dom'
import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react'
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import { LibraryListSortDropdown } from '../components/LibraryListSortDropdown'
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
import type { LibraryListSortField, LibraryListSortOrder } from '../utils/libraryListSort'
import { HomeLibrariesSection, HomeLibraryRowSection } from './HomePageSections'
import type { LibraryPreview } from './librariesPageModel'
@@ -18,6 +20,9 @@ export function LibrariesHeader({
repairMsg,
repairEpisodeArtwork,
repairing,
sortField,
sortOrder,
onSortChange,
onRepairEpisodeArtworkChange,
onRepairRescrape,
onManageLibraries,
@@ -28,6 +33,9 @@ export function LibrariesHeader({
repairMsg: string
repairEpisodeArtwork: boolean
repairing: boolean
sortField: LibraryListSortField
sortOrder: LibraryListSortOrder
onSortChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
onRepairEpisodeArtworkChange: (value: boolean) => void
onRepairRescrape: () => void
onManageLibraries: () => void
@@ -40,42 +48,45 @@ export function LibrariesHeader({
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
</p>
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
<EpisodeArtworkToggle
checked={repairEpisodeArtwork}
onChange={onRepairEpisodeArtworkChange}
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
className="h-9 sm:h-10 text-xs sm:text-sm"
/>
<button
type="button"
onClick={onRepairRescrape}
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"
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
>
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
{repairing ? '正在启动…' : '全库修复+重刮'}
</button>
<Link
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"
title="查看正在进行的刮削任务与进度"
>
<Sparkles size={14} className="text-brand-500" />
<span>刮削队列</span>
</Link>
<button
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>
)}
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<LibraryListSortDropdown value={sortField} order={sortOrder} onChange={onSortChange} />
{isAdmin && (
<>
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
<EpisodeArtworkToggle
checked={repairEpisodeArtwork}
onChange={onRepairEpisodeArtworkChange}
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
className="h-9 sm:h-10 text-xs sm:text-sm"
/>
<button
type="button"
onClick={onRepairRescrape}
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"
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
>
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
{repairing ? '正在启动…' : '全库修复+重刮'}
</button>
<Link
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"
title="查看正在进行的刮削任务与进度"
>
<Sparkles size={14} className="text-brand-500" />
<span>刮削队列</span>
</Link>
<button
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>
</div>
)
}
+201 -25
View File
@@ -33,7 +33,7 @@ import {
type SubtitlePosition,
type SubtitleStylePreset,
} from '../utils/subtitleDisplay'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel'
import { classifyDirectPlayError } from './directPlayError'
import { apiErrorMessage } from './StrmManagePage'
import { PlayerTopBar } from './PlayerTopBar'
@@ -42,6 +42,14 @@ import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
import { mediaVersionsOf } from '../utils/mediaVersion'
import {
detectVr360Profile,
loadVr360Preference,
sameVr360Profile,
saveVr360Preference,
type Vr360Detection,
type Vr360Profile,
} from '../utils/vr360'
// Fullscreen, dark-themed video page.
//
@@ -96,6 +104,22 @@ export function PlayerPage() {
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [media, setMedia] = useState<Media | null>(null)
// VR 全景播放:null = 普通播放。识别结果与用户开关只存本机,
// 不改动数据库,换设备/换浏览器不会互相影响。
const [vr360, setVr360] = useState<Vr360Profile | null>(null)
const [vr360Detected, setVr360Detected] = useState(false)
const vr360TouchedRef = useRef(false)
// VR 取帧失败后的自愈:只尝试一次「切到 HLS」,并在切换窗口内忽略重复报错。
const vr360TextureRetryUsedRef = useRef(false)
const vr360TextureRetryUntilRef = useRef(0)
// VR 全景下 STRM/网盘直连必须由服务端同源转发,浏览器才允许 WebGL 读取画面;
// 普通播放仍然 302 直连,避免把网盘流量压到服务器。放在 ref 里是为了让
// handleVideoError 里的 src 比对始终拿到最新值。
const vr360DirectProxy = Boolean(vr360) && isStrmMedia(media)
const vr360DirectProxyRef = useRef(vr360DirectProxy)
useEffect(() => {
vr360DirectProxyRef.current = vr360DirectProxy
}, [vr360DirectProxy])
const [mode, setMode] = useState<PlayerMode>('direct')
const [subs, setSubs] = useState<SubtitleTrack[]>([])
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
@@ -458,6 +482,11 @@ export function PlayerPage() {
useEffect(() => {
setMedia(null)
setLoadError('')
vr360TouchedRef.current = false
vr360TextureRetryUsedRef.current = false
vr360TextureRetryUntilRef.current = 0
setVr360(null)
setVr360Detected(false)
setHlsStartSec(0)
setPlaybackInfo(null)
setHlsSource('local')
@@ -537,6 +566,90 @@ export function PlayerPage() {
}
}, [id, modeParam, directOnly])
// VR 全景素材识别:只在用户没手动改过开关时自动进入 VR 模式。
// 识别完全基于文件名/路径关键词与画幅比例,误判时点一下 VR 按钮即可退出。
useEffect(() => {
if (!media || media.id !== id) return
const detection = detectVr360Profile({
title: media.title,
originalName: media.original_name,
path: media.path,
relativePath: media.relative_path,
width: media.width,
height: media.height,
})
setVr360Detected(detection.confident)
if (vr360TouchedRef.current) return
const preference = loadVr360Preference()
if (!preference.autoDetect || !detection.confident) {
setVr360(null)
return
}
if (detection.profile.projection !== preference.profile.projection) {
// 投影方式由文件名决定(360 / 180 / 鱼眼),记住它作为下次的默认值。
saveVr360Preference({ ...preference, profile: detection.profile })
}
setVr360(detection.profile)
}, [id, media])
// 远端 Emby 挂载的媒体常常没有宽高元数据(width/height 为 0),等播放器拿到
// 真实画面尺寸后再补一次识别。HLS 转码是等比缩放,所以比例依然可信。
useEffect(() => {
if (!media || media.id !== id) return
if (media.width > 0 && media.height > 0) return
const video = ref.current
if (!video) return
const refine = () => {
if (vr360TouchedRef.current || video.videoWidth <= 0 || video.videoHeight <= 0) return
const detection = detectVr360Profile({
title: media.title,
originalName: media.original_name,
path: media.path,
relativePath: media.relative_path,
width: video.videoWidth,
height: video.videoHeight,
})
setVr360Detected(detection.confident)
const preference = loadVr360Preference()
if (!preference.autoDetect || !detection.confident) return
setVr360((current) =>
current && sameVr360Profile(current, detection.profile) ? current : detection.profile,
)
}
video.addEventListener('loadedmetadata', refine)
refine()
return () => video.removeEventListener('loadedmetadata', refine)
}, [id, media])
const toggleVr360 = useCallback(() => {
vr360TouchedRef.current = true
if (vr360) {
setVr360(null)
return
}
const current = mediaRef.current
const detection: Vr360Detection | null = current
? detectVr360Profile({
title: current.title,
originalName: current.original_name,
path: current.path,
relativePath: current.relative_path,
width: current.width,
height: current.height,
})
: null
const preference = loadVr360Preference()
const profile = detection?.profile ?? preference.profile
saveVr360Preference({ ...preference, profile })
setVr360(profile)
}, [vr360])
const changeVr360Profile = useCallback((profile: Vr360Profile) => {
vr360TouchedRef.current = true
setVr360(profile)
saveVr360Preference({ ...loadVr360Preference(), profile })
}, [])
// Wire up the actual <video> element when we know the mode.
// Depend on media.id (not the media object): refreshing duration after
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
@@ -608,6 +721,33 @@ export function PlayerPage() {
[mediaId, setPlaybackMode, switchToLocalHLS],
)
// 切到 HLS 播放(优先 115 云端,其次本地转码)。toggleMode 与「VR 需要同源帧」
// 的自愈逻辑共用,避免两处各写一遍云端/本地选择。
const enterHlsPlayback = useCallback(
(startSec: number) => {
setHlsStartSec(startSec)
const preferred =
playbackInfo?.provider === 'cloud115'
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
: undefined
if (preferred?.source === 'cloud') {
setHlsSource('cloud')
if (preferred.available) {
setCloudWaiting(false)
} else {
setCloudWaiting(true)
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
void startCloudTranscode(Number(preferred.id) || 4)
}
} else {
switchToLocalHLS(startSec)
}
setPlaybackMode('hls')
},
[playbackInfo, selectedQuality, setPlaybackMode, startCloudTranscode, switchToLocalHLS],
)
// 云端转码等待:按后端建议间隔轮询,完成后自动切到云 HLS。
useEffect(() => {
if (!cloudWaiting || !mediaId) return
@@ -792,13 +932,30 @@ export function PlayerPage() {
setPlaybackMode('direct')
})
} else {
const url = streamURL(mediaId)
// VR 全景需要浏览器能读帧:STRM/网盘直链会被 302 到跨域 CDN,此时改为
// 服务端同源转发(原画,不转码);普通播放仍走 302 直连,省服务器流量。
const url = streamURL(mediaId, { proxy: vr360DirectProxyRef.current })
const absoluteURL = new URL(url, window.location.href).href
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
if (video.src !== absoluteURL) {
directRetryRef.current = false
clearFallbackTimer()
// 进出 VR 会切换播放源地址,这里保住当前播放位置。
const resumeAt = video.currentTime > 2 ? video.currentTime : 0
if (resumeAt > 0) {
video.addEventListener(
'loadedmetadata',
() => {
try {
video.currentTime = resumeAt
} catch {
// ignore
}
},
{ once: true },
)
}
video.src = url
void video.play().catch(() => undefined)
}
@@ -827,6 +984,7 @@ export function PlayerPage() {
setPlaybackMode,
switchToLocalHLS,
teardownHls,
vr360DirectProxy,
])
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
@@ -1186,38 +1344,49 @@ export function PlayerPage() {
}
const next = mode === 'hls' ? 'direct' : 'hls'
if (next === 'hls') {
setHlsStartSec(0)
const preferred =
playbackInfo?.provider === 'cloud115'
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
: undefined
if (preferred?.source === 'cloud') {
setHlsSource('cloud')
if (preferred.available) {
setCloudWaiting(false)
} else {
setCloudWaiting(true)
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
void startCloudTranscode(Number(preferred.id) || 4)
}
} else {
switchToLocalHLS()
}
enterHlsPlayback(0)
} else {
setCloudWaiting(false)
}
setPlaybackMode(next)
}, [
enterHlsPlayback,
isDirectStream,
mode,
playbackInfo,
selectedQuality,
setPlaybackMode,
startCloudTranscode,
switchToLocalHLS,
])
// VR 渲染取不到帧时:网盘/STRM 的直连会 302 跳到 CDN,视频就变成跨域资源,
// 浏览器禁止 WebGL 读取(texImage2D 抛 SecurityError)。此时自动切到同源的
// HLS(云端优先)再继续 VR,只有确实做不到时才如实报错并退出 VR。
const handleVr360Error = useCallback(
(message: string) => {
const now = Date.now()
// 切换 HLS 的过程中旧的直连帧还会继续上报失败,先忽略这一窗口内的重复错误。
if (now < vr360TextureRetryUntilRef.current) return
const canRetryWithHls =
!vr360TextureRetryUsedRef.current &&
modeRef.current === 'direct' &&
!directOnly &&
!isRemoteEmbyID(mediaRef.current?.id)
if (canRetryWithHls) {
vr360TextureRetryUsedRef.current = true
vr360TextureRetryUntilRef.current = now + 15_000
setPlayerError('')
toast('直连源跨域,浏览器无法取帧;正在切到 HLS 转码后继续 VR 全景')
enterHlsPlayback(ref.current?.currentTime || 0)
return
}
vr360TouchedRef.current = true
setVr360(null)
setPlayerError(
`${message}。若当前是网盘/STRM 直连(会 302 跳转到 CDN),浏览器不允许 WebGL 读取跨域画面,请改用 HLS 播放后再开启 VR。`,
)
toast.error(message)
},
[directOnly, enterHlsPlayback],
)
const handleSeekAbsolute = useCallback(
(absoluteSec: number) => {
if (mode !== 'hls') return false
@@ -1343,7 +1512,9 @@ export function PlayerPage() {
if (retryingDirectRef.current) return
const expectedSrc = mediaId ? new URL(streamURL(mediaId), window.location.href).href : ''
const expectedSrc = mediaId
? new URL(streamURL(mediaId, { proxy: vr360DirectProxyRef.current }), window.location.href).href
: ''
const action = classifyDirectPlayError({
errorCode: video?.error?.code,
readyState: video?.readyState ?? 0,
@@ -1499,6 +1670,11 @@ export function PlayerPage() {
showQuality={showQuality}
waiting={cloudWaiting}
waitingMessage={cloudWaitMessage}
vr360={vr360}
vr360Detected={vr360Detected}
onToggleVr360={toggleVr360}
onVr360ProfileChange={changeVr360Profile}
onVr360Error={handleVr360Error}
playlistPanel={
<PlayerPlaylistPanel
open={playlistOpen}
+61 -10
View File
@@ -12,6 +12,8 @@ import {
import { AssSubtitleStage } from '../components/AssSubtitleStage'
import { DanmakuStage } from '../components/DanmakuStage'
import { PlayerControls } from '../components/PlayerControls'
import { Vr360Stage } from '../components/Vr360Stage'
import type { Vr360Profile } from '../utils/vr360'
import {
subtitleTextStyle,
type SubtitlePosition,
@@ -186,6 +188,13 @@ type PlayerVideoStageProps = {
selectedQuality?: string
onSelectQuality?: (quality: PlaybackQuality) => void
showQuality?: boolean
/** VR 全景播放配置;非 null 表示当前处于 VR 模式。 */
vr360?: Vr360Profile | null
/** 是否由文件名/画幅自动识别出 VR 素材(用于在菜单里提示)。 */
vr360Detected?: boolean
onToggleVr360?: () => void
onVr360ProfileChange?: (profile: Vr360Profile) => void
onVr360Error?: (message: string) => void
waiting?: boolean
waitingMessage?: string
}
@@ -241,6 +250,11 @@ export function PlayerVideoStage({
selectedQuality = '',
onSelectQuality,
showQuality = false,
vr360 = null,
vr360Detected = false,
onToggleVr360,
onVr360ProfileChange,
onVr360Error,
waiting = false,
waitingMessage = '',
}: PlayerVideoStageProps) {
@@ -248,6 +262,11 @@ export function PlayerVideoStage({
const [videoRatio, setVideoRatio] = useState<number | null>(null)
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
const [controlsVisible, setControlsVisible] = useState(true)
// VR 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
// 只按「媒体 ID」记录:切换投影方式/画幅布局不会重建画布,也就不能清掉这个
// 状态——渲染器只在挂载后上报一次就绪,清掉之后提示会一直停在「正在启动」。
const [vrReadyMediaId, setVrReadyMediaId] = useState<string | null>(null)
const vrReady = Boolean(media && vrReadyMediaId === media.id)
const revealControlsOnlyRef = useRef(false)
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
const [activeCues, setActiveCues] = useState<SubtitleCue[]>([])
@@ -392,7 +411,9 @@ export function PlayerVideoStage({
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
revealControlsOnlyRef.current = event.pointerType === 'touch' && !controlsVisible
}
const handleStageClick = () => {
// 舞台任意位置的「激活」(轻触/单击):移动端控制栏隐藏时首次轻触只唤出
// 控制栏,控制栏已显示时再次轻触才切换播放/暂停。
const handleSurfaceActivate = () => {
if (revealControlsOnlyRef.current) {
revealControlsOnlyRef.current = false
setControlsVisible(true)
@@ -550,18 +571,25 @@ export function PlayerVideoStage({
: true
const assTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
const wrapperStyle = videoRatio
// VR 模式下画面由 WebGL 画布输出,舞台窗口本身就是「镜头」,
// 不再按视频宽高比留黑边。
const wrapperStyle = vr360
? {
aspectRatio: `${videoRatio}`,
width: isWiderThanStage ? '100%' : 'auto',
height: isWiderThanStage ? 'auto' : '100%',
maxWidth: '100%',
maxHeight: '100%',
}
: {
width: '100%',
height: '100%',
}
: videoRatio
? {
aspectRatio: `${videoRatio}`,
width: isWiderThanStage ? '100%' : 'auto',
height: isWiderThanStage ? 'auto' : '100%',
maxWidth: '100%',
maxHeight: '100%',
}
: {
width: '100%',
height: '100%',
}
return (
<div
@@ -569,7 +597,7 @@ export function PlayerVideoStage({
data-player-stage
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
onPointerDown={handleStagePointerDown}
onClick={handleStageClick}
onClick={handleSurfaceActivate}
onDoubleClick={toggleFullscreen}
>
{media ? (
@@ -602,6 +630,18 @@ export function PlayerVideoStage({
)
})}
</video>
{vr360 ? (
<Vr360Stage
key={`${media.id}:vr360`}
videoRef={videoRef}
profile={vr360}
uiVisible={controlsVisible}
onSurfaceTap={handleSurfaceActivate}
onReady={() => setVrReadyMediaId(media.id)}
onError={(message) => onVr360Error?.(message)}
onProfileChange={onVr360ProfileChange}
/>
) : null}
<DanmakuStage
key={media.id}
media={media}
@@ -692,6 +732,9 @@ export function PlayerVideoStage({
selectedQuality={selectedQuality}
onSelectQuality={onSelectQuality}
showQuality={showQuality}
vr360={vr360}
vr360Detected={vr360Detected}
onToggleVr360={onToggleVr360}
/>
{danmakuPanel}
{playlistPanel}
@@ -701,6 +744,14 @@ export function PlayerVideoStage({
) : (
<p className="text-sand-500">加载中…</p>
)}
{vr360 && media && !vrReady ? (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/60">
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-black/80 px-5 py-3 text-sm text-white shadow-2xl backdrop-blur">
<Loader2 className="animate-spin text-rose-400" size={18} />
正在启动 VR 全景渲染…
</div>
</div>
) : null}
{playerError ? (
<div className="absolute bottom-20 left-1/2 w-[min(92vw,720px)] -translate-x-1/2 rounded-2xl border border-white/15 bg-black/75 px-5 py-4 text-sm text-white shadow-2xl backdrop-blur">
{playerError}
+4
View File
@@ -1,3 +1,5 @@
import type { LibraryTagSet } from './library'
export interface User {
id: string
username: string
@@ -16,6 +18,8 @@ export interface User {
is_active: boolean
allowed_library_ids?: string[]
pinned_library_ids?: string[]
/** 当前用户维护的媒体库标签分组。 */
library_tags?: LibraryTagSet[]
is_default_admin?: boolean
is_protected?: boolean
realtime_online?: boolean
+9
View File
@@ -28,6 +28,15 @@ export interface Library {
total?: number
}
/**
* 用户自定义的媒体库标签分组。标签属于当前用户本人,用于在媒体库页把
* 同一标签下的媒体库聚合到一起。一个媒体库同时只属于一个标签。
*/
export interface LibraryTagSet {
name: string
library_ids: string[]
}
export interface ScanResult {
library_id: string
visited: number
+92
View File
@@ -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')
+122
View File
@@ -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)
}
+166
View File
@@ -0,0 +1,166 @@
import {
ALL_TAG_ID,
assignLibrariesToTag,
attachLibrariesToTag,
attachLibraryToTag,
buildLibraryTagTabs,
dedupeLibraryTags,
detachLibrariesFromTag,
detachLibraryFromTag,
filterLibrariesByTag,
filterLibrariesForTagging,
normalizeLibraryTags,
normalizeTagName,
reorderLibraryTags,
resolveSelectedTagId,
tagNameError,
} from './libraryTags.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`libraryTags: ${name}`)
}
const libs = [
{ id: 'lib-a', total: 10 },
{ id: 'lib-b', total: 5 },
{ id: 'lib-c', total: 0 },
]
check('empty input normalizes to empty list', normalizeLibraryTags(null).length === 0)
check('trims and drops blank names', normalizeLibraryTags([{ name: ' ', library_ids: [] }]).length === 0)
check(
'merges duplicate tag names case-insensitively',
normalizeLibraryTags([
{ name: 'Anime', library_ids: ['a'] },
{ name: 'anime', library_ids: ['b', 'a'] },
]).length === 1,
)
check(
'dedupes ids inside one tag',
normalizeLibraryTags([{ name: '动画', library_ids: ['a', '', 'a', 'b'] }])[0].library_ids.join(',') === 'a,b',
)
check('truncates over-long names to 24 chars', Array.from(normalizeTagName('x'.repeat(40))).length === 24)
const tabs = buildLibraryTagTabs(libs, [
{ name: '动画', library_ids: ['lib-b', 'missing'] },
{ name: '电影', library_ids: [] },
])
check('tab bar always starts with 全部', tabs[0].id === ALL_TAG_ID && tabs[0].count === 3)
check('tag tab keeps creation order', tabs[1].name === '动画' && tabs[2].name === '电影')
check('tag count ignores unavailable libraries', tabs[1].count === 1)
check(
'filtering drops unknown ids and keeps input order',
filterLibrariesByTag(libs, [{ name: '动画', library_ids: ['lib-c', 'missing', 'lib-a'] }], '动画')
.map((lib) => lib.id)
.join(',') === 'lib-a,lib-c',
)
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(
'selected tag falls back to 全部 when the tag is gone',
resolveSelectedTagId([{ name: '动画', library_ids: [] }], '动画') === '动画' &&
resolveSelectedTagId([{ name: '动画', library_ids: [] }], '电影') === ALL_TAG_ID,
)
const claimed = dedupeLibraryTags([
{ name: '动画', library_ids: ['a', 'b'] },
{ name: '电影', library_ids: ['b', 'c'] },
])
check('one library belongs to a single tag', claimed[0].library_ids.join(',') === 'a,b')
check('later tag loses the shared library', claimed[1].library_ids.join(',') === 'c')
const attached = attachLibraryToTag(
[
{ name: '动画', library_ids: ['a'] },
{ name: '电影', library_ids: ['b'] },
],
'a',
'电影',
)
check('attach moves the library out of its old tag', attached[0].library_ids.length === 0)
check('attach appends to the target tag', attached[1].library_ids.join(',') === 'b,a')
const detached = detachLibraryFromTag(
[{ name: '电影', library_ids: ['b', 'a'] }],
'a',
'电影',
)
check('detach removes only the requested library', detached[0].library_ids.join(',') === 'b')
const batchAttached = attachLibrariesToTag(
[
{ name: '动画', library_ids: ['a', 'x'] },
{ name: '电影', library_ids: ['b'] },
],
['b', 'x', 'b', ' missing '],
'动画',
)
check('batch attach moves every id out of its old tag', batchAttached[1].library_ids.length === 0)
check('batch attach keeps order and dedupes ids', batchAttached[0].library_ids.join(',') === 'a,b,x,missing')
check('batch attach ignores unknown target tag', attachLibrariesToTag([{ name: '动画', library_ids: [] }], ['a'], '不存在')[0].library_ids.length === 0)
check('batch attach with no ids is a no-op', attachLibrariesToTag([{ name: '动画', library_ids: ['a'] }], [], '动画')[0].library_ids.join(',') === 'a')
const batchDetached = detachLibrariesFromTag(
[
{ name: '动画', library_ids: ['a', 'b'] },
{ name: '电影', library_ids: ['b', 'c'] },
],
['b', 'c'],
)
check('batch detach removes ids from every tag', batchDetached[0].library_ids.join(',') === 'a')
check('batch detach clears emptied tags', batchDetached[1].library_ids.length === 0)
check(
'assign with empty tag name clears everything',
assignLibrariesToTag([{ name: '动画', library_ids: ['a'] }], ['a'], '')[0].library_ids.length === 0,
)
check(
'assign with a tag routes to attach',
assignLibrariesToTag([{ name: '动画', library_ids: [] }], ['a'], '动画')[0].library_ids.join(',') === 'a',
)
const searchable = [
{ id: 'lib-a', name: '电影库', path: '/media/movies', type: 'movie' },
{ id: 'lib-b', name: 'TV Shows', path: '/media/tv', type: 'tv' },
]
check('tagging search matches name', filterLibrariesForTagging(searchable as never, '电影').length === 1)
check('tagging search matches path', filterLibrariesForTagging(searchable as never, 'media/tv').length === 1)
check('tagging search matches type case-insensitively', filterLibrariesForTagging(searchable as never, 'MOVIE').length === 1)
check('tagging search with blank keyword returns all', filterLibrariesForTagging(searchable as never, ' ').length === 2)
check('blank name is rejected', tagNameError(' ', []) !== '')
check('duplicate name is rejected', tagNameError('动画', [{ name: '动画', library_ids: [] }]) !== '')
check(
'renaming a tag to itself is allowed',
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')
+309
View File
@@ -0,0 +1,309 @@
import type { Library, LibraryTagSet } from '../types'
/** 与后端 model.MaxLibraryTagNameLen 保持一致。 */
export const MAX_TAG_NAME_LENGTH = 24
/** 与后端 model.MaxLibraryTags 保持一致。 */
export const MAX_LIBRARY_TAGS = 50
/** 「全部」伪标签:不参与持久化,仅用于标签栏选中项。 */
export const ALL_TAG_ID = '__all__'
export type LibraryTag = LibraryTagSet
export type LibraryTagTab = {
/** 选中的稳定标识:ALL_TAG_ID 或标签名。 */
id: string
name: string
count: number
/** 是否为「全部」标签栏 */
isAll: boolean
}
const STORAGE_KEY = 'mebox_library_tags'
const SELECTED_KEY = 'mebox_library_tag_selected'
/** 标签名去空白并按字符数截断,与后端清洗规则一致。 */
export function normalizeTagName(name: string): string {
return Array.from(name.trim()).slice(0, MAX_TAG_NAME_LENGTH).join('').trim()
}
export function normalizeLibraryTags(tags: unknown): LibraryTag[] {
if (!Array.isArray(tags)) return []
const out: LibraryTag[] = []
const indexByName = new Map<string, number>()
for (const raw of tags) {
if (!raw || typeof raw !== 'object') continue
const name = normalizeTagName(String((raw as LibraryTagSet).name ?? ''))
if (!name) continue
const key = name.toLowerCase()
let pos = indexByName.get(key)
if (pos === undefined) {
if (out.length >= MAX_LIBRARY_TAGS) break
out.push({ name, library_ids: [] })
pos = out.length - 1
indexByName.set(key, pos)
}
const ids = (raw as LibraryTagSet).library_ids
if (!Array.isArray(ids)) continue
const seen = new Set(out[pos].library_ids)
for (const id of ids) {
const trimmed = String(id ?? '').trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
out[pos].library_ids.push(trimmed)
}
}
return out
}
/** 读取本地兜底缓存:接口不可用时标签栏仍然可用。 */
export function readCachedLibraryTags(): LibraryTag[] {
if (typeof window === 'undefined') return []
try {
return normalizeLibraryTags(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '[]'))
} catch {
return []
}
}
export function writeCachedLibraryTags(tags: LibraryTag[]): void {
if (typeof window === 'undefined') return
try {
if (tags.length === 0) {
window.localStorage.removeItem(STORAGE_KEY)
return
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(tags))
} catch {
// 私密模式等场景忽略存储失败。
}
}
export function readSelectedTagId(): string {
if (typeof window === 'undefined') return ALL_TAG_ID
try {
return window.localStorage.getItem(SELECTED_KEY)?.trim() || ALL_TAG_ID
} catch {
return ALL_TAG_ID
}
}
export function writeSelectedTagId(tagId: string): void {
if (typeof window === 'undefined') return
try {
if (!tagId || tagId === ALL_TAG_ID) {
window.localStorage.removeItem(SELECTED_KEY)
return
}
window.localStorage.setItem(SELECTED_KEY, tagId)
} catch {
// 同上。
}
}
export async function loadLibraryTags(): Promise<LibraryTag[]> {
const { profileAPI } = await import('../api/profile')
const remote = normalizeLibraryTags(await profileAPI.getLibraryTags())
writeCachedLibraryTags(remote)
return remote
}
export async function saveLibraryTags(tags: LibraryTag[]): Promise<LibraryTag[]> {
const { profileAPI } = await import('../api/profile')
const saved = normalizeLibraryTags(await profileAPI.setLibraryTags(normalizeLibraryTags(tags)))
writeCachedLibraryTags(saved)
return saved
}
/**
* 把标签集合中的媒体库 ID 收敛为「每个库只归属一个标签」:越靠前的标签优先,
* 后面的标签里重复出现的库会被移除。与后端 SetLibraryTags 的语义一致。
*/
export function dedupeLibraryTags(tags: LibraryTag[]): LibraryTag[] {
const claimed = new Set<string>()
return tags.map((tag) => {
const libraryIds: string[] = []
for (const id of tag.library_ids) {
if (claimed.has(id)) continue
claimed.add(id)
libraryIds.push(id)
}
return { ...tag, library_ids: libraryIds }
})
}
/** 标签名是否可用:非空、未超长、且(除了自身以外)没有重名。 */
export function tagNameError(name: string, tags: LibraryTag[], exceptName?: string): string {
const trimmed = normalizeTagName(name)
if (!trimmed) return '标签名不能为空'
if (Array.from(name.trim()).length > MAX_TAG_NAME_LENGTH) {
return `标签名最多 ${MAX_TAG_NAME_LENGTH} 个字`
}
const key = trimmed.toLowerCase()
const selfKey = exceptName ? normalizeTagName(exceptName).toLowerCase() : ''
if (key !== selfKey && tags.some((tag) => tag.name.toLowerCase() === key)) {
return '标签名已存在'
}
return ''
}
export function findTagIdByName(tags: LibraryTag[], name: string): string {
const key = normalizeTagName(name).toLowerCase()
if (!key) return ''
return tags.find((tag) => tag.name.toLowerCase() === key)?.name ?? ''
}
export function tagLibraryIds(tags: LibraryTag[], tagId: string): string[] {
if (!tagId || tagId === ALL_TAG_ID) return []
const tag = tags.find((item) => item.name === tagId)
return tag ? tag.library_ids : []
}
export function isLibraryInTag(tags: LibraryTag[], tagId: string, libraryId: string): boolean {
return tagLibraryIds(tags, tagId).includes(libraryId)
}
/**
* 按标签过滤媒体库:`tagId` 为「全部」时原样返回。
* 只做成员筛选、保持输入顺序,展示顺序(置顶、排序下拉等)由调用方决定;
* 标签里记录的 library_ids 顺序只表示归属,不作为展示排序。
*/
export function filterLibrariesByTag<T extends { id: string }>(
libraries: T[],
tags: LibraryTag[],
tagId: string,
): T[] {
if (!tagId || tagId === ALL_TAG_ID) return libraries
const ids = new Set(tagLibraryIds(tags, tagId))
if (ids.size === 0) return []
return libraries.filter((lib) => ids.has(lib.id))
}
/** 生成标签栏模型:首项固定为「全部」,其后按标签顺序追加。 */
export function buildLibraryTagTabs(libraries: Library[], tags: LibraryTag[]): LibraryTagTab[] {
const tabs: LibraryTagTab[] = [
{ id: ALL_TAG_ID, name: '全部', count: libraries.length, isAll: true },
]
if (tags.length === 0) return tabs
const available = new Set(libraries.map((lib) => lib.id))
for (const tag of tags) {
tabs.push({
id: tag.name,
name: tag.name,
count: tag.library_ids.filter((id) => available.has(id)).length,
isAll: false,
})
}
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 {
if (!selectedId || selectedId === ALL_TAG_ID) return ALL_TAG_ID
return tags.some((tag) => tag.name === selectedId) ? selectedId : ALL_TAG_ID
}
/** 标签下的媒体库条目总数(用于标题文案)。 */
export function sumLibraryTotals(libraries: Library[]): number {
return libraries.reduce((sum, lib) => sum + (lib.total ?? 0), 0)
}
/** 把一个媒体库挂到标签下:会先从原标签移除,保证一库一标签。 */
export function attachLibraryToTag(tags: LibraryTag[], libraryId: string, tagName: string): LibraryTag[] {
const target = findTagIdByName(tags, tagName)
if (!target) return tags
const next = tags.map((tag) => ({
...tag,
library_ids: tag.library_ids.filter((id) => id !== libraryId),
}))
return next.map((tag) =>
tag.name === target ? { ...tag, library_ids: [...tag.library_ids, libraryId] } : tag,
)
}
/** 从标签下移除媒体库。 */
export function detachLibraryFromTag(tags: LibraryTag[], libraryId: string, tagName: string): LibraryTag[] {
return tags.map((tag) =>
tag.name === tagName ? { ...tag, library_ids: tag.library_ids.filter((id) => id !== libraryId) } : tag,
)
}
/**
* 批量把一个或多个媒体库挂到标签下:先从各自原标签移除,再按传入顺序追加,
* 保证「一库一标签」。整批只产生一次写入。
*/
export function attachLibrariesToTag(
tags: LibraryTag[],
libraryIds: string[],
tagName: string,
): LibraryTag[] {
const target = findTagIdByName(tags, tagName)
const moving = normalizeLibraryIds(libraryIds)
if (!target || moving.length === 0) return tags
const movingSet = new Set(moving)
const next = tags.map((tag) => ({
...tag,
library_ids: tag.library_ids.filter((id) => !movingSet.has(id)),
}))
return next.map((tag) =>
tag.name === target ? { ...tag, library_ids: [...tag.library_ids, ...moving] } : tag,
)
}
/** 批量把媒体库移出所有标签(即变为「未分类」)。 */
export function detachLibrariesFromTag(tags: LibraryTag[], libraryIds: string[]): LibraryTag[] {
const moving = new Set(normalizeLibraryIds(libraryIds))
if (moving.size === 0) return tags
return tags.map((tag) => ({
...tag,
library_ids: tag.library_ids.filter((id) => !moving.has(id)),
}))
}
/** 批量归类:tagName 为空表示移出所有标签。 */
export function assignLibrariesToTag(
tags: LibraryTag[],
libraryIds: string[],
tagName: string,
): LibraryTag[] {
return tagName ? attachLibrariesToTag(tags, libraryIds, tagName) : detachLibrariesFromTag(tags, libraryIds)
}
/** 去掉空白与重复,保留首次出现的顺序。 */
export function normalizeLibraryIds(libraryIds: unknown): string[] {
if (!Array.isArray(libraryIds)) return []
const seen = new Set<string>()
const out: string[] = []
for (const raw of libraryIds) {
const trimmed = String(raw ?? '').trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
out.push(trimmed)
}
return out
}
/** 媒体库列表的模糊匹配(名称 / 路径 / 类型),供标签管理里的搜索框使用。 */
export function filterLibrariesForTagging(libraries: Library[], keyword: string): Library[] {
const needle = keyword.trim().toLowerCase()
if (!needle) return libraries
return libraries.filter((lib) =>
`${lib.name} ${lib.path} ${lib.type}`.toLowerCase().includes(needle),
)
}
+44
View File
@@ -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')
+11 -2
View File
@@ -107,6 +107,15 @@ function cleanFallbackFilename(raw: string): string {
return name
}
function isIndistinctVersionLabel(parts: string[]): boolean {
if (parts.length === 0) return true
return parts.every((part) => {
const upper = part.toUpperCase()
if (upper === '云端直链' || upper === 'STRM') return true
return VIDEO_EXTENSIONS.has(part.toLowerCase())
})
}
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积 / 规格 / 来源标签。 */
export function mediaVersionLabel(media: Media): string {
const isStrm = isStrmMedia(media)
@@ -153,11 +162,11 @@ export function mediaVersionLabel(media: Media): string {
parts.push('云端直链')
}
if (parts.length > 0) {
// 只有容器 /「云端直链」时无法区分多分片,回退文件名(sivr-270-1 / …part1)
if (parts.length > 0 && !isIndistinctVersionLabel(parts)) {
return parts.join(' · ')
}
// 兜底:使用清理后的文件名
const cleaned = cleanFallbackFilename(media.path || '')
return cleaned || media.title || media.id
}
-28
View File
@@ -67,31 +67,3 @@ export function togglePinnedLibraryId(current: string[], id: string): string[] {
export function isLibraryPinned(id: string, pinnedIds: string[]): boolean {
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
})
}
+179
View File
@@ -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')
+237
View File
@@ -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 模式。
}
}
+154
View File
@@ -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,
])
}
+365
View File
@@ -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()
},
}
}