mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ec9adc976 | |||
| f4abdf78e2 | |||
| ae8db26c1e | |||
| 8017e994f4 | |||
| d56fb53a08 | |||
| 30ca94c79b | |||
| 9c1f9a6e7e | |||
| ec7be38266 | |||
| ebf791c436 | |||
| 83c0b65a0b | |||
| 1153fe4191 | |||
| 93866d169b | |||
| d283d39008 | |||
| fc89d23867 | |||
| 1f14a1e85b | |||
| f0a6b630c6 | |||
| c723664e46 | |||
| 5faa6975d0 |
@@ -174,6 +174,39 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) {
|
||||||
|
svc := newDanmakuSettingsService(t)
|
||||||
|
|
||||||
|
// 默认未看过:配置接口必须先如实告知前端,否则首次操作说明会被跳过。
|
||||||
|
initial := svc.Danmaku.ConfigForUser(t.Context(), "user-1")
|
||||||
|
if initial.Vr360GuideSeen {
|
||||||
|
t.Fatal("vr360 guide should be unread for a fresh user")
|
||||||
|
}
|
||||||
|
|
||||||
|
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||||
|
`{"vr360_guide_seen":true}`, "user-1")
|
||||||
|
updateDanmakuSettingsHandler(svc)(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var cfg service.DanmakuRenderConfig
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.Vr360GuideSeen {
|
||||||
|
t.Fatalf("config should reflect the persisted guide flag: %+v", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||||
|
if err != nil || user == nil {
|
||||||
|
t.Fatalf("read persisted user: %v", err)
|
||||||
|
}
|
||||||
|
if !user.PlayerVr360GuideSeen {
|
||||||
|
t.Fatalf("guide flag not persisted on the user: %+v", user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
|
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
|
||||||
svc := newDanmakuSettingsService(t)
|
svc := newDanmakuSettingsService(t)
|
||||||
|
|
||||||
|
|||||||
@@ -722,7 +722,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mount.ProxyPlay {
|
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
|
||||||
|
// 与挂载账号的 proxy_play 开关等价。
|
||||||
|
if mount.ProxyPlay || wantSameOriginProxy(c) {
|
||||||
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
||||||
if !c.Writer.Written() {
|
if !c.Writer.Written() {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
@@ -758,6 +760,15 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// ?proxy=1:把网盘/STRM 直链改为服务端同源转发(网页端读帧、VR 全景用),
|
||||||
|
// 画质与原文件一致,不触发转码。
|
||||||
|
if wantSameOriginProxy(c) {
|
||||||
|
handled, proxyErr := proxySTRMStream(c, svc, m)
|
||||||
|
if handled {
|
||||||
|
writeProxyError(c, proxyErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
||||||
if errors.Is(err, service.ErrMediaNotFound) {
|
if errors.Is(err, service.ErrMediaNotFound) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// 直连流的同源转发:网页端需要读取视频帧的场景(VR 全景渲染走 WebGL 纹理)
|
||||||
|
// 不能使用会跳到网盘 CDN 的跨域直链,这里把直链改为服务端转发。
|
||||||
|
//
|
||||||
|
// 只在客户端显式带上 ?proxy=1 时生效,普通播放仍走原来的 302 直连,
|
||||||
|
// 避免把网盘流量无谓地压到服务器上。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wantSameOriginProxy 客户端是否要求把直连流改为服务端同源转发。
|
||||||
|
func wantSameOriginProxy(c *gin.Context) bool {
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(c.Query("proxy"))) {
|
||||||
|
case "1", "true", "yes":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxySTRMStream 处理 STRM/网盘媒体的同源转发请求。返回 handled=false 表示
|
||||||
|
// 该媒体不需要(或无法)代理,调用方继续按原有逻辑处理。
|
||||||
|
func proxySTRMStream(c *gin.Context, svc *service.Container, m *model.Media) (bool, error) {
|
||||||
|
if c == nil || svc == nil || svc.Strm == nil || m == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if !service.IsStrmMediaRow(m) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := svc.Strm.ProxyMediaDirect(c.Request.Context(), c.Writer, c.Request, m); err != nil {
|
||||||
|
if errors.Is(err, service.ErrStrmProxyNotApplicable) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeProxyError 在尚未写入任何响应内容时回一个明确的网关错误。
|
||||||
|
func writeProxyError(c *gin.Context, err error) {
|
||||||
|
if c == nil || err == nil || c.Writer.Written() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
+12
-9
@@ -37,15 +37,18 @@ type User struct {
|
|||||||
// original / simplified / traditional。
|
// original / simplified / traditional。
|
||||||
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||||
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
||||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||||
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
|
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
|
||||||
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
// PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明,
|
||||||
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
// 按用户保存:看过一次之后不再弹出。
|
||||||
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"`
|
||||||
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
|
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
||||||
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
|
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
||||||
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
|
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
||||||
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
|
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
|
||||||
|
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
|
||||||
|
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
|
||||||
|
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
|
||||||
// DanmakuAppKey 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
|
// DanmakuAppKey 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
|
||||||
DanmakuAppKey string `gorm:"size:256" json:"-"`
|
DanmakuAppKey string `gorm:"size:256" json:"-"`
|
||||||
// ExpiredAt is the account expiry time. Nil means the account never
|
// ExpiredAt is the account expiry time. Nil means the account never
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ type DanmakuRenderConfig struct {
|
|||||||
Volume float64 `json:"volume"`
|
Volume float64 `json:"volume"`
|
||||||
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
|
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
|
||||||
PlaybackRate float64 `json:"playback_rate"`
|
PlaybackRate float64 `json:"playback_rate"`
|
||||||
|
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
|
||||||
|
Vr360GuideSeen bool `json:"vr360_guide_seen"`
|
||||||
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
||||||
MergeSources bool `json:"merge_sources"`
|
MergeSources bool `json:"merge_sources"`
|
||||||
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
|
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
|
||||||
@@ -98,6 +100,8 @@ type DanmakuSettingsPatch struct {
|
|||||||
MergeSources *bool `json:"merge_sources"`
|
MergeSources *bool `json:"merge_sources"`
|
||||||
Volume *float64 `json:"volume"`
|
Volume *float64 `json:"volume"`
|
||||||
PlaybackRate *float64 `json:"playback_rate"`
|
PlaybackRate *float64 `json:"playback_rate"`
|
||||||
|
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
|
||||||
|
Vr360GuideSeen *bool `json:"vr360_guide_seen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type danmakuUserContextKey struct{}
|
type danmakuUserContextKey struct{}
|
||||||
@@ -447,6 +451,7 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
|||||||
Area: strconv.FormatFloat(area, 'f', -1, 64),
|
Area: strconv.FormatFloat(area, 'f', -1, 64),
|
||||||
Volume: volume,
|
Volume: volume,
|
||||||
PlaybackRate: playbackRate,
|
PlaybackRate: playbackRate,
|
||||||
|
Vr360GuideSeen: user.PlayerVr360GuideSeen,
|
||||||
MergeSources: user.DanmakuMergeSources,
|
MergeSources: user.DanmakuMergeSources,
|
||||||
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
|
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
|
||||||
}
|
}
|
||||||
@@ -528,6 +533,9 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
|
|||||||
}
|
}
|
||||||
updates["player_playback_rate"] = *patch.PlaybackRate
|
updates["player_playback_rate"] = *patch.PlaybackRate
|
||||||
}
|
}
|
||||||
|
if patch.Vr360GuideSeen != nil {
|
||||||
|
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
|
||||||
|
}
|
||||||
if len(updates) == 0 {
|
if len(updates) == 0 {
|
||||||
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
|
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -179,9 +178,21 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
|
|||||||
libraryIDs = []string{media.LibraryID}
|
libraryIDs = []string{media.LibraryID}
|
||||||
}
|
}
|
||||||
filter := repository.MediaQueryFilter{IncludeNSFW: true}
|
filter := repository.MediaQueryFilter{IncludeNSFW: true}
|
||||||
candidates, narrowed, err := s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
|
// 成人条目按番号分组,而番号常常只存在于路径/标题里:本地 NFO 来源的分片
|
||||||
if err != nil {
|
// 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部
|
||||||
return nil, err
|
// ID 预先收窄候选集,同番号的其它分片会被 SQL 直接排除,表现就是库里折叠
|
||||||
|
// 出了 N 个版本、详情页却只列出带 ID 的那几个。因此成人条目不预先收窄,
|
||||||
|
// 直接在该库范围内比对版本键。未刮削但文件名带番号的分片同理:同一部片的
|
||||||
|
// 各分片元数据来源不一致(刮削 vs pending),收窄后标题也对不上。
|
||||||
|
var candidates []model.Media
|
||||||
|
narrowed := false
|
||||||
|
hasAdultCode := mediaAdultGroupCode(*media) != "" ||
|
||||||
|
canonicalAdultGroupCode(AdultCodeFromMediaPath(media.Path)) != ""
|
||||||
|
if !hasAdultCode {
|
||||||
|
candidates, narrowed, err = s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !narrowed {
|
if !narrowed {
|
||||||
candidates, err = s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
|
candidates, err = s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
|
||||||
@@ -190,23 +201,21 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.attachLibraryMetadata(ctx, candidates)
|
s.attachLibraryMetadata(ctx, candidates)
|
||||||
|
// 同番号只要有一个分片被确认为成人内容,尚未刮削的分片也按番号折叠,
|
||||||
|
// 与列表页的分组结果保持一致。
|
||||||
|
vouched := adultCodesVouchedByNSFW(candidates)
|
||||||
|
if vouchKey := adultVouchedGroupKey(*media, vouched); vouchKey != "" {
|
||||||
|
key = vouchKey
|
||||||
|
}
|
||||||
matched := make([]model.Media, 0, 4)
|
matched := make([]model.Media, 0, 4)
|
||||||
for _, row := range candidates {
|
for _, row := range candidates {
|
||||||
if mediaVersionGroupKey(row) == key {
|
if mediaVersionGroupKeyWithAdultVouch(row, vouched) == key {
|
||||||
matched = append(matched, row)
|
matched = append(matched, row)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(matched) == 0 {
|
if len(matched) == 0 {
|
||||||
return []model.Media{*media}, nil
|
return []model.Media{*media}, nil
|
||||||
}
|
}
|
||||||
sort.SliceStable(matched, func(i, j int) bool {
|
sortMediaVersionsForDisplay(matched)
|
||||||
if matched[i].ID == media.ID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if matched[j].ID == media.ID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return betterMediaVersion(matched[i], matched[j])
|
|
||||||
})
|
|
||||||
return matched, nil
|
return matched, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package service
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
@@ -75,35 +77,33 @@ func groupMediaVersions(items []model.Media) []MediaItem {
|
|||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// 同一番号只要有一个分片被刮削标记为成人内容,就认定整部片都是成人条目:
|
||||||
|
// 未刮削的分片(nsfw=false)也按番号折叠。否则「一个分片刮削成功、其余仍
|
||||||
|
// pending」时,已刮削的走番号分组、其余落到标题分组,整部片被拆成两张卡。
|
||||||
|
adultCodes := adultCodesVouchedByNSFW(items)
|
||||||
type group struct {
|
type group struct {
|
||||||
key string
|
key string
|
||||||
primary model.Media
|
rows []model.Media
|
||||||
rows []model.Media
|
|
||||||
}
|
}
|
||||||
groups := make([]group, 0, len(items))
|
groups := make([]group, 0, len(items))
|
||||||
byKey := make(map[string]int, len(items))
|
byKey := make(map[string]int, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
key := mediaVersionGroupKey(item)
|
key := mediaVersionGroupKeyWithAdultVouch(item, adultCodes)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
groups = append(groups, group{primary: item, rows: []model.Media{item}})
|
groups = append(groups, group{rows: []model.Media{item}})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if idx, ok := byKey[key]; ok {
|
if idx, ok := byKey[key]; ok {
|
||||||
groups[idx].rows = append(groups[idx].rows, item)
|
groups[idx].rows = append(groups[idx].rows, item)
|
||||||
if betterMediaVersion(item, groups[idx].primary) {
|
|
||||||
groups[idx].primary = item
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
byKey[key] = len(groups)
|
byKey[key] = len(groups)
|
||||||
groups = append(groups, group{key: key, primary: item, rows: []model.Media{item}})
|
groups = append(groups, group{key: key, rows: []model.Media{item}})
|
||||||
}
|
}
|
||||||
out := make([]MediaItem, 0, len(groups))
|
out := make([]MediaItem, 0, len(groups))
|
||||||
for _, g := range groups {
|
for _, g := range groups {
|
||||||
sort.SliceStable(g.rows, func(i, j int) bool {
|
sortMediaVersionsForDisplay(g.rows)
|
||||||
return betterMediaVersion(g.rows[i], g.rows[j])
|
item := MediaItem{Media: g.rows[0]}
|
||||||
})
|
|
||||||
item := MediaItem{Media: g.primary}
|
|
||||||
if len(g.rows) > 1 {
|
if len(g.rows) > 1 {
|
||||||
item.Versions = g.rows
|
item.Versions = g.rows
|
||||||
}
|
}
|
||||||
@@ -139,16 +139,65 @@ func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem {
|
|||||||
return grouped
|
return grouped
|
||||||
}
|
}
|
||||||
|
|
||||||
func mediaVersionGroupKey(m model.Media) string {
|
// mediaVersionLibraryKey 返回版本身份里使用的库标识。
|
||||||
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
func mediaVersionLibraryKey(m model.Media) string {
|
||||||
if IsEmbyRemoteID(m.ID) {
|
|
||||||
return fmt.Sprintf("embyremote:%s", m.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
|
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
|
||||||
if libKey == "" {
|
if libKey == "" {
|
||||||
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
|
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
|
||||||
}
|
}
|
||||||
|
return libKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// adultCodesVouchedByNSFW 收集「已被刮削确认为成人内容」的番号。
|
||||||
|
func adultCodesVouchedByNSFW(items []model.Media) map[string]bool {
|
||||||
|
codes := map[string]bool{}
|
||||||
|
for _, item := range items {
|
||||||
|
if !item.NSFW {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if code := canonicalAdultGroupCode(AdultCodeFromMediaPath(item.Path)); code != "" {
|
||||||
|
codes[code] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if code := mediaAdultGroupCode(item); code != "" {
|
||||||
|
codes[code] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaVersionGroupKeyWithAdultVouch 是版本身份的统一入口:番号已被同组分片
|
||||||
|
// 确认时优先按番号折叠,否则沿用 mediaVersionGroupKey 的原判定。
|
||||||
|
func mediaVersionGroupKeyWithAdultVouch(m model.Media, vouched map[string]bool) string {
|
||||||
|
if key := adultVouchedGroupKey(m, vouched); key != "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return mediaVersionGroupKey(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adultVouchedGroupKey 在番号已被同组分片确认时,返回番号分组键;
|
||||||
|
// 其余情况返回空串,表示沿用 mediaVersionGroupKey 的原判定。
|
||||||
|
func adultVouchedGroupKey(m model.Media, vouched map[string]bool) string {
|
||||||
|
if len(vouched) == 0 || m.NSFW {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
code := canonicalAdultGroupCode(AdultCodeFromMediaPath(m.Path))
|
||||||
|
if code == "" || !vouched[code] {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
libKey := mediaVersionLibraryKey(m)
|
||||||
|
if libKey == "" {
|
||||||
|
libKey = "_"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("adult:%s:%s", libKey, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
||||||
|
if IsEmbyRemoteID(m.ID) {
|
||||||
|
return fmt.Sprintf("embyremote:%s", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
libKey := mediaVersionLibraryKey(m)
|
||||||
specialKind := mediaSpecialKind(m.Path)
|
specialKind := mediaSpecialKind(m.Path)
|
||||||
season, episode := m.SeasonNum, m.EpisodeNum
|
season, episode := m.SeasonNum, m.EpisodeNum
|
||||||
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
|
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
|
||||||
@@ -193,6 +242,18 @@ func mediaVersionGroupKey(m model.Media) string {
|
|||||||
return mediaVersionStemGroupKey(m, libKey)
|
return mediaVersionStemGroupKey(m, libKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 成人条目按番号折叠。在线刮削(MetaTube)会把番号与 provider「借用」
|
||||||
|
// 存进 douban_id/thetvdb_id,本地 NFO 路径则拿不到这两个字段;一旦按
|
||||||
|
// 外部 ID 分组,同一部片的各个分片就会因为元数据来源不同(在线刮削 vs
|
||||||
|
// 本地 NFO)裂成两张标题相同的卡。番号取自原文件名/路径/标题,与元数据
|
||||||
|
// 来源无关,因此这里统一用番号做版本身份。
|
||||||
|
if code := mediaAdultGroupCode(m); code != "" {
|
||||||
|
if libKey == "" {
|
||||||
|
libKey = "_"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("adult:%s:%s", libKey, code)
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case m.TMDbID > 0:
|
case m.TMDbID > 0:
|
||||||
if libKey != "" {
|
if libKey != "" {
|
||||||
@@ -267,6 +328,44 @@ func mediaVersionGroupKey(m model.Media) string {
|
|||||||
return fmt.Sprintf("movie:%s:%d", title, year)
|
return fmt.Sprintf("movie:%s:%d", title, year)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mediaAdultGroupCode 返回成人条目的番号,作为与元数据来源无关的版本身份。
|
||||||
|
//
|
||||||
|
// 只在已经确认是成人条目(NSFW)时才返回番号:番号本身来自原文件名/路径/
|
||||||
|
// 标题,不会随「在线刮削(MetaTube 把番号与 provider 借用进 douban_id/
|
||||||
|
// thetvdb_id)」还是「本地 NFO(不带外部 ID)」而改变,因此两条来源能算出
|
||||||
|
// 同一个键。未标记 NSFW 的条目仍走标题分组,普通影视库里 "The Matrix 1999"
|
||||||
|
// 这类文件名不会被当成番号。
|
||||||
|
func mediaAdultGroupCode(m model.Media) string {
|
||||||
|
if !m.NSFW {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
code := firstText(
|
||||||
|
normalizeAdultCode(m.OriginalName),
|
||||||
|
AdultCodeFromMediaPath(m.Path),
|
||||||
|
normalizeAdultCode(m.Title),
|
||||||
|
)
|
||||||
|
if code == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return canonicalAdultGroupCode(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalAdultGroupCode 去掉番号数字部分的补零,让 IPVR-00192 与 IPVR-192
|
||||||
|
// 这类同一部片的不同写法落到同一个分组键上。无法解析数字的番号原样返回
|
||||||
|
// (如 FC2-PPV-4701981)。
|
||||||
|
func canonicalAdultGroupCode(code string) string {
|
||||||
|
code = strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
prefix, digits, ok := strings.Cut(code, "-")
|
||||||
|
if !ok {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(digits)
|
||||||
|
if err != nil {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
return prefix + "-" + strconv.Itoa(value)
|
||||||
|
}
|
||||||
|
|
||||||
// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
|
// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
|
||||||
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
|
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
|
||||||
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
|
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
|
||||||
@@ -300,8 +399,11 @@ func mediaVersionFileStem(name string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
|
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
|
||||||
|
// 当只有容器等无法区分多版本的信息时,回退到清理后的文件名。
|
||||||
func MediaVersionLabel(m model.Media) string {
|
func MediaVersionLabel(m model.Media) string {
|
||||||
parts := make([]string, 0, 4)
|
parts := make([]string, 0, 4)
|
||||||
|
isStrm := strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") ||
|
||||||
|
strings.TrimSpace(m.STRMURL) != ""
|
||||||
if m.Height > 0 {
|
if m.Height > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("%dp", m.Height))
|
parts = append(parts, fmt.Sprintf("%dp", m.Height))
|
||||||
} else if m.Width > 0 {
|
} else if m.Width > 0 {
|
||||||
@@ -317,17 +419,91 @@ func MediaVersionLabel(m model.Media) string {
|
|||||||
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
|
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
|
||||||
parts = append(parts, strings.ToUpper(codec))
|
parts = append(parts, strings.ToUpper(codec))
|
||||||
}
|
}
|
||||||
if m.SizeBytes > 0 {
|
// STRM 占位体积(通常几十到几百字节)不能区分分片,跳过。
|
||||||
|
placeholderSize := isStrm && m.SizeBytes > 0 && m.SizeBytes < 1024*1024
|
||||||
|
if m.SizeBytes > 0 && !placeholderSize {
|
||||||
parts = append(parts, formatMediaSize(m.SizeBytes))
|
parts = append(parts, formatMediaSize(m.SizeBytes))
|
||||||
}
|
}
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 && !mediaVersionLabelIndistinct(parts) {
|
||||||
return strings.Join(parts, " · ")
|
return strings.Join(parts, " · ")
|
||||||
}
|
}
|
||||||
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/"))
|
if cleaned := cleanMediaVersionFilename(m.Path); cleaned != "" {
|
||||||
if base == "" || base == "." {
|
return cleaned
|
||||||
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
|
|
||||||
}
|
}
|
||||||
return base
|
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaVersionLabelIndistinct(parts []string) bool {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, part := range parts {
|
||||||
|
upper := strings.ToUpper(strings.TrimSpace(part))
|
||||||
|
if upper == "" || upper == "云端直链" || upper == "STRM" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := videoExtensions["."+strings.ToLower(upper)]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanMediaVersionFilename(path string) string {
|
||||||
|
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
|
||||||
|
if base == "" || base == "." {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
stem := mediaFileStem(base)
|
||||||
|
stem = strings.ReplaceAll(stem, "_", " ")
|
||||||
|
stem = strings.ReplaceAll(stem, ".", " ")
|
||||||
|
return strings.Join(strings.Fields(stem), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaPartNumber 从路径提取分片序号(part1 / cd2 / -3)。
|
||||||
|
// 裸后缀只认 1–2 位数字,避免把年份 2024 当成 part。
|
||||||
|
func mediaPartNumber(path string) (int, bool) {
|
||||||
|
stem := mediaFileStem(filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")))
|
||||||
|
if stem == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(stem)
|
||||||
|
for _, re := range mediaPartNumberPatterns {
|
||||||
|
if m := re.FindStringSubmatch(lower); len(m) == 2 {
|
||||||
|
n, err := strconv.Atoi(m[1])
|
||||||
|
if err == nil && n > 0 {
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var mediaPartNumberPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?:^|[.\-_])part[\s._-]*(\d{1,3})$`),
|
||||||
|
regexp.MustCompile(`(?:^|[.\-_])(?:cd|disc|disk)[\s._-]*(\d{1,3})$`),
|
||||||
|
regexp.MustCompile(`[-_](\d{1,2})$`),
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortMediaVersionsForDisplay(rows []model.Media) {
|
||||||
|
sort.SliceStable(rows, func(i, j int) bool {
|
||||||
|
return compareMediaVersionsForDisplay(rows[i], rows[j])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareMediaVersionsForDisplay:有分片号时按 1→2→3… 升序;否则按画质/体积择优。
|
||||||
|
func compareMediaVersionsForDisplay(a, b model.Media) bool {
|
||||||
|
pa, oka := mediaPartNumber(a.Path)
|
||||||
|
pb, okb := mediaPartNumber(b.Path)
|
||||||
|
if oka && okb {
|
||||||
|
if pa != pb {
|
||||||
|
return pa < pb
|
||||||
|
}
|
||||||
|
} else if oka != okb {
|
||||||
|
return oka
|
||||||
|
}
|
||||||
|
return betterMediaVersion(a, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mediaVersionContainerFromPath(path, strmURL string) string {
|
func mediaVersionContainerFromPath(path, strmURL string) string {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 真实场景:同一部片的分片里只有第 4 个被刮削成功(nsfw=true),其余仍是 pending。
|
||||||
|
// 修复前:已刮削的分片走「番号」分组、其余落到「标题」分组,7 个分片会裂成两张卡
|
||||||
|
// (一张只有第 4 个,另一张是其余 6 个)。
|
||||||
|
func TestGroupMediaVersionsKeepsUnscrapedPartsWithScrapedSibling(t *testing.T) {
|
||||||
|
const libID = "lib-1"
|
||||||
|
const dir = "/media/云下载/sivr-270/"
|
||||||
|
|
||||||
|
parts := func(nums ...int) []model.Media {
|
||||||
|
out := make([]model.Media, 0, len(nums))
|
||||||
|
for _, n := range nums {
|
||||||
|
out = append(out, model.Media{
|
||||||
|
LibraryID: libID,
|
||||||
|
Title: "sivr 270",
|
||||||
|
Path: dir + "sivr-270-" + strconv.Itoa(n) + ".strm",
|
||||||
|
Container: "strm",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
items := parts(1, 2, 3)
|
||||||
|
items = append(items, model.Media{
|
||||||
|
LibraryID: libID,
|
||||||
|
Title: "SIVR-270-【VR】河北彩花×ご奉仕ナース",
|
||||||
|
OriginalName: "SIVR-270",
|
||||||
|
Path: dir + "sivr-270-4.strm",
|
||||||
|
NSFW: true,
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
Container: "strm",
|
||||||
|
})
|
||||||
|
items = append(items, parts(5, 6, 7)...)
|
||||||
|
|
||||||
|
grouped := GroupMediaVersions(items)
|
||||||
|
if len(grouped) != 1 {
|
||||||
|
paths := make([]string, 0, len(grouped))
|
||||||
|
for _, g := range grouped {
|
||||||
|
paths = append(paths, g.Path)
|
||||||
|
}
|
||||||
|
t.Fatalf("分成 %d 张卡 %v,期望折叠成 1 张", len(grouped), paths)
|
||||||
|
}
|
||||||
|
if len(grouped[0].Versions) != 7 {
|
||||||
|
t.Fatalf("版本数 = %d,期望 7", len(grouped[0].Versions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反向保护:没有任何分片被确认为成人内容时,不能仅凭文件名里的番号形态折叠,
|
||||||
|
// 否则普通媒体也可能被误并进同一张卡。
|
||||||
|
func TestGroupMediaVersionsIgnoresUnvouchedAdultCode(t *testing.T) {
|
||||||
|
items := []model.Media{
|
||||||
|
{LibraryID: "lib-2", Title: "Alpha Movie", Path: "/media/movies/Alpha Movie/ABC-123-1080p.mkv"},
|
||||||
|
{LibraryID: "lib-2", Title: "Beta Movie", Path: "/media/movies/Beta Movie/ABC-123-2160p.mkv"},
|
||||||
|
}
|
||||||
|
grouped := GroupMediaVersions(items)
|
||||||
|
if len(grouped) != 2 {
|
||||||
|
t.Fatalf("分成 %d 张卡,期望 2 张(未被确认成人内容的番号不参与折叠)", len(grouped))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -442,3 +442,298 @@ func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) {
|
|||||||
t.Fatalf("unexpected label %q", label)
|
t.Fatalf("unexpected label %q", label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMediaPartNumberFromPath(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want int
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm", 1, true},
|
||||||
|
{"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm", 2, true},
|
||||||
|
{"/media/云下载/sivr-270/sivr-270-1.strm", 1, true},
|
||||||
|
{"/media/云下载/sivr-270/sivr-270-7.strm", 7, true},
|
||||||
|
{"/media/云下载/fc2/FC2PPV-4701981-cd2.strm", 2, true},
|
||||||
|
{"/media/Movies/Inception.2010.1080p.BluRay.x264.mkv", 0, false},
|
||||||
|
{"/media/Movies/Movie.2024.mkv", 0, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, ok := mediaPartNumber(tc.path)
|
||||||
|
if ok != tc.ok || got != tc.want {
|
||||||
|
t.Fatalf("mediaPartNumber(%q) = (%d, %v), want (%d, %v)", tc.path, got, ok, tc.want, tc.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupMediaVersionsSortsPartsAscending(t *testing.T) {
|
||||||
|
rows := []model.Media{
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "p3"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "SIVR-270",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-3.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
SizeBytes: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "p1"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "SIVR-270",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
SizeBytes: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "p2"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "SIVR-270",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-2.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
SizeBytes: 200,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
grouped := groupMediaVersions(rows)
|
||||||
|
if len(grouped) != 1 || len(grouped[0].Versions) != 3 {
|
||||||
|
t.Fatalf("grouped = %#v", grouped)
|
||||||
|
}
|
||||||
|
want := []string{"p1", "p2", "p3"}
|
||||||
|
for i, id := range want {
|
||||||
|
if grouped[0].Versions[i].ID != id {
|
||||||
|
t.Fatalf("versions[%d] = %q, want %q (order %#v)", i, grouped[0].Versions[i].ID, id, grouped[0].Versions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if grouped[0].ID != "p1" {
|
||||||
|
t.Fatalf("primary = %q, want p1 (first part)", grouped[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaVersionLabelFallsBackToFilenameWhenIndistinct(t *testing.T) {
|
||||||
|
label := MediaVersionLabel(model.Media{
|
||||||
|
Title: "SIVR-270-【VR】河北彩花",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||||
|
SizeBytes: 157,
|
||||||
|
STRMURL: "/api/strm/play/cloud115/video.mp4?pickcode=a",
|
||||||
|
})
|
||||||
|
if !strings.Contains(label, "sivr-270-1") {
|
||||||
|
t.Fatalf("indistinct strm label should fall back to filename, got %q", label)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(label, "MP4") || strings.Contains(label, "云端") {
|
||||||
|
t.Fatalf("should not keep generic-only label %q", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIVR-270 现场:一部分分片走在线刮削(MetaTube 把番号/provider 借用进
|
||||||
|
// douban_id/thetvdb_id),另一部分只从本地 NFO 拿到标题(没有外部 ID)。
|
||||||
|
// 按外部 ID 分组会把它们裂成两张标题完全相同的卡,必须按番号折成一张。
|
||||||
|
func TestGroupMediaVersionsMergesAdultPartsAcrossMetadataSources(t *testing.T) {
|
||||||
|
online := model.Media{
|
||||||
|
Base: model.Base{ID: "sivr-270-2"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "SIVR-270-【VR】河北彩花",
|
||||||
|
OriginalName: "SIVR-270",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-2.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
DoubanID: "SIVR-270",
|
||||||
|
TheTVDBID: "JavBus",
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
SizeBytes: 200,
|
||||||
|
}
|
||||||
|
fromNFO := model.Media{
|
||||||
|
Base: model.Base{ID: "sivr-270-3"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "SIVR-270-【VR】河北彩花",
|
||||||
|
OriginalName: "SIVR-270",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-3.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
SizeBytes: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped := groupMediaVersions([]model.Media{online, fromNFO})
|
||||||
|
if len(grouped) != 1 {
|
||||||
|
t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped)
|
||||||
|
}
|
||||||
|
if len(grouped[0].Versions) != 2 {
|
||||||
|
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
|
||||||
|
}
|
||||||
|
if grouped[0].Path != online.Path {
|
||||||
|
t.Fatalf("larger part should stay primary, got %q", grouped[0].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupMediaVersionsKeepsDifferentAdultCodesSeparate(t *testing.T) {
|
||||||
|
rows := []model.Media{
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "sivr-270"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "SIVR-270 作品",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "sivr-271"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "SIVR-271 作品",
|
||||||
|
Path: "/media/云下载/sivr-271/sivr-271-1.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
grouped := groupMediaVersions(rows)
|
||||||
|
if len(grouped) != 2 {
|
||||||
|
t.Fatalf("different adult codes must stay separate: %#v", grouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一部片的两种写法:路径里的 IPVR-00192 与刮削回来的 IPVR-192 必须归一。
|
||||||
|
func TestGroupMediaVersionsMergesAdultCodesIgnoringZeroPadding(t *testing.T) {
|
||||||
|
scraped := model.Media{
|
||||||
|
Base: model.Base{ID: "ipvr-192-part2"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "IPVR-192-相沢みなみ",
|
||||||
|
OriginalName: "IPVR-192",
|
||||||
|
Path: "/media/云下载/ipvr00192pl/scraped/part2.strm",
|
||||||
|
Year: 2022,
|
||||||
|
NSFW: true,
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
SizeBytes: 200,
|
||||||
|
}
|
||||||
|
fromPath := model.Media{
|
||||||
|
Base: model.Base{ID: "ipvr-00192-part1"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "fbzip com@ipvr00192",
|
||||||
|
Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm",
|
||||||
|
Year: 2022,
|
||||||
|
NSFW: true,
|
||||||
|
SizeBytes: 100,
|
||||||
|
}
|
||||||
|
grouped := groupMediaVersions([]model.Media{scraped, fromPath})
|
||||||
|
if len(grouped) != 1 || len(grouped[0].Versions) != 2 {
|
||||||
|
t.Fatalf("zero padded code must fold into one item: %#v", grouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupMediaVersionsMergesAdultPartsWithBorrowedMetaTubeIDs(t *testing.T) {
|
||||||
|
// IPVR-00192 现场:两个 part 标题、年份完全一样,但 part2 是走 MetaTube
|
||||||
|
// 刮削的(番号/provider 被借用进 douban_id/thetvdb_id),part1 只有本地
|
||||||
|
// NFO 身份、两个字段为空。旧逻辑按外部 ID 分组会裂成两张卡。
|
||||||
|
part1 := model.Media{
|
||||||
|
Base: model.Base{ID: "ipvr-00192-part1"},
|
||||||
|
LibraryID: "cloud",
|
||||||
|
Title: "IPVR-00192-IPVR-192-【VR】相沢みなみ",
|
||||||
|
OriginalName: "IPVR-00192",
|
||||||
|
Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm",
|
||||||
|
Year: 2022,
|
||||||
|
NSFW: true,
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
SizeBytes: 100,
|
||||||
|
}
|
||||||
|
part2 := part1
|
||||||
|
part2.ID = "ipvr-00192-part2"
|
||||||
|
part2.Path = "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm"
|
||||||
|
part2.DoubanID = "ipvr00192"
|
||||||
|
part2.TheTVDBID = "JAV321"
|
||||||
|
part2.SizeBytes = 200
|
||||||
|
|
||||||
|
grouped := groupMediaVersions([]model.Media{part1, part2})
|
||||||
|
if len(grouped) != 1 {
|
||||||
|
t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped)
|
||||||
|
}
|
||||||
|
if len(grouped[0].Versions) != 2 {
|
||||||
|
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
|
||||||
|
}
|
||||||
|
if grouped[0].Versions[0].ID != "ipvr-00192-part1" || grouped[0].Versions[1].ID != "ipvr-00192-part2" {
|
||||||
|
t.Fatalf("versions should be part1 then part2, got %#v", grouped[0].Versions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMediaItemListsAdultVersionsAcrossMetadataSources(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
lib := model.Library{Name: "云下载", Path: "/media/云下载", Type: "movie", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows := []model.Media{
|
||||||
|
{
|
||||||
|
// 走 MetaTube 刮削的分片:番号/provider 被借用进 douban/thetvdb。
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "SIVR-270-【VR】河北彩花",
|
||||||
|
OriginalName: "SIVR-270",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
DoubanID: "SIVR-270",
|
||||||
|
TheTVDBID: "JavBus",
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
SizeBytes: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 只有本地 NFO 身份的分片:两个外部 ID 都为空。
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "SIVR-270-【VR】河北彩花",
|
||||||
|
OriginalName: "SIVR-270",
|
||||||
|
Path: "/media/云下载/sivr-270/sivr-270-4.strm",
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
SizeBytes: 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := db.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
|
||||||
|
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
|
||||||
|
item, err := svc.GetMediaItem(t.Context(), rows[0].ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if item == nil || len(item.Versions) != 2 {
|
||||||
|
// 详情页版本列表不能只按外部 ID 收窄,否则没有 ID 的分片会消失。
|
||||||
|
t.Fatalf("versions = %#v, want both parts", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaAdultGroupCodeRequiresNSFW(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
m model.Media
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "普通影片名里的字母+数字不当番号",
|
||||||
|
m: model.Media{Title: "Spider-Man 2008 1080p", Path: "/media/Movies/Spider-Man 2008 1080p.mkv"},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "未识别成人的条目即使文件名像番号也不折叠",
|
||||||
|
m: model.Media{Title: "SIVR-270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "已标记 NSFW 时按番号分组",
|
||||||
|
m: model.Media{NSFW: true, Title: "sivr 270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"},
|
||||||
|
want: "SIVR-270",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "站点前缀里带补零的番号会归一",
|
||||||
|
m: model.Media{NSFW: true, Title: "fbzip com@ipvr00192", Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm"},
|
||||||
|
want: "IPVR-192",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := mediaAdultGroupCode(tc.m); got != tc.want {
|
||||||
|
t.Fatalf("mediaAdultGroupCode = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+44
-14
@@ -18,6 +18,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -48,9 +49,13 @@ type movieNFO struct {
|
|||||||
Poster string `xml:"thumb,omitempty"`
|
Poster string `xml:"thumb,omitempty"`
|
||||||
Fanart string `xml:"fanart,omitempty"`
|
Fanart string `xml:"fanart,omitempty"`
|
||||||
TMDb int `xml:"tmdbid,omitempty"`
|
TMDb int `xml:"tmdbid,omitempty"`
|
||||||
Genre []string `xml:"genre,omitempty"`
|
// UniqueIDs 让 sidecar 能把 MeBox 自己用的外部 ID 原样带回来。缺了它,
|
||||||
Country []string `xml:"country,omitempty"`
|
// 「在线刮削 → 写 NFO → 重扫读 NFO」会丢掉 douban/thetvdb(成人条目借用
|
||||||
Language []string `xml:"language,omitempty"`
|
// 这两个字段存番号/provider),同一部片的两条来源就会算出不同的版本键。
|
||||||
|
UniqueIDs []nfoUniqueID `xml:"uniqueid,omitempty"`
|
||||||
|
Genre []string `xml:"genre,omitempty"`
|
||||||
|
Country []string `xml:"country,omitempty"`
|
||||||
|
Language []string `xml:"language,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type episodeNFO struct {
|
type episodeNFO struct {
|
||||||
@@ -167,17 +172,18 @@ func WriteMediaNFO(m *model.Media) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
doc = movieNFO{
|
doc = movieNFO{
|
||||||
Title: title,
|
Title: title,
|
||||||
Original: original,
|
Original: original,
|
||||||
Year: m.Year,
|
Year: m.Year,
|
||||||
Plot: m.Overview,
|
Plot: m.Overview,
|
||||||
Rating: m.Rating,
|
Rating: m.Rating,
|
||||||
Poster: m.PosterURL,
|
Poster: m.PosterURL,
|
||||||
Fanart: m.BackdropURL,
|
Fanart: m.BackdropURL,
|
||||||
TMDb: m.TMDbID,
|
TMDb: m.TMDbID,
|
||||||
Genre: splitNFOList(m.Genres),
|
UniqueIDs: nfoExternalUniqueIDs(m),
|
||||||
Country: splitNFOList(m.Countries),
|
Genre: splitNFOList(m.Genres),
|
||||||
Language: splitNFOList(m.Languages),
|
Country: splitNFOList(m.Countries),
|
||||||
|
Language: splitNFOList(m.Languages),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out, err := xml.MarshalIndent(doc, "", " ")
|
out, err := xml.MarshalIndent(doc, "", " ")
|
||||||
@@ -202,3 +208,27 @@ func splitNFOList(value string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nfoExternalUniqueIDs 把 MeBox 使用的外部 ID 写进 sidecar,供自己重扫时读回
|
||||||
|
// (metadataFromDoc 会按 type 还原成 bangumi/douban/thetvdb 字段)。
|
||||||
|
// TMDb 走 <tmdbid>,单集 NFO 不写唯一 ID:单集 ID 属于单集,写进整剧身份会把
|
||||||
|
// 同一部剧拆成多张卡(见 mergeEpisodeMetadata 的约束说明)。
|
||||||
|
func nfoExternalUniqueIDs(m *model.Media) []nfoUniqueID {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]nfoUniqueID, 0, 3)
|
||||||
|
if m.BangumiID > 0 {
|
||||||
|
out = append(out, nfoUniqueID{Type: "bangumi", Value: strconv.Itoa(m.BangumiID)})
|
||||||
|
}
|
||||||
|
if id := strings.TrimSpace(m.DoubanID); id != "" {
|
||||||
|
out = append(out, nfoUniqueID{Type: "douban", Value: id})
|
||||||
|
}
|
||||||
|
if id := strings.TrimSpace(m.TheTVDBID); id != "" {
|
||||||
|
out = append(out, nfoUniqueID{Type: "thetvdb", Value: id})
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,3 +100,51 @@ func TestWriteMediaNFOAdultTitleWithCode(t *testing.T) {
|
|||||||
t.Fatalf("expected originaltitle with code, got:\n%s", text)
|
t.Fatalf("expected originaltitle with code, got:\n%s", text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteMediaNFORoundTripsExternalUniqueIDs(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mediaPath := filepath.Join(root, "云下载", "sivr-270", "sivr-270-1.strm")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(mediaPath, []byte("strm"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst, err := WriteMediaNFO(&model.Media{
|
||||||
|
Title: "SIVR-270-河北彩花",
|
||||||
|
OriginalName: "SIVR-270",
|
||||||
|
Path: mediaPath,
|
||||||
|
Year: 2023,
|
||||||
|
NSFW: true,
|
||||||
|
DoubanID: "SIVR-270",
|
||||||
|
TheTVDBID: "JavBus",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
text := string(body)
|
||||||
|
// 成人条目把番号/provider 借用存在 douban/thetvdb 两列;sidecar 必须把它们
|
||||||
|
// 带回来,否则「在线刮削 → 写 NFO → 重扫读 NFO」之后同一部片会被按不同
|
||||||
|
// 分组键拆成多张卡。
|
||||||
|
for _, want := range []string{
|
||||||
|
`<uniqueid type="douban">SIVR-270</uniqueid>`,
|
||||||
|
`<uniqueid type="thetvdb">JavBus</uniqueid>`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("sidecar missing %s, got:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := ReadLocalMetadata(mediaPath, "", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meta == nil || meta.DoubanID != "SIVR-270" || meta.TheTVDBID != "JavBus" {
|
||||||
|
t.Fatalf("external ids did not round trip: %#v", meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re
|
|||||||
if !cloudPlaybackModeEnabled(r.Context(), s.repo, cloudMode) {
|
if !cloudPlaybackModeEnabled(r.Context(), s.repo, cloudMode) {
|
||||||
return ErrCloudPlaybackDisabled
|
return ErrCloudPlaybackDisabled
|
||||||
}
|
}
|
||||||
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host。
|
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host;
|
||||||
target := normalizeCloudPlayTarget(strmURL)
|
// 指向别的 MeBox 实例的地址保持原样,按第三方直链透传。
|
||||||
|
target := normalizeCloudPlayTarget(r.Context(), s.repo, s.cfg, r, strmURL)
|
||||||
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
|
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
|
||||||
setCloudRedirectNoStore(w)
|
setCloudRedirectNoStore(w)
|
||||||
http.Redirect(w, r, absoluteInternalRedirect(target, r), http.StatusFound)
|
http.Redirect(w, r, absoluteInternalRedirect(target, r), http.StatusFound)
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestNormalizeCloudPlayTarget 验证存库的云盘播放 URL(可能携带扫描时的
|
// TestNormalizeCloudPlayTarget 验证存库的云盘播放 URL(可能携带扫描时的旧 host)
|
||||||
// 旧 host)被规范化为相对路径,使 302 始终基于当前请求地址构造。
|
// 被规范化为相对路径,使 302 始终基于当前请求地址构造。
|
||||||
func TestNormalizeCloudPlayTarget(t *testing.T) {
|
func TestNormalizeCloudPlayTarget(t *testing.T) {
|
||||||
ref := "/电影/某部影片 (2024)/movie.mkv"
|
ref := "/电影/某部影片 (2024)/movie.mkv"
|
||||||
stale := "http://192.168.1.4:9011/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
|
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/media-1", nil)
|
||||||
got := normalizeCloudPlayTarget(stale)
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 相对路径:本来就是本机形态,按 provider+ref 重建(保持相对)。
|
||||||
|
relative := "/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
|
||||||
|
got := normalizeCloudPlayTarget(ctx, nil, nil, req, relative)
|
||||||
want := BuildRelativeCloudPlayURL("openlist", ref)
|
want := BuildRelativeCloudPlayURL("openlist", ref)
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("normalizeCloudPlayTarget = %q, want %q", got, want)
|
t.Fatalf("normalizeCloudPlayTarget(relative) = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
parsed, err := url.Parse(got)
|
parsed, err := url.Parse(got)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -26,16 +35,83 @@ func TestNormalizeCloudPlayTarget(t *testing.T) {
|
|||||||
t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref"))
|
t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref"))
|
||||||
}
|
}
|
||||||
|
|
||||||
strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
// 绝对地址但 host 就是当前请求 host:宿主切换过(开发机扫描 → 部署)
|
||||||
gotStrm := normalizeCloudPlayTarget(strmStale)
|
// 之后仍要能当作本机地址处理。
|
||||||
|
sameHost := "http://nas.local:18080/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
||||||
|
gotStrm := normalizeCloudPlayTarget(ctx, nil, nil, req, sameHost)
|
||||||
wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
||||||
if gotStrm != wantStrm {
|
if gotStrm != wantStrm {
|
||||||
t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm)
|
t.Fatalf("normalizeCloudPlayTarget(same host) = %q, want %q", gotStrm, wantStrm)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 非云盘播放 URL 保持原样(WebDAV/直链等)。
|
// 非云盘播放 URL 保持原样(WebDAV/直链等)。
|
||||||
passthrough := "https://dav.example.com/media/file.mkv"
|
passthrough := "https://dav.example.com/media/file.mkv"
|
||||||
if got := normalizeCloudPlayTarget(passthrough); got != passthrough {
|
if got := normalizeCloudPlayTarget(ctx, nil, nil, req, passthrough); got != passthrough {
|
||||||
t.Fatalf("non-cloud target should pass through, got %q", got)
|
t.Fatalf("non-cloud target should pass through, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNormalizeCloudPlayTargetKeepsForeignInstanceURL 验证别的 MeBox /
|
||||||
|
// MediaStationGo 实例生成的 .strm 内容不被本机账号解析,而是按第三方直链透传。
|
||||||
|
func TestNormalizeCloudPlayTargetKeepsForeignInstanceURL(t *testing.T) {
|
||||||
|
svc := testStrmService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
// 本机自己的 115 账号(strm.base_url 由 testStrmService 设为 http://test.local:8096)。
|
||||||
|
own := &model.StrmAccount{
|
||||||
|
Base: model.Base{ID: "acct-own-115"},
|
||||||
|
Name: "own",
|
||||||
|
Provider: model.StrmProvider115,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := svc.repo.StrmAccount.Create(ctx, own); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://mebox.local/api/stream/media-1", nil)
|
||||||
|
|
||||||
|
// host 不认识 + acct 不是本机账号 → 另一个实例的 .strm,原样透传。
|
||||||
|
foreign := "http://other-mebox.example:18080/api/strm/play/cloud115/video.mkv?acct=acct-other&pickcode=xyz"
|
||||||
|
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, foreign); got != foreign {
|
||||||
|
t.Fatalf("foreign instance url should pass through, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// host 不认识但 acct 是本机账号 → 本机换了域名/IP 的老 .strm,仍要认领。
|
||||||
|
staleOwn := "http://192.168.1.4:9011/api/strm/play/cloud115/video.mkv?acct=acct-own-115&pickcode=123"
|
||||||
|
wantStale := "/api/strm/play/cloud115/video.mkv?acct=acct-own-115&pickcode=123"
|
||||||
|
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, staleOwn); got != wantStale {
|
||||||
|
t.Fatalf("own acct on stale host = %q, want %q", got, wantStale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// acct 撞上本机账号 ID 但账号类型与路径 provider 不一致:不算本机。
|
||||||
|
wrongProvider := "http://other-mebox.example/api/strm/play/openlist/video.mkv?acct=acct-own-115&pickcode=123"
|
||||||
|
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, wrongProvider); got != wrongProvider {
|
||||||
|
t.Fatalf("provider mismatch should pass through, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeCloudPlayTargetLegacyCloudURL 旧格式 /api/cloud/play(不带 acct)
|
||||||
|
// 无法凭账号 ID 判断归属:本机配了该类型账号就按本机处理(保住老固化地址的可
|
||||||
|
// 播放性),完全没配才透传。
|
||||||
|
func TestNormalizeCloudPlayTargetLegacyCloudURL(t *testing.T) {
|
||||||
|
svc := testStrmService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://mebox.local/api/stream/media-1", nil)
|
||||||
|
ref := "/Movies/Movie.mkv"
|
||||||
|
legacy := "http://old-host:9011/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
|
||||||
|
|
||||||
|
// 本机没有 openlist 账号 → 不是本机地址。
|
||||||
|
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, legacy); got != legacy {
|
||||||
|
t.Fatalf("legacy url without local provider should pass through, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.repo.StrmAccount.Create(ctx, &model.StrmAccount{
|
||||||
|
Base: model.Base{ID: "acct-openlist"},
|
||||||
|
Name: "openlist",
|
||||||
|
Provider: model.StrmProviderOpenList,
|
||||||
|
Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, legacy); got != BuildRelativeCloudPlayURL("openlist", ref) {
|
||||||
|
t.Fatalf("legacy url with local provider = %q, want relative rebuild", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。
|
// normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。
|
||||||
@@ -11,28 +15,197 @@ import (
|
|||||||
// STRMURL 是扫描时根据当时的 server_url/请求地址生成并固化进数据库的。
|
// STRMURL 是扫描时根据当时的 server_url/请求地址生成并固化进数据库的。
|
||||||
// 在 Windows 开发机上扫描、再部署到 Docker(或更换了内网 IP/域名)后,
|
// 在 Windows 开发机上扫描、再部署到 Docker(或更换了内网 IP/域名)后,
|
||||||
// 这些绝对 URL 会指向已失效的旧地址,第三方播放器跟随 302 就会拿到
|
// 这些绝对 URL 会指向已失效的旧地址,第三方播放器跟随 302 就会拿到
|
||||||
// 连接失败/404。这里只要能从 URL 中解析出 provider+ref,就重建为相对
|
// 连接失败/404。所以只要确认这个地址「是本机自己生成的」,就从 URL 中解析出
|
||||||
// /api/cloud/play 路径,由 absoluteInternalRedirect 基于「当前请求」补全
|
// provider+ref,重建为相对 /api/cloud/play 或 /api/strm/play 路径,由
|
||||||
// host,从而对历史脏数据免疫。
|
// absoluteInternalRedirect 基于「当前请求」补全 host,从而对历史脏数据免疫。
|
||||||
func normalizeCloudPlayTarget(raw string) string {
|
//
|
||||||
|
// 反过来,指向**别的 MeBox / MediaStationGo 实例**的地址不能按本机账号解析:
|
||||||
|
// 别人 .strm 里的 acct 是他那台机器的账号 ID,拿到本机来查只会得到
|
||||||
|
// 「网盘账号不存在」。这种地址按普通第三方直链原样透传,让客户端跟着 302 去
|
||||||
|
// 对方实例取流(/api/strm/play 是公开端点,不需要本机凭据),或直接去对方的
|
||||||
|
// CDN 直链。归属判断见 isInternalPlaybackTarget。
|
||||||
|
func normalizeCloudPlayTarget(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request, raw string) string {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
if !isInternalPlaybackTarget(ctx, repo, cfg, r, raw) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
if typ, ref, ok := parseCloudMediaPlaybackURL(raw); ok {
|
if typ, ref, ok := parseCloudMediaPlaybackURL(raw); ok {
|
||||||
return BuildRelativeCloudPlayURL(typ, ref)
|
return BuildRelativeCloudPlayURL(typ, ref)
|
||||||
}
|
}
|
||||||
if u, err := url.Parse(raw); err == nil {
|
u, err := url.Parse(raw)
|
||||||
path := strings.ToLower(u.Path)
|
if err != nil {
|
||||||
if strings.HasPrefix(path, "/api/strm/play/") || strings.HasPrefix(path, "/api/cloud/play/") || strings.HasPrefix(path, "/api/stream/") {
|
return raw
|
||||||
u.Scheme = ""
|
}
|
||||||
u.Host = ""
|
path := strings.ToLower(u.Path)
|
||||||
return u.String()
|
if strings.HasPrefix(path, "/api/strm/play/") || strings.HasPrefix(path, "/api/cloud/play/") || strings.HasPrefix(path, "/api/stream/") {
|
||||||
}
|
u.Scheme = ""
|
||||||
|
u.Host = ""
|
||||||
|
return u.String()
|
||||||
}
|
}
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isInternalPlaybackTarget 判断播放地址能否按「本机自己的云盘播放地址」处理
|
||||||
|
// (相对化 + 用本机账号解析)。判断顺序:
|
||||||
|
//
|
||||||
|
// 1. 相对路径一定是本机存库的常规形态;
|
||||||
|
// 2. 绝对地址的 host 与本机配置的 strm.base_url、各同步目录覆盖的 base_url、
|
||||||
|
// 当前请求 host 之一相同 → 就是本机(老 .strm 里固化的旧 host 属于这一类);
|
||||||
|
// 3. host 对不上时,再看地址里带的网盘账号 ID 是不是本机账号:MeBox /
|
||||||
|
// MediaStationGo 会把本机账号 ID 写进 acct,而账号 ID 由各实例自行生成,
|
||||||
|
// 跨实例几乎不可能撞号。这条用于「换了域名/IP 之后」认领自己的老 .strm。
|
||||||
|
//
|
||||||
|
// 三条都不满足(典型是另一台 MeBox 生成的 .strm)→ 视为第三方直链。
|
||||||
|
func isInternalPlaybackTarget(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request, raw string) bool {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil || u == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if u.Host == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !isPlaybackAPIPath(u.Path) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if matchesLocalPlaybackHost(ctx, repo, cfg, r, u) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return localAccountOwnsPlaybackTarget(ctx, repo, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPlaybackAPIPath 判断路径是不是本服务自己的播放端点。
|
||||||
|
func isPlaybackAPIPath(path string) bool {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(path))
|
||||||
|
for _, prefix := range []string{"/api/strm/play/", "/api/cloud/play/", "/api/stream/"} {
|
||||||
|
if strings.HasPrefix(lower, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesLocalPlaybackHost 判断绝对播放地址的 host 是否就是本机。
|
||||||
|
func matchesLocalPlaybackHost(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request, u *url.URL) bool {
|
||||||
|
if u == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
target := canonicalHostPort(u)
|
||||||
|
if target == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, base := range localPlaybackBaseURLs(ctx, repo, cfg, r) {
|
||||||
|
parsed, err := url.Parse(base)
|
||||||
|
if err != nil || parsed == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if canonicalHostPort(parsed) == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// localPlaybackBaseURLs 汇总本机的播放基地址:管理员配置的公网地址 /
|
||||||
|
// strm.base_url、每条 STRM 同步目录单独覆盖的 base_url,以及当前请求的 host
|
||||||
|
// (含反向代理头)。请求 host 也要算进来:没有配 base_url 时 .strm 里固化的
|
||||||
|
// 就是请求地址。
|
||||||
|
func localPlaybackBaseURLs(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request) []string {
|
||||||
|
bases := make([]string, 0, 4)
|
||||||
|
if base := PublicServerURL(ctx, repo, cfg); base != "" {
|
||||||
|
bases = append(bases, base)
|
||||||
|
}
|
||||||
|
if repo != nil && repo.StrmSyncPath != nil {
|
||||||
|
if paths, err := repo.StrmSyncPath.List(ctx); err == nil {
|
||||||
|
for i := range paths {
|
||||||
|
if override := strings.TrimSpace(paths[i].StrmBaseURL); override != "" {
|
||||||
|
bases = append(bases, override)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r != nil {
|
||||||
|
if host := strings.TrimSpace(r.Host); host != "" {
|
||||||
|
bases = append(bases, "//"+host)
|
||||||
|
}
|
||||||
|
for _, header := range []string{"X-Forwarded-Host", "X-Original-Host"} {
|
||||||
|
if host := strings.TrimSpace(r.Header.Get(header)); host != "" {
|
||||||
|
bases = append(bases, "//"+host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bases
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalHostPort 归一化 host[:port]:小写、忽略默认端口(http 80 / https 443)。
|
||||||
|
func canonicalHostPort(u *url.URL) string {
|
||||||
|
if u == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
port := strings.TrimSpace(u.Port())
|
||||||
|
if port == "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
|
||||||
|
if (scheme == "http" || scheme == "") && port == "80" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
if scheme == "https" && port == "443" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return host + ":" + port
|
||||||
|
}
|
||||||
|
|
||||||
|
// localAccountOwnsPlaybackTarget 用「地址里带的网盘账号是不是本机的」判断播放
|
||||||
|
// 地址归属。账号 ID 跨实例不会撞号,因此比 host 更可靠:
|
||||||
|
//
|
||||||
|
// - /api/strm/play/{provider}/video{ext}?acct=…:acct 能在本机查到、且账号类型
|
||||||
|
// 与路径里的 provider 一致,才认作本机地址;
|
||||||
|
// - /api/cloud/play/{provider}?ref=…(旧格式,不带 acct):只能退化为「本机是否
|
||||||
|
// 配置了该类型账号」,配置了才按本机处理,保持老版本固化地址的可播放性。
|
||||||
|
func localAccountOwnsPlaybackTarget(ctx context.Context, repo *repository.Container, u *url.URL) bool {
|
||||||
|
if repo == nil || repo.StrmAccount == nil || u == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
segments := strings.Split(strings.Trim(strings.TrimSpace(u.Path), "/"), "/")
|
||||||
|
if len(segments) < 4 || !strings.EqualFold(segments[0], "api") || !strings.EqualFold(segments[2], "play") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
provider := strings.TrimSpace(segments[3])
|
||||||
|
if provider == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(segments[1]) {
|
||||||
|
case "strm":
|
||||||
|
accountID := strings.TrimSpace(u.Query().Get("acct"))
|
||||||
|
if accountID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
account, err := repo.StrmAccount.FindByID(ctx, accountID)
|
||||||
|
if err != nil || account == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(account.Provider), provider)
|
||||||
|
case "cloud":
|
||||||
|
accounts, err := repo.StrmAccount.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range accounts {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(accounts[i].Provider), provider) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// BuildRelativeCloudPlayURL 构造相对的云盘播放 API 路径。
|
// BuildRelativeCloudPlayURL 构造相对的云盘播放 API 路径。
|
||||||
func BuildRelativeCloudPlayURL(typ, ref string) string {
|
func BuildRelativeCloudPlayURL(typ, ref string) string {
|
||||||
return "/api/cloud/play/" + url.PathEscape(strings.TrimSpace(typ)) + "?" + url.Values{"ref": []string{ref}}.Encode()
|
return "/api/cloud/play/" + url.PathEscape(strings.TrimSpace(typ)) + "?" + url.Values{"ref": []string{ref}}.Encode()
|
||||||
|
|||||||
@@ -216,6 +216,78 @@ func TestServeFileRedirectsLocalSTRMFileTargetByDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 别的 MeBox / MediaStationGo 实例生成的 .strm:里面的 acct 是对方实例的账号,
|
||||||
|
// 本机不能拿自己的账号去解析,直接把 302 透传给客户端,由客户端去对方实例取流。
|
||||||
|
func TestServeFilePassesThroughForeignInstanceSTRMURL(t *testing.T) {
|
||||||
|
repos := newStreamTestRepo(t)
|
||||||
|
target := "http://other-mebox.example:18080/api/strm/play/cloud115/video.mkv?acct=other-acct&pickcode=xyz"
|
||||||
|
if err := repos.DB.Create(&model.Media{
|
||||||
|
Base: model.Base{ID: "foreign-strm"},
|
||||||
|
Title: "Foreign STRM",
|
||||||
|
Path: "D:/media/Foreign.strm",
|
||||||
|
Container: "strm",
|
||||||
|
STRMURL: target,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/foreign-strm?token=jwt123", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
if err := svc.ServeFile(w, req, "foreign-strm"); err != nil {
|
||||||
|
t.Fatalf("foreign instance strm url should be passed through: %v", err)
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status = %d, want 302", w.Code)
|
||||||
|
}
|
||||||
|
loc := w.Header().Get("Location")
|
||||||
|
if loc != target {
|
||||||
|
t.Fatalf("Location = %q, want untouched %q", loc, target)
|
||||||
|
}
|
||||||
|
if strings.Contains(loc, "jwt123") {
|
||||||
|
t.Fatalf("foreign instance url must not receive our auth token, got %q", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本机自己生成的 .strm 在换了域名/IP 之后仍要认领:host 对不上,但 acct 是本机
|
||||||
|
// 网盘账号,于是按当前请求 host 相对化,保持可播放。
|
||||||
|
func TestServeFileRealignsOwnSTRMURLOtherHost(t *testing.T) {
|
||||||
|
repos := repository.New(newServiceTestDB(t, &model.Media{}, &model.Setting{}, &model.StrmAccount{}))
|
||||||
|
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
|
||||||
|
Base: model.Base{ID: "own-acct"},
|
||||||
|
Name: "own",
|
||||||
|
Provider: model.StrmProvider115,
|
||||||
|
Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repos.DB.Create(&model.Media{
|
||||||
|
Base: model.Base{ID: "own-strm"},
|
||||||
|
Title: "Own STRM",
|
||||||
|
Path: "D:/media/Own.strm",
|
||||||
|
Container: "strm",
|
||||||
|
STRMURL: "http://old-host:9011/api/strm/play/cloud115/video.mkv?acct=own-acct&pickcode=123",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/own-strm?token=jwt123", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
if err := svc.ServeFile(w, req, "own-strm"); err != nil {
|
||||||
|
t.Fatalf("own strm url on a stale host should still play: %v", err)
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status = %d, want 302", w.Code)
|
||||||
|
}
|
||||||
|
loc := w.Header().Get("Location")
|
||||||
|
if !strings.HasPrefix(loc, "http://nas.local:18080/api/strm/play/cloud115/video.mkv?") ||
|
||||||
|
!strings.Contains(loc, "acct=own-acct") ||
|
||||||
|
!strings.Contains(loc, "pickcode=123") {
|
||||||
|
t.Fatalf("own strm url should be realigned to current host, got %q", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCloudPlaybackModeUsesExplicitModeBeforeLegacySTRMFlag(t *testing.T) {
|
func TestCloudPlaybackModeUsesExplicitModeBeforeLegacySTRMFlag(t *testing.T) {
|
||||||
repos := newStreamTestRepo(t)
|
repos := newStreamTestRepo(t)
|
||||||
if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeRedirectProxy {
|
if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeRedirectProxy {
|
||||||
|
|||||||
+106
-12
@@ -121,7 +121,7 @@ func (s *StrmService) resolveLocalPlay(ctx context.Context, rawPath string) (*St
|
|||||||
// 支持:
|
// 支持:
|
||||||
// - /api/strm/play/{provider}/video{ext}?acct=..&pickcode=.. (常规格式,含账号)
|
// - /api/strm/play/{provider}/video{ext}?acct=..&pickcode=.. (常规格式,含账号)
|
||||||
// - /api/cloud/play/{type}?ref=.. (旧格式,无账号 → 取该类型第一个启用账号)
|
// - /api/cloud/play/{type}?ref=.. (旧格式,无账号 → 取该类型第一个启用账号)
|
||||||
// - 绝对 http(s) 链接(直接透传)
|
// - 绝对 http(s) 链接(直接透传,包含别的 MeBox / MediaStationGo 实例的播放端点)
|
||||||
// - 其余协议(webdav:// 等)返回错误,由调用方决定是否静默跳过
|
// - 其余协议(webdav:// 等)返回错误,由调用方决定是否静默跳过
|
||||||
func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmPlayResult, error) {
|
func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmPlayResult, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
@@ -132,6 +132,17 @@ func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmP
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("解析播放目标失败: %w", err)
|
return nil, fmt.Errorf("解析播放目标失败: %w", err)
|
||||||
}
|
}
|
||||||
|
// 别的 MeBox / MediaStationGo 实例的播放端点:本机没有对应账号,按普通外部
|
||||||
|
// 直链处理——客户端与 ffmpeg 直接跟随 302 去对方实例取流(/api/strm/play 是
|
||||||
|
// 公开端点,不需要本机凭据),不能拿本机账号去查别人的 pickcode。
|
||||||
|
if isPlaybackAPIPath(u.Path) && !s.isLocalPlaybackTarget(ctx, raw) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
|
||||||
|
case "http", "https":
|
||||||
|
return &StrmPlayResult{RedirectURL: raw}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("不支持的播放目标协议: %s", u.Scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
lowerPath := strings.ToLower(u.Path)
|
lowerPath := strings.ToLower(u.Path)
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(lowerPath, "/api/strm/play/"):
|
case strings.HasPrefix(lowerPath, "/api/strm/play/"):
|
||||||
@@ -156,6 +167,16 @@ func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isLocalPlaybackTarget 报告播放地址是否属于本机。这里没有 HTTP 请求上下文,
|
||||||
|
// 「本机」由 strm.base_url / 各同步目录覆盖的 base_url / 本机网盘账号共同界定
|
||||||
|
// (见 isInternalPlaybackTarget)。
|
||||||
|
func (s *StrmService) isLocalPlaybackTarget(ctx context.Context, raw string) bool {
|
||||||
|
if s == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isInternalPlaybackTarget(ctx, s.repo, s.cfg, nil, raw)
|
||||||
|
}
|
||||||
|
|
||||||
// firstEnabledAccountOf 返回指定提供方第一个凭据可用的启用账号。
|
// firstEnabledAccountOf 返回指定提供方第一个凭据可用的启用账号。
|
||||||
func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string) (*model.StrmAccount, error) {
|
func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string) (*model.StrmAccount, error) {
|
||||||
accounts, err := s.repo.StrmAccount.List(ctx)
|
accounts, err := s.repo.StrmAccount.List(ctx)
|
||||||
@@ -174,38 +195,111 @@ func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrStrmProxyNotApplicable 表示该媒体没有需要服务端转发的直链(本地文件本身同源)。
|
||||||
|
var ErrStrmProxyNotApplicable = errors.New("strm proxy not applicable")
|
||||||
|
|
||||||
|
// ProxyMediaDirect 把媒体行的网盘/STRM 直链解析为真实地址后由服务端反向代理给
|
||||||
|
// 客户端,让浏览器拿到「同源」数据。画质与原文件完全一致,不触发任何转码。
|
||||||
|
//
|
||||||
|
// 用途:VR 全景播放要把视频帧读进 WebGL 纹理,而跨域直链(网盘 302 跳到 CDN)
|
||||||
|
// 在浏览器里属于被污染的资源,WebGL 读取会抛 SecurityError;把流量经服务端转发
|
||||||
|
// 是「原画 + VR」唯一可行的办法。
|
||||||
|
func (s *StrmService) ProxyMediaDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, m *model.Media) error {
|
||||||
|
if s == nil || m == nil {
|
||||||
|
return ErrStrmProxyNotApplicable
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(m.STRMURL)
|
||||||
|
if raw == "" {
|
||||||
|
path := strings.TrimSpace(m.Path)
|
||||||
|
if !strings.HasSuffix(strings.ToLower(path), ".strm") {
|
||||||
|
return ErrStrmProxyNotApplicable
|
||||||
|
}
|
||||||
|
target, err := readLocalSTRMTarget(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw = strings.TrimSpace(target)
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
return ErrStrmProxyNotApplicable
|
||||||
|
}
|
||||||
|
result, err := s.ResolvePlayTarget(ctx, raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case result.Link != nil && result.Link.URL != "":
|
||||||
|
return s.ProxyDirect(ctx, w, r, result.Link)
|
||||||
|
case result.RedirectURL != "":
|
||||||
|
return s.ProxyDirect(ctx, w, r, &cloud.DirectLink{URL: result.RedirectURL})
|
||||||
|
default:
|
||||||
|
// 本地文件(LocalPath)由静态文件处理器提供,本身就是同源。
|
||||||
|
return ErrStrmProxyNotApplicable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
|
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
|
||||||
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
||||||
if link == nil || link.URL == "" {
|
if link == nil || link.URL == "" {
|
||||||
return errors.New("空直链")
|
return errors.New("空直链")
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil)
|
method := http.MethodGet
|
||||||
|
if r != nil && r.Method == http.MethodHead {
|
||||||
|
method = http.MethodHead
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, link.URL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for k, v := range link.Headers {
|
for k, v := range link.Headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
if r != nil {
|
||||||
req.Header.Set("Range", rangeHeader)
|
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
||||||
|
req.Header.Set("Range", rangeHeader)
|
||||||
|
}
|
||||||
|
// 部分网盘直链按 UA 防盗链;解析时未绑定 UA 的直链沿用浏览器 UA 更稳。
|
||||||
|
if ua := strings.TrimSpace(r.Header.Get("User-Agent")); ua != "" && req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", ua)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
resp, err := s.http.Do(req)
|
resp, err := s.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag"} {
|
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
|
||||||
if value := resp.Header.Get(header); value != "" {
|
if value := resp.Header.Get(header); value != "" {
|
||||||
w.Header().Set(header, value)
|
w.Header().Set(header, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
// 原样透传上游状态码:Range 请求必须回 206,改写成 200 会让浏览器误判
|
||||||
w.WriteHeader(http.StatusOK)
|
// 响应长度,拖动进度条时反复重新拉流。
|
||||||
} else {
|
w.WriteHeader(resp.StatusCode)
|
||||||
w.WriteHeader(resp.StatusCode)
|
if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if resp.StatusCode == http.StatusPartialContent || resp.StatusCode == http.StatusOK {
|
if method == http.MethodHead {
|
||||||
_, _ = io.Copy(w, resp.Body)
|
return nil
|
||||||
}
|
}
|
||||||
return nil
|
// 边转发边 flush,避免大体积视频被 net/http 的写缓冲切成一段段卡顿。
|
||||||
|
writer := io.Writer(w)
|
||||||
|
if flusher, ok := w.(http.Flusher); ok {
|
||||||
|
writer = &flushWriter{writer: w, flusher: flusher}
|
||||||
|
}
|
||||||
|
_, err = io.Copy(writer, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type flushWriter struct {
|
||||||
|
writer io.Writer
|
||||||
|
flusher http.Flusher
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *flushWriter) Write(p []byte) (int, error) {
|
||||||
|
n, err := f.writer.Write(p)
|
||||||
|
if f.flusher != nil {
|
||||||
|
f.flusher.Flush()
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 同源转发必须原样保留 Range 语义:把 206 改写成 200 会让浏览器误判响应长度,
|
||||||
|
// 拖动进度条时反复重新拉流。
|
||||||
|
func TestProxyMediaDirectForwardsRangeAndStatus(t *testing.T) {
|
||||||
|
var gotRange string
|
||||||
|
var gotUA string
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotRange = r.Header.Get("Range")
|
||||||
|
gotUA = r.Header.Get("User-Agent")
|
||||||
|
w.Header().Set("Content-Type", "video/mp4")
|
||||||
|
w.Header().Set("Accept-Ranges", "bytes")
|
||||||
|
w.Header().Set("Content-Range", "bytes 10-19/100")
|
||||||
|
w.WriteHeader(http.StatusPartialContent)
|
||||||
|
_, _ = w.Write([]byte("0123456789"))
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
svc := testStrmService(t)
|
||||||
|
media := &model.Media{STRMURL: upstream.URL + "/video.mp4"}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||||
|
req.Header.Set("Range", "bytes=10-19")
|
||||||
|
req.Header.Set("User-Agent", "MeBoxTest/1.0")
|
||||||
|
|
||||||
|
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
|
||||||
|
t.Fatalf("ProxyMediaDirect: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusPartialContent {
|
||||||
|
t.Fatalf("status = %d, want 206", rec.Code)
|
||||||
|
}
|
||||||
|
if gotRange != "bytes=10-19" {
|
||||||
|
t.Fatalf("upstream Range = %q, want bytes=10-19", gotRange)
|
||||||
|
}
|
||||||
|
if gotUA != "MeBoxTest/1.0" {
|
||||||
|
t.Fatalf("upstream User-Agent = %q, want MeBoxTest/1.0", gotUA)
|
||||||
|
}
|
||||||
|
if rec.Header().Get("Content-Range") != "bytes 10-19/100" {
|
||||||
|
t.Fatalf("Content-Range = %q", rec.Header().Get("Content-Range"))
|
||||||
|
}
|
||||||
|
if rec.Body.String() != "0123456789" {
|
||||||
|
t.Fatalf("body = %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地文件本身就是同源资源,不应该被代理(调用方按原静态文件逻辑处理)。
|
||||||
|
func TestProxyMediaDirectSkipsLocalFile(t *testing.T) {
|
||||||
|
svc := testStrmService(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "movie.mkv")
|
||||||
|
writeFile(t, path, "not-a-video")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||||
|
err := svc.ProxyMediaDirect(context.Background(), rec, req, &model.Media{Path: path})
|
||||||
|
if !errors.Is(err, ErrStrmProxyNotApplicable) {
|
||||||
|
t.Fatalf("err = %v, want ErrStrmProxyNotApplicable", err)
|
||||||
|
}
|
||||||
|
if rec.Body.Len() != 0 {
|
||||||
|
t.Fatalf("local file must not be proxied, body = %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -746,7 +746,10 @@ func TestHandleMetaSha1Identity(t *testing.T) {
|
|||||||
|
|
||||||
// fakeRemoteProvider 是 walkRemote 并发遍历的假提供方:返回一棵固定目录树,
|
// fakeRemoteProvider 是 walkRemote 并发遍历的假提供方:返回一棵固定目录树,
|
||||||
// 并记录每个目录被 List 的次数,用于验证并发遍历无漏目录、无重复目录。
|
// 并记录每个目录被 List 的次数,用于验证并发遍历无漏目录、无重复目录。
|
||||||
|
// walkRemote 会并发调用 List,因此计数必须加锁:此前直接写 map 会偶发
|
||||||
|
// "fatal error: concurrent map writes",把整个测试进程带走。
|
||||||
type fakeRemoteProvider struct {
|
type fakeRemoteProvider struct {
|
||||||
|
mu sync.Mutex
|
||||||
listed map[string]int
|
listed map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,11 +759,20 @@ func (f *fakeRemoteProvider) Resolve(context.Context, string) (*cloud.DirectLink
|
|||||||
return &cloud.DirectLink{URL: "http://cdn/x.mkv"}, nil
|
return &cloud.DirectLink{URL: "http://cdn/x.mkv"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listedCount 返回某个目录被 List 的次数。
|
||||||
|
func (f *fakeRemoteProvider) listedCount(dirID string) int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.listed[dirID]
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeRemoteProvider) List(_ context.Context, dirID string) ([]cloud.FileEntry, error) {
|
func (f *fakeRemoteProvider) List(_ context.Context, dirID string) ([]cloud.FileEntry, error) {
|
||||||
|
f.mu.Lock()
|
||||||
if f.listed == nil {
|
if f.listed == nil {
|
||||||
f.listed = map[string]int{}
|
f.listed = map[string]int{}
|
||||||
}
|
}
|
||||||
f.listed[dirID]++
|
f.listed[dirID]++
|
||||||
|
f.mu.Unlock()
|
||||||
switch dirID {
|
switch dirID {
|
||||||
case "root":
|
case "root":
|
||||||
return []cloud.FileEntry{
|
return []cloud.FileEntry{
|
||||||
@@ -828,8 +840,8 @@ func TestWalkRemoteConcurrent(t *testing.T) {
|
|||||||
st.flushPreferredVideos()
|
st.flushPreferredVideos()
|
||||||
|
|
||||||
for _, dir := range []string{"root", "a", "a1", "b"} {
|
for _, dir := range []string{"root", "a", "a1", "b"} {
|
||||||
if provider.listed[dir] != 1 {
|
if count := provider.listedCount(dir); count != 1 {
|
||||||
t.Errorf("目录 %s 被列出 %d 次,期望 1 次", dir, provider.listed[dir])
|
t.Errorf("目录 %s 被列出 %d 次,期望 1 次", dir, count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -410,14 +410,10 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
|
|||||||
}
|
}
|
||||||
return s.serveEmbedded(ctx, m, index, w)
|
return s.serveEmbedded(ctx, m, index, w)
|
||||||
}
|
}
|
||||||
abs, err := filepath.Abs(sub)
|
abs, err := readExternalSubtitlePath(m, sub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
mediaDir, _ := filepath.Abs(filepath.Dir(m.Path))
|
|
||||||
if !pathWithin(abs, mediaDir) {
|
|
||||||
return fmt.Errorf("path escape")
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the media file directory with pathWithin.
|
f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the media file directory with pathWithin.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -428,14 +424,17 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 非 UTF-8 的外挂字幕(UTF-16、GBK/Big5 等)必须先归一化:浏览器只能按
|
||||||
|
// UTF-8 解析 <track> 内容,否则整篇都会变成替换字符。
|
||||||
|
text := decodeSubtitleText(body)
|
||||||
|
|
||||||
switch strings.ToLower(filepath.Ext(abs)) {
|
switch strings.ToLower(filepath.Ext(abs)) {
|
||||||
case ".vtt":
|
case ".vtt":
|
||||||
_, err = w.Write(body)
|
_, err = io.WriteString(w, text)
|
||||||
case ".srt":
|
case ".srt":
|
||||||
_, err = w.Write([]byte(srtToVTT(string(body))))
|
_, err = io.WriteString(w, srtToVTT(text))
|
||||||
case ".ass", ".ssa":
|
case ".ass", ".ssa":
|
||||||
_, err = w.Write([]byte(assToVTT(string(body))))
|
_, err = io.WriteString(w, assToVTT(text))
|
||||||
default:
|
default:
|
||||||
return errors.New("unsupported subtitle format")
|
return errors.New("unsupported subtitle format")
|
||||||
}
|
}
|
||||||
@@ -517,12 +516,42 @@ func (s *SubtitleService) ServeASS(ctx context.Context, mediaID, sub string, w i
|
|||||||
}
|
}
|
||||||
switch strings.ToLower(filepath.Ext(sub)) {
|
switch strings.ToLower(filepath.Ext(sub)) {
|
||||||
case ".ass", ".ssa":
|
case ".ass", ".ssa":
|
||||||
return s.ServeRaw(ctx, mediaID, sub, w)
|
// 外挂 ASS 同样要归一化成 UTF-8:libass 只认 UTF-8,UTF-16/GBK 的
|
||||||
|
// 字幕交给它会解析不到任何事件,表现为「字幕选中了却不显示」。
|
||||||
|
abs, err := readExternalSubtitlePath(m, sub)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the media file directory with pathWithin.
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
body, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.WriteString(w, decodeSubtitleText(body))
|
||||||
|
return err
|
||||||
default:
|
default:
|
||||||
return errors.New("subtitle is not ASS/SSA")
|
return errors.New("subtitle is not ASS/SSA")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readExternalSubtitlePath 校验外挂字幕路径(必须落在媒体文件所在目录内)并返回
|
||||||
|
// 绝对路径。Serve 与 ServeASS 共用,避免两处各自实现出现安全口径不一致。
|
||||||
|
func readExternalSubtitlePath(m *model.Media, sub string) (string, error) {
|
||||||
|
abs, err := filepath.Abs(sub)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
mediaDir, _ := filepath.Abs(filepath.Dir(m.Path))
|
||||||
|
if !pathWithin(abs, mediaDir) {
|
||||||
|
return "", fmt.Errorf("path escape")
|
||||||
|
}
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SubtitleService) serveEmbeddedASS(ctx context.Context, media *model.Media, streamIndex int, w io.Writer) error {
|
func (s *SubtitleService) serveEmbeddedASS(ctx context.Context, media *model.Media, streamIndex int, w io.Writer) error {
|
||||||
input, err := s.resolveInput(ctx, media)
|
input, err := s.resolveInput(ctx, media)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf16"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"golang.org/x/text/encoding"
|
||||||
|
"golang.org/x/text/encoding/japanese"
|
||||||
|
"golang.org/x/text/encoding/korean"
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
|
"golang.org/x/text/encoding/traditionalchinese"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 外挂字幕文件的编码归一化。
|
||||||
|
//
|
||||||
|
// 字幕文件由字幕组/工具生成,编码并不统一:除 UTF-8 外,中文圈常见 GBK/GB18030,
|
||||||
|
// 港台常见 Big5,部分 Windows 工具(Aegisub 早期版本、Subtitle Edit 的某些导出)
|
||||||
|
// 会写成带 BOM 的 UTF-16LE。浏览器 <track> 与 libass(JASSUB)都只按 UTF-8 解析,
|
||||||
|
// 后端如果原样下发字节,非 UTF-8 的字幕会整篇变成替换字符——表现就是「字幕轨已经
|
||||||
|
// 选中,但画面上一个字都不显示」。所以外挂字幕在服务端就要统一解码成 UTF-8。
|
||||||
|
//
|
||||||
|
// 注意:只用于下发给浏览器/播放器的文本路径。Emby 兼容层的 /subtitles 原始字节
|
||||||
|
// 接口(ServeRaw)必须保持字节一致,不能经过这里。
|
||||||
|
|
||||||
|
// 各编码的 BOM 前缀。
|
||||||
|
var (
|
||||||
|
utf8BOM = []byte{0xEF, 0xBB, 0xBF}
|
||||||
|
utf16BEBOM = []byte{0xFE, 0xFF}
|
||||||
|
utf16LEBOM = []byte{0xFF, 0xFE}
|
||||||
|
)
|
||||||
|
|
||||||
|
// subtitleLegacyEncodings 是无 BOM 时的候选编码,按优先级排列。
|
||||||
|
// GB18030 是 GBK/GB2312 的超集,中文外挂字幕绝大多数是它,因此排在最前——
|
||||||
|
// 多个候选都能解出合法字幕时,靠前的优先。
|
||||||
|
var subtitleLegacyEncodings = []struct {
|
||||||
|
name string
|
||||||
|
enc encoding.Encoding
|
||||||
|
}{
|
||||||
|
{"gb18030", simplifiedchinese.GB18030},
|
||||||
|
{"big5", traditionalchinese.Big5},
|
||||||
|
{"shift_jis", japanese.ShiftJIS},
|
||||||
|
{"euc-kr", korean.EUCKR},
|
||||||
|
}
|
||||||
|
|
||||||
|
// subtitleTimecodePattern 匹配 SRT(00:00:01,000 --> ...)与 WebVTT
|
||||||
|
// (00:00:01.000 --> ...)的时间行。
|
||||||
|
var subtitleTimecodePattern = regexp.MustCompile(`\d{1,2}:\d{2}:\d{2}[,.]\d{1,3}\s*-->`)
|
||||||
|
|
||||||
|
// decodeSubtitleText 把字幕文件字节归一化成 UTF-8 文本。识别不出编码时原样返回,
|
||||||
|
// 保证不会比「直接透传」更糟。
|
||||||
|
func decodeSubtitleText(raw []byte) string {
|
||||||
|
switch {
|
||||||
|
case bytes.HasPrefix(raw, utf8BOM):
|
||||||
|
return string(raw[len(utf8BOM):])
|
||||||
|
case bytes.HasPrefix(raw, utf16LEBOM):
|
||||||
|
return decodeUTF16(raw[len(utf16LEBOM):], false)
|
||||||
|
case bytes.HasPrefix(raw, utf16BEBOM):
|
||||||
|
return decodeUTF16(raw[len(utf16BEBOM):], true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无 BOM 且是合法 UTF-8:这是绝大多数情况,直接返回。
|
||||||
|
if utf8.Valid(raw) {
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
best := ""
|
||||||
|
bestScore := math.MinInt
|
||||||
|
consider := func(text string) {
|
||||||
|
if !utf8.ValidString(text) || !looksLikeSubtitle(text) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
score := subtitlePlausibility(text)
|
||||||
|
if score > bestScore {
|
||||||
|
best, bestScore = text, score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无 BOM 的 UTF-16 少见(多来自手工编辑),但一旦是它,所有单字节编码都会
|
||||||
|
// 解出带大量 NUL 的垃圾,因此先用「格式特征」筛掉这些候选。
|
||||||
|
consider(decodeUTF16(raw, false))
|
||||||
|
consider(decodeUTF16(raw, true))
|
||||||
|
|
||||||
|
for _, candidate := range subtitleLegacyEncodings {
|
||||||
|
text, err := candidate.enc.NewDecoder().Bytes(raw)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
consider(string(text))
|
||||||
|
}
|
||||||
|
if best != "" {
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeUTF16 按给定字节序把 UTF-16 字节解码为字符串(含代理对)。
|
||||||
|
func decodeUTF16(raw []byte, bigEndian bool) string {
|
||||||
|
units := make([]uint16, 0, len(raw)/2)
|
||||||
|
for i := 0; i+1 < len(raw); i += 2 {
|
||||||
|
if bigEndian {
|
||||||
|
units = append(units, binary.BigEndian.Uint16(raw[i:i+2]))
|
||||||
|
} else {
|
||||||
|
units = append(units, binary.LittleEndian.Uint16(raw[i:i+2]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(utf16.Decode(units))
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksLikeSubtitle 判断解码结果是否真的像一份字幕。解码器「没报错」并不代表
|
||||||
|
// 解对了——很多编码能把任意字节解成合法文本,只有格式特征才能确认。
|
||||||
|
func looksLikeSubtitle(text string) bool {
|
||||||
|
head := text
|
||||||
|
if len(head) > 4096 {
|
||||||
|
head = head[:4096]
|
||||||
|
}
|
||||||
|
if strings.Contains(head, "[Script Info]") || strings.Contains(head, "Dialogue:") {
|
||||||
|
return true // ASS/SSA
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(head), "WEBVTT") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return subtitleTimecodePattern.MatchString(head) // SRT / WebVTT 时间行
|
||||||
|
}
|
||||||
|
|
||||||
|
// subtitlePlausibility 给解码结果打分,返回 -100..100 的可信度:正常文本字符加分,
|
||||||
|
// 替换字符(解码失败)、控制字符、未分配码位扣分。
|
||||||
|
//
|
||||||
|
// 刻意用比例而不是字符数量:单字节回退会把一个双字节字符拆成两个字符,字符多的
|
||||||
|
// 候选看起来「更正常」,实际是解错了——GBK 中文字幕被当成 Shift_JIS 解,就会得到
|
||||||
|
// 一串半角片假名,数量比正确结果还多。半角片假名单独扣分正是为了压住这种情况。
|
||||||
|
func subtitlePlausibility(text string) int {
|
||||||
|
total := 0
|
||||||
|
weight := 0
|
||||||
|
for _, r := range text {
|
||||||
|
total++
|
||||||
|
switch {
|
||||||
|
case r == utf8.RuneError:
|
||||||
|
weight -= 8
|
||||||
|
case r == '\n' || r == '\r' || r == '\t':
|
||||||
|
// 正常换行不计分也不扣分。
|
||||||
|
case r < 0x20 || r == 0x7F:
|
||||||
|
weight -= 8
|
||||||
|
case unicode.Is(unicode.Co, r) || unicode.Is(unicode.Cn, r):
|
||||||
|
weight -= 4
|
||||||
|
case r >= 0xFF61 && r <= 0xFF9F:
|
||||||
|
// 半角片假名:GBK 汉字被按 Shift_JIS 解出来的典型产物。
|
||||||
|
weight -= 2
|
||||||
|
case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul),
|
||||||
|
unicode.Is(unicode.Latin, r), unicode.IsDigit(r), unicode.IsPunct(r):
|
||||||
|
weight++
|
||||||
|
case unicode.IsSpace(r):
|
||||||
|
// 空格(含全角空格)不参与打分。
|
||||||
|
default:
|
||||||
|
weight--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return weight * 100 / total
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf16"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
const assFixture = "[Script Info]\n" +
|
||||||
|
"Title:Railgun 01 BD\n" +
|
||||||
|
"ScriptType:v4.00+\n" +
|
||||||
|
"\n" +
|
||||||
|
"[Events]\n" +
|
||||||
|
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" +
|
||||||
|
"Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,放て!心に刻んだ夢を\n"
|
||||||
|
|
||||||
|
const srtFixture = "1\n" +
|
||||||
|
"00:00:01,000 --> 00:00:02,000\n" +
|
||||||
|
"只有我的超电磁炮\n" +
|
||||||
|
"\n"
|
||||||
|
|
||||||
|
func encodeUTF16(s string, bigEndian bool, withBOM bool) []byte {
|
||||||
|
units := utf16.Encode([]rune(s))
|
||||||
|
out := make([]byte, 0, len(units)*2+2)
|
||||||
|
if withBOM {
|
||||||
|
if bigEndian {
|
||||||
|
out = append(out, 0xFE, 0xFF)
|
||||||
|
} else {
|
||||||
|
out = append(out, 0xFF, 0xFE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf := make([]byte, 2)
|
||||||
|
for _, unit := range units {
|
||||||
|
if bigEndian {
|
||||||
|
binary.BigEndian.PutUint16(buf, unit)
|
||||||
|
} else {
|
||||||
|
binary.LittleEndian.PutUint16(buf, unit)
|
||||||
|
}
|
||||||
|
out = append(out, buf...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeGBK(t *testing.T, s string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
encoded, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(s))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode GBK: %v", err)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这是本次线上问题的核心场景:字幕组给的 ASS 是带 BOM 的 UTF-16LE,
|
||||||
|
// 之前原样下发,浏览器按 UTF-8 解开后整篇是替换字符,一个字都渲染不出来。
|
||||||
|
func TestDecodeSubtitleTextNormalisesLegacyEncodings(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw []byte
|
||||||
|
}{
|
||||||
|
{"utf8", []byte(assFixture)},
|
||||||
|
{"utf8-bom", append([]byte{0xEF, 0xBB, 0xBF}, []byte(assFixture)...)},
|
||||||
|
{"utf16le-bom", encodeUTF16(assFixture, false, true)},
|
||||||
|
{"utf16be-bom", encodeUTF16(assFixture, true, true)},
|
||||||
|
{"utf16le-no-bom", encodeUTF16(assFixture, false, false)},
|
||||||
|
{"gb18030", encodeGBK(t, assFixture)},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := decodeSubtitleText(tc.raw)
|
||||||
|
if !strings.Contains(got, "[Script Info]") {
|
||||||
|
t.Fatalf("decoded text lost ASS header: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "Dialogue:") {
|
||||||
|
t.Fatalf("decoded text lost Dialogue line: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "放て!心に刻んだ夢を") {
|
||||||
|
t.Fatalf("decoded text lost non-ASCII content: %q", got)
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(got, 0) || strings.ContainsRune(got, '\uFFFD') {
|
||||||
|
t.Fatalf("decoded text still contains NUL or replacement runes: %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSubtitleTextKeepsUnknownBytes(t *testing.T) {
|
||||||
|
raw := []byte("not a subtitle at all: \x81\x82\x83\x84")
|
||||||
|
if got := decodeSubtitleText(raw); got != string(raw) {
|
||||||
|
t.Fatalf("decodeSubtitleText mangled non-subtitle bytes: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接口层回归:外挂 UTF-16 字幕经 /subtitles/:id/ass(libass 路径)与
|
||||||
|
// /subtitles/:id(WebVTT 降级路径)下发时都必须是可解析的 UTF-8。
|
||||||
|
func TestSubtitleHandlersNormaliseUTF16Sidecar(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:subtitle-encoding?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
videoPath := filepath.Join(dir, "Railgun S01E01.mkv")
|
||||||
|
assPath := filepath.Join(dir, "Railgun S01E01.ass")
|
||||||
|
srtPath := filepath.Join(dir, "Railgun S01E01.zh.srt")
|
||||||
|
if err := os.WriteFile(videoPath, []byte("video"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(assPath, encodeUTF16(assFixture, false, true), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(srtPath, encodeGBK(t, srtFixture), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
media := model.Media{Title: "Railgun", Path: videoPath}
|
||||||
|
if err := db.Create(&media).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := NewSubtitleService(&config.Config{}, zap.NewNop(), repository.New(db))
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := svc.ServeASS(t.Context(), media.ID, assPath, &buf); err != nil {
|
||||||
|
t.Fatalf("ServeASS: %v", err)
|
||||||
|
}
|
||||||
|
ass := buf.String()
|
||||||
|
if !bytes.HasPrefix(buf.Bytes(), []byte("[Script Info]")) {
|
||||||
|
t.Fatalf("ServeASS output is not UTF-8 ASS: %q", ass)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ass, "放て!心に刻んだ夢を") {
|
||||||
|
t.Fatalf("ServeASS output lost dialogue text: %q", ass)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.Reset()
|
||||||
|
if err := svc.Serve(t.Context(), media.ID, assPath, &buf); err != nil {
|
||||||
|
t.Fatalf("Serve(ass): %v", err)
|
||||||
|
}
|
||||||
|
vtt := buf.String()
|
||||||
|
if !strings.HasPrefix(vtt, "WEBVTT") || !strings.Contains(vtt, "-->") {
|
||||||
|
t.Fatalf("ASS->WebVTT fallback produced no cues: %q", vtt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(vtt, "放て!心に刻んだ夢を") {
|
||||||
|
t.Fatalf("ASS->WebVTT fallback lost dialogue text: %q", vtt)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.Reset()
|
||||||
|
if err := svc.Serve(t.Context(), media.ID, srtPath, &buf); err != nil {
|
||||||
|
t.Fatalf("Serve(srt): %v", err)
|
||||||
|
}
|
||||||
|
vtt = buf.String()
|
||||||
|
if !strings.Contains(vtt, "只有我的超电磁炮") {
|
||||||
|
t.Fatalf("GBK SRT was not transcoded: %q", vtt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,8 +141,13 @@ const profileQuery = () => {
|
|||||||
// streamURL returns a direct-play URL for <video src>. The JWT is added as
|
// streamURL returns a direct-play URL for <video src>. The JWT is added as
|
||||||
// a query parameter because <video> elements cannot send Authorization
|
// a query parameter because <video> elements cannot send Authorization
|
||||||
// headers.
|
// headers.
|
||||||
export function streamURL(mediaId: string): string {
|
//
|
||||||
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}`
|
// proxy=true 时由服务端把网盘/STRM 直链转发为同源数据(画质与原文件一致、
|
||||||
|
// 不转码)。VR 全景渲染需要把视频帧读进 WebGL 纹理,跨域直链会被浏览器
|
||||||
|
// 判定为污染资源而禁止读取,因此只有这种场景才需要开启。
|
||||||
|
export function streamURL(mediaId: string, options: { proxy?: boolean } = {}): string {
|
||||||
|
const proxy = options.proxy ? '&proxy=1' : ''
|
||||||
|
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}${proxy}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export interface DanmakuConfig {
|
|||||||
area: string
|
area: string
|
||||||
volume: number
|
volume: number
|
||||||
playback_rate: number
|
playback_rate: number
|
||||||
|
/** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */
|
||||||
|
vr360_guide_seen: boolean
|
||||||
/** Per-user preference: merge the same episode's multiple sources. */
|
/** Per-user preference: merge the same episode's multiple sources. */
|
||||||
merge_sources: boolean
|
merge_sources: boolean
|
||||||
}
|
}
|
||||||
@@ -89,6 +91,8 @@ export interface DanmakuSettingsPatch {
|
|||||||
merge_sources?: boolean
|
merge_sources?: boolean
|
||||||
volume?: number
|
volume?: number
|
||||||
playback_rate?: number
|
playback_rate?: number
|
||||||
|
/** 看过 VR 操作说明后置为 true,之后不再弹出。 */
|
||||||
|
vr360_guide_seen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
|
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { ArrowDown, ArrowUp, ArrowUpDown, Check } from 'lucide-react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
LIBRARY_LIST_SORT_OPTIONS,
|
||||||
|
type LibraryListSortField,
|
||||||
|
type LibraryListSortOption,
|
||||||
|
type LibraryListSortOrder,
|
||||||
|
} from '../utils/libraryListSort'
|
||||||
|
|
||||||
|
type LibraryListSortDropdownProps = {
|
||||||
|
value: LibraryListSortField
|
||||||
|
order: LibraryListSortOrder
|
||||||
|
onChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryListSortDropdown({
|
||||||
|
value,
|
||||||
|
order,
|
||||||
|
onChange,
|
||||||
|
className = '',
|
||||||
|
}: LibraryListSortDropdownProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const currentOption = LIBRARY_LIST_SORT_OPTIONS.find((opt) => opt.id === value) ?? LIBRARY_LIST_SORT_OPTIONS[0]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleSelect = (option: LibraryListSortOption) => {
|
||||||
|
if (value === option.id) {
|
||||||
|
onChange(option.id, order === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
onChange(option.id, option.defaultOrder)
|
||||||
|
}
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative inline-block text-left ${className}`} ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
className="inline-flex h-9 items-center gap-1.5 rounded-xl border border-sand-200 bg-white/90 px-3 py-1.5 text-xs font-semibold text-ink-600 shadow-sm transition-all hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700 sm:h-10 sm:text-sm"
|
||||||
|
title="更改媒体库列表排序"
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={14} className="text-sand-500" />
|
||||||
|
<span>排序: {currentOption.label}</span>
|
||||||
|
{order === 'asc' ? (
|
||||||
|
<ArrowUp size={13} className="font-bold text-brand-600" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown size={13} className="font-bold text-brand-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute left-0 z-50 mt-1.5 w-44 max-w-[calc(100vw-2rem)] origin-top-left rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95 sm:left-auto sm:right-0 sm:origin-top-right">
|
||||||
|
<div className="border-b border-sand-100 px-2.5 py-1.5 text-[11px] font-bold text-sand-500">
|
||||||
|
排序方式
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5 py-1">
|
||||||
|
{LIBRARY_LIST_SORT_OPTIONS.map((option) => {
|
||||||
|
const isSelected = value === option.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
|
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs font-medium transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-brand-50 font-semibold text-brand-700'
|
||||||
|
: 'text-ink-600 hover:bg-sand-50 hover:text-brand-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isSelected && <Check size={13} className="text-brand-600" />}
|
||||||
|
<span className={isSelected ? '' : 'pl-5'}>{option.label}</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="flex items-center text-brand-600">
|
||||||
|
{order === 'asc' ? (
|
||||||
|
<ArrowUp size={14} className="stroke-[2.5]" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown size={14} className="stroke-[2.5]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type { LibraryTagTab } from '../utils/libraryTags'
|
|||||||
/**
|
/**
|
||||||
* 媒体库标签栏:固定「全部」+ 每个标签一个页签,选中后只展示该标签下的媒体库。
|
* 媒体库标签栏:固定「全部」+ 每个标签一个页签,选中后只展示该标签下的媒体库。
|
||||||
* 只是管理员/普通用户都能用的展示层过滤,不改动媒体库本身。
|
* 只是管理员/普通用户都能用的展示层过滤,不改动媒体库本身。
|
||||||
|
* 标签顺序由「管理标签」里的拖拽决定,这里只负责展示与选中。
|
||||||
*/
|
*/
|
||||||
export function LibraryTagBar({
|
export function LibraryTagBar({
|
||||||
tabs,
|
tabs,
|
||||||
@@ -151,7 +152,7 @@ export function LibraryTagBar({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onManage}
|
onClick={onManage}
|
||||||
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500"
|
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500"
|
||||||
title="重命名 / 删除标签,调整标签下的媒体库"
|
title="重命名 / 删除标签,调整标签下的媒体库,拖拽排序"
|
||||||
>
|
>
|
||||||
<Settings2 size={13} />
|
<Settings2 size={13} />
|
||||||
<span>{manageLabel}</span>
|
<span>{manageLabel}</span>
|
||||||
@@ -161,4 +162,4 @@ export function LibraryTagBar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Check, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react'
|
import { Check, GripVertical, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
import { confirmAction } from './confirmAction'
|
import { confirmAction } from './confirmAction'
|
||||||
import type { Library } from '../types'
|
import type { Library } from '../types'
|
||||||
import { MAX_LIBRARY_TAGS, normalizeTagName, tagNameError, type LibraryTag } from '../utils/libraryTags'
|
import { useDragReorder } from '../hooks/useDragReorder'
|
||||||
|
import {
|
||||||
|
MAX_LIBRARY_TAGS,
|
||||||
|
filterLibrariesForTagging,
|
||||||
|
normalizeTagName,
|
||||||
|
tagNameError,
|
||||||
|
type LibraryTag,
|
||||||
|
} from '../utils/libraryTags'
|
||||||
|
|
||||||
export type ManageLibraryTagsDialogProps = {
|
export type ManageLibraryTagsDialogProps = {
|
||||||
tags: LibraryTag[]
|
tags: LibraryTag[]
|
||||||
@@ -13,13 +20,23 @@ export type ManageLibraryTagsDialogProps = {
|
|||||||
onCreate: (name: string) => Promise<string>
|
onCreate: (name: string) => Promise<string>
|
||||||
onRename: (from: string, to: string) => Promise<void>
|
onRename: (from: string, to: string) => Promise<void>
|
||||||
onRemove: (name: string) => Promise<void>
|
onRemove: (name: string) => Promise<void>
|
||||||
|
/** 按给定标签名顺序重排标签(拖拽排序的落点)。 */
|
||||||
|
onReorder: (orderedNames: string[]) => Promise<void>
|
||||||
|
/** 单个媒体库归类;tagName 为空表示移出所有标签。 */
|
||||||
onAssign: (libraryId: string, tagName: string) => Promise<void>
|
onAssign: (libraryId: string, tagName: string) => Promise<void>
|
||||||
|
/** 批量归类;tagName 为空表示批量移出所有标签。一次写入。 */
|
||||||
|
onAssignBatch: (libraryIds: string[], tagName: string) => Promise<void>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 「未分类」在筛选/批量目标里的哨兵值,与标签名不会冲突。 */
|
||||||
|
const UNTAGGED = '__untagged__'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标签管理对话框:左侧维护标签(新建 / 重命名 / 删除),右侧把每个媒体库归入
|
* 标签管理对话框:左侧维护标签(新建 / 重命名 / 删除),右侧把媒体库归入
|
||||||
* 某个标签。一个媒体库同时只属于一个标签,未选择即为「未分类」。
|
* 某个标签。一个媒体库同时只属于一个标签,未选择即为「未分类」。
|
||||||
|
*
|
||||||
|
* 右侧支持搜索、按当前标签筛选、多选后一次性批量归类,避免逐条点击下拉框。
|
||||||
*/
|
*/
|
||||||
export function ManageLibraryTagsDialogView({
|
export function ManageLibraryTagsDialogView({
|
||||||
tags,
|
tags,
|
||||||
@@ -28,13 +45,24 @@ export function ManageLibraryTagsDialogView({
|
|||||||
onCreate,
|
onCreate,
|
||||||
onRename,
|
onRename,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onReorder,
|
||||||
onAssign,
|
onAssign,
|
||||||
|
onAssignBatch,
|
||||||
onClose,
|
onClose,
|
||||||
}: ManageLibraryTagsDialogProps) {
|
}: ManageLibraryTagsDialogProps) {
|
||||||
const [draftName, setDraftName] = useState('')
|
const [draftName, setDraftName] = useState('')
|
||||||
const [renaming, setRenaming] = useState('')
|
const [renaming, setRenaming] = useState('')
|
||||||
const [renameDraft, setRenameDraft] = useState('')
|
const [renameDraft, setRenameDraft] = useState('')
|
||||||
const [busyLibrary, setBusyLibrary] = 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(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -54,6 +82,58 @@ export function ManageLibraryTagsDialogView({
|
|||||||
return map
|
return map
|
||||||
}, [tags])
|
}, [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 handleCreate = async () => {
|
||||||
const error = tagNameError(draftName, tags)
|
const error = tagNameError(draftName, tags)
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -103,6 +183,23 @@ export function ManageLibraryTagsDialogView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 backdrop-blur-sm sm:p-4"
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 backdrop-blur-sm sm:p-4"
|
||||||
@@ -111,14 +208,14 @@ export function ManageLibraryTagsDialogView({
|
|||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
className="flex h-[calc(100dvh-1rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-white/70 bg-[var(--app-panel)] shadow-2xl sm:h-[80vh] sm:rounded-3xl"
|
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()}
|
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 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">
|
<div className="flex items-center gap-2">
|
||||||
<Tag size={18} className="text-brand-500" />
|
<Tag size={18} className="text-brand-500" />
|
||||||
<h3 className="font-display text-base font-bold text-[var(--app-text)] sm:text-lg">管理标签</h3>
|
<h3 className="font-display text-base font-bold text-[var(--app-text)] sm:text-lg">管理标签</h3>
|
||||||
{saving && <Loader2 size={14} className="animate-spin text-[var(--app-muted)]" />}
|
{busy && <Loader2 size={14} className="animate-spin text-[var(--app-muted)]" />}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -130,7 +227,7 @@ export function ManageLibraryTagsDialogView({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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.3fr)]">
|
<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">
|
<section className="flex min-h-0 flex-col gap-3">
|
||||||
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">标签</h4>
|
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">标签</h4>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -150,7 +247,7 @@ export function ManageLibraryTagsDialogView({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleCreate()}
|
onClick={() => void handleCreate()}
|
||||||
disabled={saving || !draftName.trim()}
|
disabled={busy || !draftName.trim()}
|
||||||
className="btn-outline shrink-0 !px-3 !py-2 text-xs"
|
className="btn-outline shrink-0 !px-3 !py-2 text-xs"
|
||||||
title="创建标签"
|
title="创建标签"
|
||||||
>
|
>
|
||||||
@@ -165,128 +262,247 @@ export function ManageLibraryTagsDialogView({
|
|||||||
还没有标签。先创建一个,再把媒体库归入其中。
|
还没有标签。先创建一个,再把媒体库归入其中。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
tags.map((tag) => (
|
tags.map((tag) => {
|
||||||
<div
|
const drag = dragProps(tag.name)
|
||||||
key={tag.name}
|
const isDragOver = dragOverId === tag.name && draggingId !== tag.name
|
||||||
className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2"
|
return (
|
||||||
>
|
<div
|
||||||
{renaming === tag.name ? (
|
key={tag.name}
|
||||||
<>
|
onDragOver={drag.onDragOver}
|
||||||
<input
|
onDragLeave={drag.onDragLeave}
|
||||||
autoFocus
|
onDrop={drag.onDrop}
|
||||||
value={renameDraft}
|
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
|
||||||
onChange={(event) => setRenameDraft(event.target.value)}
|
isDragOver
|
||||||
onKeyDown={(event) => {
|
? 'border-brand-500/60 bg-brand-500/5'
|
||||||
if (event.key === 'Enter') {
|
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
|
||||||
event.preventDefault()
|
}`}
|
||||||
void handleRename(tag.name)
|
>
|
||||||
}
|
{renaming === tag.name ? (
|
||||||
if (event.key === 'Escape') {
|
<>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={renameDraft}
|
||||||
|
onChange={(event) => setRenameDraft(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
void handleRename(tag.name)
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setRenaming('')
|
||||||
|
setRenameDraft('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
maxLength={24}
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleRename(tag.name)}
|
||||||
|
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)]"
|
||||||
|
title="保存名称"
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
setRenaming('')
|
setRenaming('')
|
||||||
setRenameDraft('')
|
setRenameDraft('')
|
||||||
}
|
}}
|
||||||
}}
|
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
|
||||||
maxLength={24}
|
title="取消"
|
||||||
className="min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500"
|
>
|
||||||
/>
|
<X size={14} />
|
||||||
<button
|
</button>
|
||||||
type="button"
|
</>
|
||||||
onClick={() => void handleRename(tag.name)}
|
) : (
|
||||||
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)]"
|
<>
|
||||||
title="保存名称"
|
<button
|
||||||
>
|
type="button"
|
||||||
<Check size={14} />
|
draggable={drag.draggable}
|
||||||
</button>
|
onDragStart={drag.onDragStart}
|
||||||
<button
|
onDragEnd={drag.onDragEnd}
|
||||||
type="button"
|
className={`-ml-1 shrink-0 cursor-grab rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] ${
|
||||||
onClick={() => {
|
draggingId === tag.name ? 'opacity-40' : ''
|
||||||
setRenaming('')
|
}`}
|
||||||
setRenameDraft('')
|
title="拖拽调整标签顺序"
|
||||||
}}
|
aria-label="拖拽调整标签顺序"
|
||||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
|
>
|
||||||
title="取消"
|
<GripVertical size={13} />
|
||||||
>
|
</button>
|
||||||
<X size={14} />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</>
|
onClick={() => setTagFilter(tag.name)}
|
||||||
) : (
|
className="min-w-0 flex-1 truncate text-left text-sm font-bold text-[var(--app-text)] transition-colors hover:text-brand-500"
|
||||||
<>
|
title={`只看「${tag.name}」标签下的媒体库`}
|
||||||
<span
|
>
|
||||||
className="min-w-0 flex-1 truncate text-sm font-bold text-[var(--app-text)]"
|
{tag.name}
|
||||||
title={tag.name}
|
</button>
|
||||||
>
|
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
|
||||||
{tag.name}
|
{tag.library_ids.length}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
|
<button
|
||||||
{tag.library_ids.length}
|
type="button"
|
||||||
</span>
|
onClick={() => {
|
||||||
<button
|
setRenaming(tag.name)
|
||||||
type="button"
|
setRenameDraft(tag.name)
|
||||||
onClick={() => {
|
}}
|
||||||
setRenaming(tag.name)
|
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500"
|
||||||
setRenameDraft(tag.name)
|
title="重命名"
|
||||||
}}
|
>
|
||||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500"
|
<Pencil size={13} />
|
||||||
title="重命名"
|
</button>
|
||||||
>
|
<button
|
||||||
<Pencil size={13} />
|
type="button"
|
||||||
</button>
|
onClick={() => void handleRemove(tag)}
|
||||||
<button
|
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500"
|
||||||
type="button"
|
title="删除标签"
|
||||||
onClick={() => void handleRemove(tag)}
|
>
|
||||||
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500"
|
<Trash2 size={13} />
|
||||||
title="删除标签"
|
</button>
|
||||||
>
|
</>
|
||||||
<Trash2 size={13} />
|
)}
|
||||||
</button>
|
</div>
|
||||||
</>
|
)
|
||||||
)}
|
})
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<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">
|
||||||
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
媒体库归类({libraries.length})
|
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">
|
||||||
</h4>
|
媒体库归类({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">
|
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
|
||||||
{libraries.length === 0 ? (
|
{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)]">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
libraries.map((library) => (
|
visibleLibraries.map((library) => {
|
||||||
<div
|
const current = tagByLibrary.get(library.id) ?? ''
|
||||||
key={library.id}
|
const checked = selectedSet.has(library.id)
|
||||||
className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2"
|
return (
|
||||||
>
|
<div
|
||||||
<span
|
key={library.id}
|
||||||
className="min-w-0 flex-1 truncate text-xs font-semibold text-[var(--app-text)]"
|
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
|
||||||
title={library.name}
|
checked
|
||||||
|
? 'border-brand-500/50 bg-brand-500/5'
|
||||||
|
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{library.name}
|
<input
|
||||||
</span>
|
type="checkbox"
|
||||||
{busyLibrary === library.id && (
|
checked={checked}
|
||||||
<Loader2 size={13} className="shrink-0 animate-spin text-[var(--app-muted)]" />
|
onChange={() => toggleLibrary(library.id)}
|
||||||
)}
|
disabled={applying}
|
||||||
<select
|
className="h-4 w-4 shrink-0 accent-brand-400"
|
||||||
value={tagByLibrary.get(library.id) ?? ''}
|
title="勾选后可批量设置标签"
|
||||||
disabled={saving || busyLibrary === library.id}
|
/>
|
||||||
onChange={(event) => void handleAssign(library, event.target.value)}
|
<span
|
||||||
className="input-base h-8 w-32 shrink-0 !py-0 text-xs"
|
className="min-w-0 flex-1 truncate text-xs font-semibold text-[var(--app-text)]"
|
||||||
title="选择该媒体库所属标签"
|
title={library.path || library.name}
|
||||||
>
|
>
|
||||||
<option value="">未分类</option>
|
{library.name}
|
||||||
{tags.map((tag) => (
|
</span>
|
||||||
<option key={tag.name} value={tag.name}>
|
{busyLibrary === library.id && (
|
||||||
{tag.name}
|
<Loader2 size={13} className="shrink-0 animate-spin text-[var(--app-muted)]" />
|
||||||
</option>
|
)}
|
||||||
))}
|
<select
|
||||||
</select>
|
value={current}
|
||||||
</div>
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,26 +6,23 @@ import { isStrmMedia, mediaVersionLabel, mediaVersionsOf } from '../utils/mediaV
|
|||||||
|
|
||||||
type MediaVersionSwitcherProps = {
|
type MediaVersionSwitcherProps = {
|
||||||
media: Media
|
media: Media
|
||||||
/** 详情页:用 Link 跳转播放;播放页:回调切换 */
|
|
||||||
mode?: 'detail' | 'player'
|
|
||||||
onSelect?: (version: Media) => void
|
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaVersionSwitcher({
|
/**
|
||||||
media,
|
* 详情页的版本切换。
|
||||||
mode = 'detail',
|
*
|
||||||
onSelect,
|
* 播放器内不再单独浮出一块版本条:多版本切换统一收在「选集」面板里
|
||||||
className = '',
|
* (见 PlayerPlaylistPanel),这里只负责详情页的同片多版本跳转。
|
||||||
}: MediaVersionSwitcherProps) {
|
*/
|
||||||
|
export function MediaVersionSwitcher({ media, className = '' }: MediaVersionSwitcherProps) {
|
||||||
const versions = mediaVersionsOf(media)
|
const versions = mediaVersionsOf(media)
|
||||||
if (versions.length <= 1) return null
|
if (versions.length <= 1) return null
|
||||||
const dark = mode === 'player'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-2 ${className}`.trim()}>
|
<div className={`space-y-2 ${className}`.trim()}>
|
||||||
<div className={`flex items-center gap-2 text-sm font-semibold ${dark ? 'text-white/90' : 'text-ink-600'}`}>
|
<div className="flex items-center gap-2 text-sm font-semibold text-ink-600">
|
||||||
<Layers size={14} className={dark ? 'text-white/60' : 'text-sand-500'} />
|
<Layers size={14} className="text-sand-500" />
|
||||||
<span>版本({versions.length})</span>
|
<span>版本({versions.length})</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -33,42 +30,10 @@ export function MediaVersionSwitcher({
|
|||||||
const active = version.id === media.id
|
const active = version.id === media.id
|
||||||
const isStrm = isStrmMedia(version)
|
const isStrm = isStrmMedia(version)
|
||||||
const label = mediaVersionLabel(version)
|
const label = mediaVersionLabel(version)
|
||||||
const btnClass = dark
|
const btnClass = active
|
||||||
? active
|
? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
|
||||||
? 'border-white/50 bg-white/20 text-white shadow-sm ring-1 ring-white/20'
|
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
|
||||||
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15 hover:text-white'
|
|
||||||
: active
|
|
||||||
? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
|
|
||||||
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
{isStrm && (
|
|
||||||
<Cloud
|
|
||||||
size={12}
|
|
||||||
className={dark ? (active ? 'text-brand-300' : 'text-white/60') : (active ? 'text-brand-500' : 'text-sand-400')}
|
|
||||||
aria-label="云端 / STRM"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span>{label}</span>
|
|
||||||
{!dark && active ? <span className="opacity-75">· 当前</span> : null}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (mode === 'player') {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={version.id}
|
|
||||||
type="button"
|
|
||||||
disabled={active}
|
|
||||||
onClick={() => onSelect?.(version)}
|
|
||||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${btnClass}`}
|
|
||||||
title={version.path || version.strm_url}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={version.id}
|
key={version.id}
|
||||||
@@ -77,7 +42,17 @@ export function MediaVersionSwitcher({
|
|||||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${btnClass}`}
|
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${btnClass}`}
|
||||||
title={version.path || version.strm_url}
|
title={version.path || version.strm_url}
|
||||||
>
|
>
|
||||||
{content}
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{isStrm && (
|
||||||
|
<Cloud
|
||||||
|
size={12}
|
||||||
|
className={active ? 'text-brand-500' : 'text-sand-400'}
|
||||||
|
aria-label="云端 / STRM"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{label}</span>
|
||||||
|
{active ? <span className="opacity-75">· 当前</span> : null}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Film, Hash, KeyRound, Loader2, MessageSquareText, RefreshCw, Search, Server, Settings2, Sparkles, Tag, X } from 'lucide-react'
|
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Film, Hash, KeyRound, Loader2, MessageSquareText, RefreshCw, Search, Server, Settings2, Sparkles, Tag, X } from 'lucide-react'
|
||||||
|
|
||||||
import type { DanmakuAnime, DanmakuEpisode, DanmakuLoadedInfo } from '../api/danmaku'
|
import type { DanmakuAnime, DanmakuEpisode, DanmakuLoadedInfo } from '../api/danmaku'
|
||||||
|
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER, PLAYER_SHEET, PLAYER_SHEET_BODY, PLAYER_SHEET_HEADER } from './playerTheme'
|
||||||
|
|
||||||
// PlayerDanmakuPanel — the on-player danmaku control panel. It displays
|
// PlayerDanmakuPanel — the on-player danmaku control panel. It displays
|
||||||
// the matched danmaku details (anime title, episode title, comment count,
|
// the matched danmaku details (anime title, episode title, comment count,
|
||||||
@@ -55,6 +56,11 @@ type PlayerDanmakuPanelProps = {
|
|||||||
danmakuInfo?: DanmakuLoadedInfo | null
|
danmakuInfo?: DanmakuLoadedInfo | null
|
||||||
onSelectEpisode: (episodeId: number, animeTitle: string, episodeTitle: string) => void
|
onSelectEpisode: (episodeId: number, animeTitle: string, episodeTitle: string) => void
|
||||||
onResetAuto: () => void
|
onResetAuto: () => void
|
||||||
|
/**
|
||||||
|
* 竖屏剧场模式:弹幕面板改为视频区底部的动作面板,而不是右侧抽屉,
|
||||||
|
* 窄屏下搜索框与滑杆不再被压扁。
|
||||||
|
*/
|
||||||
|
theater?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlayerDanmakuPanel({
|
export function PlayerDanmakuPanel({
|
||||||
@@ -89,6 +95,7 @@ export function PlayerDanmakuPanel({
|
|||||||
danmakuInfo,
|
danmakuInfo,
|
||||||
onSelectEpisode,
|
onSelectEpisode,
|
||||||
onResetAuto,
|
onResetAuto,
|
||||||
|
theater = false,
|
||||||
}: PlayerDanmakuPanelProps) {
|
}: PlayerDanmakuPanelProps) {
|
||||||
const [draft, setDraft] = useState(search)
|
const [draft, setDraft] = useState(search)
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
@@ -194,27 +201,34 @@ export function PlayerDanmakuPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
|
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
|
||||||
|
// 排布和选集抽屉完全一致(整条贴住右边缘、标题栏常驻),两个面板互斥打开时
|
||||||
|
// 位置不会跳,用户也不用重新找入口。
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="absolute inset-x-3 top-12 bottom-3 z-30 w-auto overflow-y-auto overscroll-contain rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:bottom-auto sm:right-4 sm:top-16 sm:w-80 sm:max-h-[calc(100%-5rem)]"
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onPointerUp={(e) => e.stopPropagation()}
|
||||||
|
className={
|
||||||
|
theater
|
||||||
|
? `absolute inset-x-0 bottom-0 z-40 ${PLAYER_SHEET}`
|
||||||
|
: `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className={theater ? PLAYER_SHEET_HEADER : PLAYER_PANEL_HEADER}>
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
||||||
<MessageSquareText size={16} className="text-rose-400" /> 弹幕设置
|
<MessageSquareText size={16} className="shrink-0 text-rose-400" />
|
||||||
{settingsSaving && <Loader2 size={12} className="animate-spin text-rose-300" />}
|
<span className="truncate">弹幕设置</span>
|
||||||
|
{settingsSaving && <Loader2 size={12} className="shrink-0 animate-spin text-rose-300" />}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
|
|
||||||
title="关闭"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 是否加载弹幕 */}
|
<div className={theater ? PLAYER_SHEET_BODY : 'min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3.5'}>
|
||||||
|
{/* 弹幕总开关。从操作栏的「弹」按钮直接进来就到了这里,所以这一项就是
|
||||||
|
用户找的「开关弹幕」;文案跟旧的按钮提示保持一致,避免换个说法让人找不到。 */}
|
||||||
<label className="mb-3 flex cursor-pointer items-center justify-between rounded-lg bg-white/5 px-2.5 py-2 text-sm transition hover:bg-white/10">
|
<label className="mb-3 flex cursor-pointer items-center justify-between rounded-lg bg-white/5 px-2.5 py-2 text-sm transition hover:bg-white/10">
|
||||||
<span className="text-white/85">加载弹幕</span>
|
<span className="text-white/85">显示弹幕</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
@@ -573,6 +587,7 @@ export function PlayerDanmakuPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { Layers, ListVideo } from 'lucide-react'
|
||||||
|
|
||||||
|
import type { Media } from '../types'
|
||||||
|
import { mediaVersionLabel, mediaVersionMatches } from '../utils/mediaVersion'
|
||||||
|
|
||||||
|
// PlayerMobileTheaterInfo — 竖屏剧场模式下视频下方的可滚动内容区。
|
||||||
|
//
|
||||||
|
// 竖屏手机不再用全屏居中黑边布局:视频贴顶按 16:9 自适应高度,
|
||||||
|
// 下面这块区域接管标题、选集横滑、版本切换和简介,信息密度与
|
||||||
|
// 主流手机视频应用保持一致。桌面端与横屏手机不渲染它。
|
||||||
|
|
||||||
|
type PlayerMobileTheaterInfoProps = {
|
||||||
|
media: Media | null
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
playbackModeLabel?: string
|
||||||
|
qualityLabel?: string
|
||||||
|
episodes: Media[]
|
||||||
|
currentMediaId: string
|
||||||
|
currentEpisodeIndex: number
|
||||||
|
currentVersions?: Media[]
|
||||||
|
onSelectEpisode: (media: Media) => void
|
||||||
|
onSelectVersion?: (media: Media) => void
|
||||||
|
onOpenPlaylist?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function episodeShortLabel(ep: Media): string {
|
||||||
|
if (ep.episode_num > 0) return `${ep.episode_num}`
|
||||||
|
const title = ep.episode_title?.trim() || ep.title?.trim() || ''
|
||||||
|
return title ? title.slice(0, 4) : '·'
|
||||||
|
}
|
||||||
|
|
||||||
|
function episodeFullLabel(ep: Media): string {
|
||||||
|
if (ep.episode_num > 0) {
|
||||||
|
const title = ep.episode_title?.trim()
|
||||||
|
return title && title !== ep.title?.trim() ? `第 ${ep.episode_num} 集 · ${title}` : `第 ${ep.episode_num} 集`
|
||||||
|
}
|
||||||
|
return ep.episode_title?.trim() || ep.title?.trim() || '未命名'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerMobileTheaterInfo({
|
||||||
|
media,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
playbackModeLabel,
|
||||||
|
qualityLabel,
|
||||||
|
episodes,
|
||||||
|
currentMediaId,
|
||||||
|
currentEpisodeIndex,
|
||||||
|
currentVersions = [],
|
||||||
|
onSelectEpisode,
|
||||||
|
onSelectVersion,
|
||||||
|
onOpenPlaylist,
|
||||||
|
}: PlayerMobileTheaterInfoProps) {
|
||||||
|
const hasEpisodeList = episodes.length > 1
|
||||||
|
const versionsToSwitch = currentVersions.length > 1 ? currentVersions : []
|
||||||
|
const overview = media?.overview?.trim() || ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-black px-4 pb-8 pt-3 text-white">
|
||||||
|
<h1 className="text-[15px] font-semibold leading-snug text-white/95">{title || '加载中…'}</h1>
|
||||||
|
{subtitle ? <p className="mt-1 text-xs text-white/55">{subtitle}</p> : null}
|
||||||
|
{(playbackModeLabel || qualityLabel) && (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||||
|
{qualityLabel ? (
|
||||||
|
<span className="rounded-md bg-white/10 px-2 py-0.5 text-[11px] font-medium text-white/80">
|
||||||
|
{qualityLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{playbackModeLabel ? (
|
||||||
|
<span className="rounded-md bg-white/10 px-2 py-0.5 text-[11px] text-white/60">
|
||||||
|
{playbackModeLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasEpisodeList ? (
|
||||||
|
<section className="mt-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h2 className="flex items-center gap-1.5 text-[13px] font-semibold text-white/90">
|
||||||
|
<ListVideo size={14} className="text-rose-400" />
|
||||||
|
选集
|
||||||
|
<span className="font-mono text-[11px] font-normal text-white/45">
|
||||||
|
{currentEpisodeIndex >= 0 ? `${currentEpisodeIndex + 1}/${episodes.length}` : `${episodes.length} 集`}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{onOpenPlaylist ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenPlaylist}
|
||||||
|
className="rounded-md px-2 py-1 text-[11px] text-white/55 transition hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
全部 >
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="scrollbar-hide -mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1">
|
||||||
|
{episodes.map((ep) => {
|
||||||
|
const active = mediaVersionMatches(ep, currentMediaId)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ep.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectEpisode(ep)}
|
||||||
|
title={episodeFullLabel(ep)}
|
||||||
|
className={`flex h-10 min-w-12 shrink-0 items-center justify-center rounded-lg px-2 text-xs font-medium tabular-nums transition ${
|
||||||
|
active
|
||||||
|
? 'bg-rose-500 text-white'
|
||||||
|
: 'bg-white/[0.07] text-white/80 active:bg-white/15'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{episodeShortLabel(ep)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{versionsToSwitch.length > 0 ? (
|
||||||
|
<section className="mt-4">
|
||||||
|
<h2 className="mb-2 flex items-center gap-1.5 text-[13px] font-semibold text-white/90">
|
||||||
|
<Layers size={13} className="text-rose-400" />
|
||||||
|
版本
|
||||||
|
<span className="font-mono text-[11px] font-normal text-white/45">
|
||||||
|
{versionsToSwitch.length} 个
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{versionsToSwitch.map((version) => {
|
||||||
|
const active = mediaVersionMatches(version, currentMediaId)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={version.id}
|
||||||
|
type="button"
|
||||||
|
disabled={active}
|
||||||
|
onClick={() => onSelectVersion?.(version)}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition disabled:cursor-default ${
|
||||||
|
active
|
||||||
|
? 'border-rose-500/50 bg-rose-500/15 text-white'
|
||||||
|
: 'border-white/5 bg-white/5 text-white/85 active:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||||
|
{mediaVersionLabel(version)}
|
||||||
|
</span>
|
||||||
|
{active ? <span className="shrink-0 text-[10px] text-rose-300">播放中</span> : null}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{overview ? (
|
||||||
|
<section className="mt-4">
|
||||||
|
<h2 className="mb-1.5 text-[13px] font-semibold text-white/90">简介</h2>
|
||||||
|
<p className="text-xs leading-relaxed text-white/60">{overview}</p>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="mt-5 text-center text-[10px] text-white/30">将手机横放可获得全屏沉浸式播放体验</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Check, Film, ListVideo, Play, Search, X } from 'lucide-react'
|
import { Check, Cloud, Film, Layers, Play, Search, X } from 'lucide-react'
|
||||||
|
|
||||||
import { imageURL } from '../api/client'
|
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import { seasonLabel, seasonSortOrder, seriesTitleFromPath } from '../utils/groupSeries'
|
import { seasonLabel, seasonSortOrder, seriesTitleFromPath } from '../utils/groupSeries'
|
||||||
|
import {
|
||||||
|
isStrmMedia,
|
||||||
|
mediaVersionFileName,
|
||||||
|
mediaVersionLabel,
|
||||||
|
mediaVersionMatches,
|
||||||
|
mediaVersionSourceLabel,
|
||||||
|
} from '../utils/mediaVersion'
|
||||||
|
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER, PLAYER_SHEET, PLAYER_SHEET_BODY, PLAYER_SHEET_HEADER } from './playerTheme'
|
||||||
|
|
||||||
export type SeasonGroup = {
|
export type SeasonGroup = {
|
||||||
season: number
|
season: number
|
||||||
@@ -14,20 +21,46 @@ type PlayerPlaylistPanelProps = {
|
|||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
currentMediaId: string
|
currentMediaId: string
|
||||||
|
/** 当前正在播放媒体的全部版本(含自身)。多版本时面板顶部展示版本切换。 */
|
||||||
|
currentVersions?: Media[]
|
||||||
episodes: Media[]
|
episodes: Media[]
|
||||||
onSelectEpisode: (media: Media) => void
|
onSelectEpisode: (media: Media) => void
|
||||||
|
/** 切换到同一集/同一条目的另一个版本。 */
|
||||||
|
onSelectVersion?: (media: Media) => void
|
||||||
|
/**
|
||||||
|
* 竖屏剧场模式:选集面板改为视频区底部的动作面板,而不是右侧抽屉,
|
||||||
|
* 避免窄屏下抽屉遮挡返回按钮、宫格被压扁。
|
||||||
|
*/
|
||||||
|
theater?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选集面板 —— 右侧整条抽屉,内容参考 B 站的番剧选集。
|
||||||
|
*
|
||||||
|
* 以前这里是「悬浮卡片 + 每集一行缩略图」,两三百集的库翻起来很累,卡片本身
|
||||||
|
* 也和画面上别的浮层长得不一样。现在改成:整条贴住播放区右边缘的抽屉,剧集用
|
||||||
|
* 编号宫格排(一屏能看到几十集),标题/时长放在悬停提示里;当前播放的那一格用
|
||||||
|
* 强调色填充。
|
||||||
|
*
|
||||||
|
* 多版本(同一部片的 4K / 1080p / 云端直链等)依旧不丢:宫格右上角的小角标表示
|
||||||
|
* 这一集有多个版本,点角标会在宫格上方展开版本列表;当前正在播放的条目的版本
|
||||||
|
* 则常驻在抽屉顶部,随时可换。单条媒体(电影)没有剧集列表时,抽屉本身就是
|
||||||
|
* 版本选择器。
|
||||||
|
*/
|
||||||
export function PlayerPlaylistPanel({
|
export function PlayerPlaylistPanel({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
currentMediaId,
|
currentMediaId,
|
||||||
|
currentVersions = [],
|
||||||
episodes,
|
episodes,
|
||||||
onSelectEpisode,
|
onSelectEpisode,
|
||||||
|
onSelectVersion,
|
||||||
|
theater = false,
|
||||||
}: PlayerPlaylistPanelProps) {
|
}: PlayerPlaylistPanelProps) {
|
||||||
const [filterText, setFilterText] = useState('')
|
const [filterText, setFilterText] = useState('')
|
||||||
const activeItemRef = useRef<HTMLDivElement | null>(null)
|
/** 正在挑选版本的剧集 id;null = 宫格直接播放。 */
|
||||||
const listContainerRef = useRef<HTMLDivElement | null>(null)
|
const [versionsForEpisodeId, setVersionsForEpisodeId] = useState<string | null>(null)
|
||||||
|
const activeItemRef = useRef<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
// 按季分组
|
// 按季分组
|
||||||
const seasonGroups = useMemo<SeasonGroup[]>(() => {
|
const seasonGroups = useMemo<SeasonGroup[]>(() => {
|
||||||
@@ -46,9 +79,9 @@ export function PlayerPlaylistPanel({
|
|||||||
.map(([season, list]) => ({ season, episodes: list }))
|
.map(([season, list]) => ({ season, episodes: list }))
|
||||||
}, [episodes])
|
}, [episodes])
|
||||||
|
|
||||||
// 当前播放所在季
|
// 当前播放所在季(播放中的可能是某个剧集行的非首个版本,需按版本组比对)
|
||||||
const currentSeason = useMemo(() => {
|
const currentSeason = useMemo(() => {
|
||||||
const found = episodes.find((e) => e.id === currentMediaId)
|
const found = episodes.find((e) => mediaVersionMatches(e, currentMediaId))
|
||||||
if (!found) return seasonGroups[0]?.season ?? 1
|
if (!found) return seasonGroups[0]?.season ?? 1
|
||||||
return found.episode_num > 0 ? (found.season_num ?? 0) : (found.season_num || 1)
|
return found.episode_num > 0 ? (found.season_num ?? 0) : (found.season_num || 1)
|
||||||
}, [episodes, currentMediaId, seasonGroups])
|
}, [episodes, currentMediaId, seasonGroups])
|
||||||
@@ -62,15 +95,19 @@ export function PlayerPlaylistPanel({
|
|||||||
}
|
}
|
||||||
}, [open, currentSeason])
|
}, [open, currentSeason])
|
||||||
|
|
||||||
|
// 换集/换季时收起版本列表:那张列表是针对上一集的,留着会让人误点。
|
||||||
|
useEffect(() => {
|
||||||
|
setVersionsForEpisodeId(null)
|
||||||
|
}, [open, selectedSeason, currentMediaId])
|
||||||
|
|
||||||
// 当面板打开时,自动平滑滚动到当前播放集的位置
|
// 当面板打开时,自动平滑滚动到当前播放集的位置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && activeItemRef.current) {
|
if (!open || versionsForEpisodeId) return
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
activeItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
activeItemRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
}, 100)
|
}, 100)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}
|
}, [open, selectedSeason, currentMediaId, versionsForEpisodeId])
|
||||||
}, [open, selectedSeason, currentMediaId])
|
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
@@ -86,35 +123,105 @@ export function PlayerPlaylistPanel({
|
|||||||
})
|
})
|
||||||
: listToDisplay
|
: listToDisplay
|
||||||
|
|
||||||
|
const versionsToSwitch = currentVersions.length > 1 ? currentVersions : []
|
||||||
|
// 只有一集(电影/单条媒体)时,面板就是版本选择器,不再重复列出一行「剧集」。
|
||||||
|
const hasEpisodeList = episodes.length > 1
|
||||||
|
const versionsForEpisode = versionsForEpisodeId
|
||||||
|
? (listToDisplay.find((ep) => ep.id === versionsForEpisodeId)?.versions ?? [])
|
||||||
|
: []
|
||||||
|
const expandedEpisode = versionsForEpisodeId
|
||||||
|
? listToDisplay.find((ep) => ep.id === versionsForEpisodeId)
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="absolute inset-x-3 top-12 bottom-3 z-30 flex flex-col overflow-hidden rounded-2xl border border-white/15 bg-black/85 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:right-4 sm:top-16 sm:bottom-20 sm:w-96"
|
// 舞台用 pointerdown/pointerup 判断「移动端轻触画面」,抽屉上的触摸不该参与。
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onPointerUp={(e) => e.stopPropagation()}
|
||||||
|
className={
|
||||||
|
theater
|
||||||
|
? `absolute inset-x-0 bottom-0 z-40 ${PLAYER_SHEET}`
|
||||||
|
: `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* 头部 */}
|
<div className={theater ? PLAYER_SHEET_HEADER : PLAYER_PANEL_HEADER}>
|
||||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 shrink-0">
|
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
<span className="truncate">{hasEpisodeList ? '选集' : '版本'}</span>
|
||||||
<ListVideo size={17} className="text-rose-400" />
|
<span className="shrink-0 font-mono text-[11px] font-normal text-white/45">
|
||||||
<span>选集列表</span>
|
{hasEpisodeList ? `${episodes.length} 集` : `${versionsToSwitch.length} 个版本`}
|
||||||
<span className="font-mono text-xs font-normal text-white/50">
|
|
||||||
({episodes.length} 集)
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
|
|
||||||
title="关闭"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={theater ? PLAYER_SHEET_BODY : 'flex min-h-0 flex-1 flex-col overflow-hidden'}>
|
||||||
|
|
||||||
|
{/* 当前条目的版本切换:多版本时置顶,随时可换 */}
|
||||||
|
{versionsToSwitch.length > 0 && (
|
||||||
|
<div className="shrink-0 border-b border-white/10 px-3 py-2.5">
|
||||||
|
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-medium text-white/55">
|
||||||
|
<Layers size={12} className="text-rose-400" />
|
||||||
|
<span>正在播放的版本</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-44 space-y-1 overflow-y-auto pr-0.5">
|
||||||
|
{versionsToSwitch.map((version) => (
|
||||||
|
<VersionOption
|
||||||
|
key={version.id}
|
||||||
|
version={version}
|
||||||
|
active={mediaVersionMatches(version, currentMediaId)}
|
||||||
|
onSelect={() => onSelectVersion?.(version)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 某一集的版本列表:从宫格角标展开,选完即收起 */}
|
||||||
|
{expandedEpisode && versionsForEpisode.length > 0 && (
|
||||||
|
<div className="shrink-0 border-b border-white/10 bg-white/[0.03] px-3 py-2.5">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-amber-200">
|
||||||
|
<Layers size={12} className="shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{expandedEpisode.episode_num > 0
|
||||||
|
? `第 ${expandedEpisode.episode_num} 集 · 选择版本`
|
||||||
|
: '选择版本'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setVersionsForEpisodeId(null)}
|
||||||
|
className="shrink-0 rounded p-0.5 text-white/45 transition hover:bg-white/10 hover:text-white"
|
||||||
|
title="收起版本列表"
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-44 space-y-1 overflow-y-auto pr-0.5">
|
||||||
|
{versionsForEpisode.map((version) => (
|
||||||
|
<VersionOption
|
||||||
|
key={version.id}
|
||||||
|
version={version}
|
||||||
|
active={mediaVersionMatches(version, currentMediaId)}
|
||||||
|
compact
|
||||||
|
onSelect={() => {
|
||||||
|
onSelectVersion?.(version)
|
||||||
|
setVersionsForEpisodeId(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 季选择 Tabs(若有多季) */}
|
{/* 季选择 Tabs(若有多季) */}
|
||||||
{seasonGroups.length > 1 && (
|
{hasEpisodeList && seasonGroups.length > 1 && (
|
||||||
<div className="flex items-center gap-1.5 border-b border-white/10 px-3 py-2 shrink-0 overflow-x-auto no-scrollbar">
|
<div className="no-scrollbar flex shrink-0 items-center gap-1.5 overflow-x-auto border-b border-white/10 px-3 py-2">
|
||||||
{seasonGroups.map(({ season, episodes: sesEps }) => {
|
{seasonGroups.map(({ season, episodes: sesEps }) => {
|
||||||
const isSelected = selectedSeason === season
|
const isSelected = selectedSeason === season
|
||||||
const isPlayingThisSeason = sesEps.some((e) => e.id === currentMediaId)
|
const isPlayingThisSeason = sesEps.some((e) => mediaVersionMatches(e, currentMediaId))
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={season}
|
key={season}
|
||||||
@@ -140,22 +247,19 @@ export function PlayerPlaylistPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 搜索/过滤单集(当单集数量较多时) */}
|
{/* 搜索/过滤单集(当单集数量较多时) */}
|
||||||
{listToDisplay.length > 10 && (
|
{hasEpisodeList && listToDisplay.length > 10 && (
|
||||||
<div className="px-3 pt-2.5 pb-1.5 shrink-0">
|
<div className="shrink-0 px-3 pb-1 pt-2.5">
|
||||||
<div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs">
|
<div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1.5 text-xs">
|
||||||
<Search size={13} className="text-white/40 shrink-0" />
|
<Search size={13} className="shrink-0 text-white/40" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={filterText}
|
value={filterText}
|
||||||
onChange={(e) => setFilterText(e.target.value)}
|
onChange={(e) => setFilterText(e.target.value)}
|
||||||
placeholder="搜索集数或标题…"
|
placeholder="搜索集数或标题…"
|
||||||
className="w-full bg-transparent outline-none placeholder:text-white/30 text-white text-xs"
|
className="w-full bg-transparent text-xs text-white outline-none placeholder:text-white/30"
|
||||||
/>
|
/>
|
||||||
{filterText && (
|
{filterText && (
|
||||||
<button
|
<button onClick={() => setFilterText('')} className="text-white/40 hover:text-white">
|
||||||
onClick={() => setFilterText('')}
|
|
||||||
className="text-white/40 hover:text-white"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -163,100 +267,147 @@ export function PlayerPlaylistPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 集数列表 */}
|
{/* 集数宫格:剧场模式下底部面板只做单层滚动,宫格区不再自己滚动 */}
|
||||||
<div
|
{hasEpisodeList ? (
|
||||||
ref={listContainerRef}
|
<div className={theater ? 'p-3 select-none' : 'min-h-0 flex-1 overflow-y-auto p-3 select-none'}>
|
||||||
className="flex-1 overflow-y-auto p-2.5 space-y-1.5 pr-2 select-none"
|
{filteredEpisodes.length === 0 ? (
|
||||||
>
|
<div className="py-10 text-center text-xs text-white/40">
|
||||||
{filteredEpisodes.length === 0 ? (
|
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
|
||||||
<div className="py-8 text-center text-xs text-white/40">
|
</div>
|
||||||
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
|
) : (
|
||||||
</div>
|
<div className="grid grid-cols-5 gap-1.5 sm:grid-cols-6">
|
||||||
) : (
|
{filteredEpisodes.map((ep) => {
|
||||||
filteredEpisodes.map((ep) => {
|
const isPlaying = mediaVersionMatches(ep, currentMediaId)
|
||||||
const isPlaying = ep.id === currentMediaId
|
const versions = ep.versions && ep.versions.length > 1 ? ep.versions : []
|
||||||
const displayTitle = getEpisodeTitle(ep, listToDisplay)
|
const displayTitle = getEpisodeTitle(ep, listToDisplay)
|
||||||
const durationText =
|
const durationText =
|
||||||
ep.duration_sec > 0 ? `${Math.floor(ep.duration_sec / 60)} 分钟` : ''
|
ep.duration_sec > 0 ? `${Math.round(ep.duration_sec / 60)} 分钟` : ''
|
||||||
|
const cellTitle = [
|
||||||
|
ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : '',
|
||||||
|
displayTitle,
|
||||||
|
durationText,
|
||||||
|
versions.length > 0 ? `${versions.length} 个版本` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={ep.id} className="relative">
|
||||||
key={ep.id}
|
<button
|
||||||
ref={isPlaying ? activeItemRef : null}
|
ref={isPlaying ? activeItemRef : null}
|
||||||
onClick={() => onSelectEpisode(ep)}
|
onClick={() => onSelectEpisode(ep)}
|
||||||
className={`group flex cursor-pointer items-center gap-2.5 rounded-xl p-2 transition border ${
|
title={cellTitle}
|
||||||
isPlaying
|
className={`flex h-9 w-full items-center justify-center rounded-md text-xs font-medium tabular-nums transition ${
|
||||||
? 'border-rose-500/60 bg-rose-500/20 text-white'
|
isPlaying
|
||||||
: 'border-white/5 bg-white/5 hover:border-white/20 hover:bg-white/10 text-white/85'
|
? 'bg-rose-500 text-white shadow-[0_0_0_1px_rgba(255,255,255,0.2)_inset]'
|
||||||
}`}
|
: 'bg-white/[0.06] text-white/80 hover:bg-white/15 hover:text-white'
|
||||||
>
|
|
||||||
{/* 封面/集号 */}
|
|
||||||
<div className="relative flex h-11 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10 text-xs font-semibold">
|
|
||||||
{ep.backdrop_url || ep.poster_url ? (
|
|
||||||
<img
|
|
||||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
|
|
||||||
alt=""
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Film size={16} className="text-white/40" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 正在播放动效 / 集数徽标 */}
|
|
||||||
{isPlaying ? (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-xs">
|
|
||||||
<div className="flex items-end gap-0.5 h-3">
|
|
||||||
<span className="w-0.5 bg-rose-400 animate-pulse h-full" />
|
|
||||||
<span className="w-0.5 bg-rose-400 animate-pulse h-2" />
|
|
||||||
<span className="w-0.5 bg-rose-400 animate-pulse h-3" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<Play size={14} className="text-white fill-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 角标显示集数 */}
|
|
||||||
<span className="absolute bottom-0.5 right-1 rounded bg-black/75 px-1 py-0.2 text-[9px] font-mono text-white/90">
|
|
||||||
{ep.episode_num > 0 ? `${ep.episode_num}` : '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标题 & 时长 */}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<p
|
|
||||||
className={`truncate text-xs font-medium ${
|
|
||||||
isPlaying ? 'text-rose-300 font-semibold' : 'group-hover:text-white'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{displayTitle}
|
{isPlaying ? (
|
||||||
</p>
|
<Play size={12} className="fill-current" />
|
||||||
</div>
|
) : ep.episode_num > 0 ? (
|
||||||
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-white/50">
|
ep.episode_num
|
||||||
{ep.episode_num > 0 && (
|
) : (
|
||||||
<span className="font-mono">第 {ep.episode_num} 集</span>
|
<Film size={13} className="text-white/45" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{/* 有多个版本:点角标挑版本,点格子本身直接播放默认版本 */}
|
||||||
|
{versions.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setVersionsForEpisodeId((current) =>
|
||||||
|
current === ep.id ? null : ep.id,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
className={`absolute right-0 top-0 flex h-3.5 w-3.5 items-center justify-center rounded-bl-md rounded-tr-md text-[9px] font-semibold leading-none transition ${
|
||||||
|
versionsForEpisodeId === ep.id
|
||||||
|
? 'bg-amber-300 text-black'
|
||||||
|
: 'bg-black/45 text-amber-200 hover:bg-black/70'
|
||||||
|
}`}
|
||||||
|
title={`${versions.length} 个版本,点这里选择`}
|
||||||
|
>
|
||||||
|
{versions.length}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
{durationText && <span>{durationText}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
|
})}
|
||||||
{isPlaying && (
|
</div>
|
||||||
<div className="shrink-0 flex items-center gap-1 text-[11px] font-medium text-rose-400 px-1">
|
)}
|
||||||
<Check size={13} />
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
versionsToSwitch.length === 0 && (
|
||||||
</div>
|
<div className="flex items-center justify-center px-4 py-8 text-center text-xs text-white/40">
|
||||||
)
|
暂无可切换的内容
|
||||||
})
|
</div>
|
||||||
)}
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单个版本选项:主行是画质/容器/体积,次行是来源与文件名。 */
|
||||||
|
function VersionOption({
|
||||||
|
version,
|
||||||
|
active,
|
||||||
|
compact = false,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
version: Media
|
||||||
|
active: boolean
|
||||||
|
compact?: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
}) {
|
||||||
|
const cloud = isStrmMedia(version)
|
||||||
|
const label = mediaVersionLabel(version)
|
||||||
|
const fileName = mediaVersionFileName(version)
|
||||||
|
const sourceLabel = mediaVersionSourceLabel(version)
|
||||||
|
// 没有探测数据时(网盘 STRM 常见)主行本身就是文件名,次行再重复一遍没有意义。
|
||||||
|
const showFileName = fileName !== '' && !labelKey(label).includes(labelKey(fileName))
|
||||||
|
const activeClass = 'border-rose-500/50 bg-rose-500/15 text-white'
|
||||||
|
const idleClass =
|
||||||
|
'border-white/5 bg-white/5 text-white/85 hover:border-white/15 hover:bg-white/10'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={active}
|
||||||
|
onClick={onSelect}
|
||||||
|
title={version.path || version.strm_url || undefined}
|
||||||
|
className={`flex w-full items-start gap-2 rounded-lg border px-2.5 text-left transition disabled:cursor-default ${
|
||||||
|
compact ? 'py-1.5' : 'py-2'
|
||||||
|
} ${active ? activeClass : idleClass}`}
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 shrink-0">
|
||||||
|
{cloud ? (
|
||||||
|
<Cloud size={13} className={active ? 'text-rose-300' : 'text-white/45'} />
|
||||||
|
) : (
|
||||||
|
<Film size={13} className={active ? 'text-rose-300' : 'text-white/45'} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
className={`block truncate text-xs font-medium ${active ? 'text-rose-200' : 'text-white/90'}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block truncate text-[10px] text-white/45">
|
||||||
|
{sourceLabel}
|
||||||
|
{showFileName ? ` · ${fileName}` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{active && <Check size={13} className="mt-0.5 shrink-0 text-rose-400" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化后比较:忽略大小写与分隔符(. _ - 空格),用于判断两段文案是否只是重复。 */
|
||||||
|
function labelKey(value: string): string {
|
||||||
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
function getEpisodeTitle(ep: Media, siblings: Media[]): string {
|
function getEpisodeTitle(ep: Media, siblings: Media[]): string {
|
||||||
const title = ep.episode_title?.trim()
|
const title = ep.episode_title?.trim()
|
||||||
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
|
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
|
||||||
|
|||||||
@@ -0,0 +1,690 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react'
|
||||||
|
import { Compass, LogOut, Minus, Plus, RotateCcw, Settings2, Smartphone } from 'lucide-react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
PLAYER_ICON_BUTTON,
|
||||||
|
PLAYER_SEGMENT,
|
||||||
|
PLAYER_SEGMENT_GROUP,
|
||||||
|
PLAYER_SEGMENT_OFF,
|
||||||
|
PLAYER_SEGMENT_ON,
|
||||||
|
PLAYER_TEXT_BUTTON,
|
||||||
|
} from './playerTheme'
|
||||||
|
import {
|
||||||
|
VR360_DEFAULT_FOV,
|
||||||
|
VR360_MAX_FOV,
|
||||||
|
VR360_MIN_FOV,
|
||||||
|
VR360_PROJECTION_OPTIONS,
|
||||||
|
VR360_STEREO_OPTIONS,
|
||||||
|
vr360FrameUv,
|
||||||
|
type Vr360Profile,
|
||||||
|
} from '../utils/vr360'
|
||||||
|
import {
|
||||||
|
applyViewOffset,
|
||||||
|
cameraForward,
|
||||||
|
cameraFromDeviceOrientation,
|
||||||
|
cameraFromYawPitch,
|
||||||
|
clampNumber,
|
||||||
|
degToRad,
|
||||||
|
forwardPitch,
|
||||||
|
forwardYaw,
|
||||||
|
viewMatrixFromCamera,
|
||||||
|
type Mat3,
|
||||||
|
} from '../utils/vr360Math'
|
||||||
|
// 只引入类型:渲染器本体走动态 import,普通播放不会下载 WebGL 全景渲染代码。
|
||||||
|
import type { Vr360Renderer } from '../utils/vr360Renderer'
|
||||||
|
|
||||||
|
// 全景播放舞台:一个覆盖整个播放区域的 WebGL 画布。视频元素本身仍在后台正常
|
||||||
|
// 播放(音轨、进度、弹幕、字幕都不受影响),只是画面被贴到了球面上。
|
||||||
|
//
|
||||||
|
// 视角控制:
|
||||||
|
// 1. 鼠标/手指拖拽 → 转动视角(画面跟随手指),滚轮/双指捏合 → 变焦;
|
||||||
|
// 2. 手机陀螺仪 → 转动设备即转动视角。iOS 13+ 必须在用户手势里调用
|
||||||
|
// DeviceOrientationEvent.requestPermission,所以这里做成显式按钮。
|
||||||
|
|
||||||
|
type ViewState = {
|
||||||
|
/** 拖拽模式下的方位角(弧度,0 = 默认正前方)。 */
|
||||||
|
yaw: number
|
||||||
|
/** 拖拽模式下的俯仰角(弧度,+ 为抬头)。 */
|
||||||
|
pitch: number
|
||||||
|
fov: number
|
||||||
|
/** 陀螺仪是否接管视角。 */
|
||||||
|
gyroActive: boolean
|
||||||
|
/** 最近一次设备姿态对应的相机旋转矩阵。 */
|
||||||
|
deviceCamera: Mat3 | null
|
||||||
|
gyroYawOffset: number
|
||||||
|
gyroPitchOffset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type PointerState = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
startAt: number
|
||||||
|
moved: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type GyroState = 'off' | 'pending' | 'active' | 'denied' | 'unsupported' | 'unavailable'
|
||||||
|
|
||||||
|
type Vr360StageProps = {
|
||||||
|
videoRef: RefObject<HTMLVideoElement>
|
||||||
|
profile: Vr360Profile
|
||||||
|
/** 控制栏是否可见:不可见时隐藏 VR 自身的浮层按钮。 */
|
||||||
|
uiVisible: boolean
|
||||||
|
/** 轻触画面(未发生拖拽)时回调,与普通播放一致:唤出控制栏或切换播放。 */
|
||||||
|
onSurfaceTap: () => void
|
||||||
|
/**
|
||||||
|
* 第一帧真正画出来时回调,每次挂载只上报一次(改投影方式/画幅布局不会重新上报),
|
||||||
|
* 调用方据此结束「正在启动」提示。
|
||||||
|
*/
|
||||||
|
onReady: () => void
|
||||||
|
onError: (message: string) => void
|
||||||
|
/** 用户调整投影方式/立体布局时回调,由播放页保存为下次进入的默认值。 */
|
||||||
|
onProfileChange?: (profile: Vr360Profile) => void
|
||||||
|
/** 退出 VR 全景播放(回到普通播放)。不提供时工具条上不显示退出入口。 */
|
||||||
|
onExitVr?: () => void
|
||||||
|
/**
|
||||||
|
* 鼠标是否停留在 VR 工具条/设置面板上。由播放页转给控制栏:悬停这些浮层时
|
||||||
|
* 控制栏不应该自动隐藏(否则设置面板会在点击前消失)。
|
||||||
|
*/
|
||||||
|
onUiHoldChange?: (hold: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每像素拖拽对应的转角(弧度)。视场角越小时画面越「拉近」,拖拽也应更细腻。 */
|
||||||
|
const DRAG_RAD_PER_PIXEL = 0.0035
|
||||||
|
const TAP_MAX_DISTANCE = 8
|
||||||
|
const TAP_MAX_DURATION_MS = 400
|
||||||
|
const GYRO_PROBE_MS = 1500
|
||||||
|
const PITCH_LIMIT = Math.PI / 2 - 0.03
|
||||||
|
const WHEEL_ZOOM_SPEED = 0.0015
|
||||||
|
|
||||||
|
function screenOrientationAngle(): number {
|
||||||
|
if (
|
||||||
|
typeof screen !== 'undefined' &&
|
||||||
|
screen.orientation &&
|
||||||
|
typeof screen.orientation.angle === 'number'
|
||||||
|
) {
|
||||||
|
return screen.orientation.angle
|
||||||
|
}
|
||||||
|
const legacy = (window as unknown as { orientation?: number }).orientation
|
||||||
|
return typeof legacy === 'number' ? legacy : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前设备是否没有真正的鼠标悬停能力(手机/平板)。
|
||||||
|
*
|
||||||
|
* 触摸设备上「悬停保持显示」这套机制本身就是错的:手指抬起后浏览器不保证补发
|
||||||
|
* mouseleave,一旦走进悬停状态标记就再也清不掉,控制栏和 VR 工具条会永久留在
|
||||||
|
* 画面上。这里按设备能力判断,而不是去猜某个鼠标事件是不是触摸合成的——
|
||||||
|
* 后者依赖 MouseEvent.sourceCapabilities,实测在不少浏览器里是 null,判断会
|
||||||
|
* 静默失效、等于没修。
|
||||||
|
*/
|
||||||
|
function isHoverlessDevice(): boolean {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return true
|
||||||
|
return window.matchMedia('(hover: none)').matches
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceOrientationEventConstructor = {
|
||||||
|
requestPermission?: () => Promise<'granted' | 'denied' | 'default'>
|
||||||
|
}
|
||||||
|
|
||||||
|
function gyroStateHint(state: GyroState): string {
|
||||||
|
if (state === 'active') return '陀螺仪已开启:转动设备即可改变视角'
|
||||||
|
if (state === 'pending') return '正在等待陀螺仪数据…'
|
||||||
|
if (state === 'denied') return '陀螺仪权限被拒绝,请在浏览器设置里允许「运动与方向」访问'
|
||||||
|
if (state === 'unavailable') return '没有收到陀螺仪数据,当前设备可能不支持'
|
||||||
|
if (state === 'unsupported') return '当前浏览器不支持陀螺仪'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Vr360Stage({
|
||||||
|
videoRef,
|
||||||
|
profile,
|
||||||
|
uiVisible,
|
||||||
|
onSurfaceTap,
|
||||||
|
onReady,
|
||||||
|
onError,
|
||||||
|
onProfileChange,
|
||||||
|
onExitVr,
|
||||||
|
onUiHoldChange,
|
||||||
|
}: Vr360StageProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const rendererRef = useRef<Vr360Renderer | null>(null)
|
||||||
|
const viewRef = useRef<ViewState>({
|
||||||
|
yaw: 0,
|
||||||
|
pitch: 0,
|
||||||
|
fov: VR360_DEFAULT_FOV,
|
||||||
|
gyroActive: false,
|
||||||
|
deviceCamera: null,
|
||||||
|
gyroYawOffset: 0,
|
||||||
|
gyroPitchOffset: 0,
|
||||||
|
})
|
||||||
|
const dirtyRef = useRef(true)
|
||||||
|
const readyRef = useRef(false)
|
||||||
|
const pointersRef = useRef(new Map<number, PointerState>())
|
||||||
|
const pinchDistanceRef = useRef<number | null>(null)
|
||||||
|
const alignmentRef = useRef<{ yaw: number; pitch: number } | null>(null)
|
||||||
|
const gyroProbeRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const absoluteSeenRef = useRef(false)
|
||||||
|
const profileRef = useRef(profile)
|
||||||
|
const [gyroState, setGyroState] = useState<GyroState>('off')
|
||||||
|
const [fovHint, setFovHint] = useState(VR360_DEFAULT_FOV)
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
|
||||||
|
// 回调放进 ref:渲染器只在挂载时创建一次,不因父组件重渲染而重建。
|
||||||
|
const callbacksRef = useRef({ onSurfaceTap, onReady, onError })
|
||||||
|
useEffect(() => {
|
||||||
|
callbacksRef.current = { onSurfaceTap, onReady, onError }
|
||||||
|
}, [onSurfaceTap, onReady, onError])
|
||||||
|
|
||||||
|
const markDirty = useCallback(() => {
|
||||||
|
dirtyRef.current = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 当前相机姿态:陀螺仪接管时以设备姿态为基准叠加拖拽偏移,否则纯拖拽角度。
|
||||||
|
const currentCamera = useCallback((): Mat3 => {
|
||||||
|
const view = viewRef.current
|
||||||
|
if (view.gyroActive && view.deviceCamera) {
|
||||||
|
return applyViewOffset(view.deviceCamera, view.gyroYawOffset, view.gyroPitchOffset)
|
||||||
|
}
|
||||||
|
return cameraFromYawPitch(view.yaw, view.pitch)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const draw = useCallback(() => {
|
||||||
|
const renderer = rendererRef.current
|
||||||
|
if (!renderer) return
|
||||||
|
renderer.setView(viewMatrixFromCamera(currentCamera()))
|
||||||
|
renderer.setFov(viewRef.current.fov)
|
||||||
|
renderer.draw()
|
||||||
|
if (!readyRef.current) {
|
||||||
|
// 第一帧真正画出来之后才通知上层:渲染失败时上层会退出 VR 模式。
|
||||||
|
readyRef.current = true
|
||||||
|
callbacksRef.current.onReady()
|
||||||
|
}
|
||||||
|
}, [currentCamera])
|
||||||
|
|
||||||
|
const applyProfile = useCallback((renderer: Vr360Renderer, next: Vr360Profile) => {
|
||||||
|
renderer.setProjection(next.projection)
|
||||||
|
const { scale, offset } = vr360FrameUv(next.stereo)
|
||||||
|
renderer.setFrameUv(scale, offset)
|
||||||
|
dirtyRef.current = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 创建渲染器(懒加载 WebGL 模块,普通播放不会下载全景渲染代码)。
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!canvas || !video) return
|
||||||
|
let cancelled = false
|
||||||
|
let created: Vr360Renderer | null = null
|
||||||
|
|
||||||
|
void import('../utils/vr360Renderer')
|
||||||
|
.then((module) => {
|
||||||
|
if (cancelled) return
|
||||||
|
created = module.createVr360Renderer(canvas, video, {
|
||||||
|
onContextLost: () => callbacksRef.current.onError('VR 渲染上下文已丢失,已退出 VR 播放'),
|
||||||
|
onTextureError: () => callbacksRef.current.onError('无法读取视频画面用于 VR 渲染'),
|
||||||
|
})
|
||||||
|
if (!created) {
|
||||||
|
callbacksRef.current.onError('当前浏览器不支持 WebGL,无法使用 VR 播放')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rendererRef.current = created
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
created.resize(rect.width, rect.height)
|
||||||
|
applyProfile(created, profileRef.current)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) callbacksRef.current.onError('VR 渲染组件加载失败')
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
rendererRef.current = null
|
||||||
|
created?.dispose()
|
||||||
|
}
|
||||||
|
}, [applyProfile, videoRef])
|
||||||
|
|
||||||
|
// 投影方式/立体布局变化时重建网格与 UV 裁切。
|
||||||
|
useEffect(() => {
|
||||||
|
profileRef.current = profile
|
||||||
|
const renderer = rendererRef.current
|
||||||
|
if (renderer) applyProfile(renderer, profile)
|
||||||
|
}, [applyProfile, profile])
|
||||||
|
|
||||||
|
// 画布尺寸跟随舞台(窗口缩放、全屏切换、首次布局)。
|
||||||
|
// 观测父容器而不是画布自身:画布尺寸由 CSS 撑满父容器,观测自身会在
|
||||||
|
// 「设置绘制缓冲区分辨率 → 布局变化 → 再次触发观测」之间来回放大。
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const target = canvas.parentElement ?? canvas
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0]
|
||||||
|
if (!entry) return
|
||||||
|
rendererRef.current?.resize(entry.contentRect.width, entry.contentRect.height)
|
||||||
|
markDirty()
|
||||||
|
})
|
||||||
|
observer.observe(target)
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
rendererRef.current?.resize(rect.width, rect.height)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [markDirty])
|
||||||
|
|
||||||
|
// 渲染循环:requestVideoFrameCallback 标记「有新画面」,rAF 按需绘制。
|
||||||
|
// 暂停且无输入时不出图,避免持续占用 GPU。
|
||||||
|
useEffect(() => {
|
||||||
|
let handle = 0
|
||||||
|
let cancelled = false
|
||||||
|
const video = videoRef.current as
|
||||||
|
| (HTMLVideoElement & { requestVideoFrameCallback?: (callback: () => void) => number })
|
||||||
|
| null
|
||||||
|
|
||||||
|
if (video && typeof video.requestVideoFrameCallback === 'function') {
|
||||||
|
const onVideoFrame = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
dirtyRef.current = true
|
||||||
|
video.requestVideoFrameCallback?.(onVideoFrame)
|
||||||
|
}
|
||||||
|
video.requestVideoFrameCallback(onVideoFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
handle = requestAnimationFrame(tick)
|
||||||
|
if (!dirtyRef.current) return
|
||||||
|
dirtyRef.current = false
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
handle = requestAnimationFrame(tick)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
cancelAnimationFrame(handle)
|
||||||
|
}
|
||||||
|
}, [draw, videoRef])
|
||||||
|
|
||||||
|
// 设备姿态 → 相机姿态。第一帧有效数据到达时把视角对齐过去,避免画面跳动。
|
||||||
|
const handleOrientation = useCallback((event: DeviceOrientationEvent) => {
|
||||||
|
if (event.alpha === null && event.beta === null && event.gamma === null) return
|
||||||
|
if (event.type === 'deviceorientationabsolute') absoluteSeenRef.current = true
|
||||||
|
// 同时收到绝对与相对事件时以绝对朝向为准(方位角才有意义)。
|
||||||
|
else if (absoluteSeenRef.current) return
|
||||||
|
|
||||||
|
const camera = cameraFromDeviceOrientation(
|
||||||
|
degToRad(event.alpha ?? 0),
|
||||||
|
degToRad(event.beta ?? 0),
|
||||||
|
degToRad(event.gamma ?? 0),
|
||||||
|
degToRad(screenOrientationAngle()),
|
||||||
|
)
|
||||||
|
const view = viewRef.current
|
||||||
|
view.deviceCamera = camera
|
||||||
|
|
||||||
|
const alignment = alignmentRef.current
|
||||||
|
if (alignment) {
|
||||||
|
const forward = cameraForward(camera)
|
||||||
|
view.gyroYawOffset = alignment.yaw - forwardYaw(forward)
|
||||||
|
view.gyroPitchOffset = clampNumber(alignment.pitch - forwardPitch(forward), -1.2, 1.2)
|
||||||
|
alignmentRef.current = null
|
||||||
|
if (gyroProbeRef.current) {
|
||||||
|
clearTimeout(gyroProbeRef.current)
|
||||||
|
gyroProbeRef.current = null
|
||||||
|
}
|
||||||
|
setGyroState('active')
|
||||||
|
}
|
||||||
|
|
||||||
|
view.gyroActive = true
|
||||||
|
dirtyRef.current = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stopGyro = useCallback(() => {
|
||||||
|
window.removeEventListener('deviceorientation', handleOrientation)
|
||||||
|
window.removeEventListener('deviceorientationabsolute', handleOrientation)
|
||||||
|
if (gyroProbeRef.current) {
|
||||||
|
clearTimeout(gyroProbeRef.current)
|
||||||
|
gyroProbeRef.current = null
|
||||||
|
}
|
||||||
|
alignmentRef.current = null
|
||||||
|
absoluteSeenRef.current = false
|
||||||
|
}, [handleOrientation])
|
||||||
|
|
||||||
|
const startGyro = useCallback(() => {
|
||||||
|
alignmentRef.current = { yaw: viewRef.current.yaw, pitch: viewRef.current.pitch }
|
||||||
|
absoluteSeenRef.current = false
|
||||||
|
window.addEventListener('deviceorientation', handleOrientation)
|
||||||
|
window.addEventListener('deviceorientationabsolute', handleOrientation)
|
||||||
|
// 桌面浏览器/无陀螺仪设备会一直收不到事件,超时后提示并回退到拖拽模式。
|
||||||
|
gyroProbeRef.current = setTimeout(() => {
|
||||||
|
gyroProbeRef.current = null
|
||||||
|
if (alignmentRef.current) {
|
||||||
|
stopGyro()
|
||||||
|
setGyroState('unavailable')
|
||||||
|
}
|
||||||
|
}, GYRO_PROBE_MS)
|
||||||
|
}, [handleOrientation, stopGyro])
|
||||||
|
|
||||||
|
const toggleGyro = useCallback(() => {
|
||||||
|
if (gyroState === 'active' || gyroState === 'pending') {
|
||||||
|
// 关闭陀螺仪时用当前合成视角续上,画面不会跳。
|
||||||
|
const forward = cameraForward(currentCamera())
|
||||||
|
viewRef.current.yaw = forwardYaw(forward)
|
||||||
|
viewRef.current.pitch = clampNumber(forwardPitch(forward), -PITCH_LIMIT, PITCH_LIMIT)
|
||||||
|
viewRef.current.gyroActive = false
|
||||||
|
viewRef.current.gyroYawOffset = 0
|
||||||
|
viewRef.current.gyroPitchOffset = 0
|
||||||
|
stopGyro()
|
||||||
|
setGyroState('off')
|
||||||
|
markDirty()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const orientationEvent = (
|
||||||
|
window as unknown as { DeviceOrientationEvent?: DeviceOrientationEventConstructor }
|
||||||
|
).DeviceOrientationEvent
|
||||||
|
if (!orientationEvent) {
|
||||||
|
setGyroState('unsupported')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setGyroState('pending')
|
||||||
|
const requestPermission = orientationEvent.requestPermission
|
||||||
|
if (typeof requestPermission !== 'function') {
|
||||||
|
startGyro()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// iOS 13+ 必须在用户手势里申请权限,这里由按钮点击直接触发。
|
||||||
|
void requestPermission
|
||||||
|
.call(orientationEvent)
|
||||||
|
.then((result) => {
|
||||||
|
if (result === 'granted') startGyro()
|
||||||
|
else setGyroState('denied')
|
||||||
|
})
|
||||||
|
.catch(() => setGyroState('denied'))
|
||||||
|
}, [currentCamera, gyroState, markDirty, startGyro, stopGyro])
|
||||||
|
|
||||||
|
useEffect(() => () => stopGyro(), [stopGyro])
|
||||||
|
|
||||||
|
// 控制栏/浮层隐藏时收起「画面」设置面板:它属于同一套浮层,下次唤出控制栏时
|
||||||
|
// 不应该自动弹回来挡住画面。
|
||||||
|
useEffect(() => {
|
||||||
|
if (uiVisible) return
|
||||||
|
setSettingsOpen(false)
|
||||||
|
// 浮层不可见时鼠标离开事件不会再触发,这里主动解除「悬停保持显示」。
|
||||||
|
onUiHoldChange?.(false)
|
||||||
|
}, [uiVisible, onUiHoldChange])
|
||||||
|
|
||||||
|
const zoomBy = useCallback(
|
||||||
|
(factor: number) => {
|
||||||
|
const view = viewRef.current
|
||||||
|
view.fov = clampNumber(view.fov * factor, VR360_MIN_FOV, VR360_MAX_FOV)
|
||||||
|
setFovHint(view.fov)
|
||||||
|
markDirty()
|
||||||
|
},
|
||||||
|
[markDirty],
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetView = useCallback(() => {
|
||||||
|
const view = viewRef.current
|
||||||
|
view.yaw = 0
|
||||||
|
view.pitch = 0
|
||||||
|
view.fov = VR360_DEFAULT_FOV
|
||||||
|
view.gyroYawOffset = 0
|
||||||
|
view.gyroPitchOffset = 0
|
||||||
|
setFovHint(view.fov)
|
||||||
|
markDirty()
|
||||||
|
}, [markDirty])
|
||||||
|
|
||||||
|
const pinchDistance = useCallback((): number => {
|
||||||
|
const pointers = [...pointersRef.current.values()]
|
||||||
|
if (pointers.length < 2) return 0
|
||||||
|
return Math.hypot(pointers[0].x - pointers[1].x, pointers[0].y - pointers[1].y)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
// 轻触画面把视角拖动之外的浮层悬停状态清掉:触摸设备不会再发出 mouseleave,
|
||||||
|
// 上一步点击工具条/设置面板留下的「悬停保持显示」会一直挂着,控制栏就永远
|
||||||
|
// 不隐藏了。用户已经在点画面,说明指针早已离开浮层。
|
||||||
|
if (event.pointerType === 'touch') onUiHoldChange?.(false)
|
||||||
|
try {
|
||||||
|
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||||
|
} catch {
|
||||||
|
// 某些环境(合成事件、非活跃指针)会抛 InvalidPointerId,退化为普通拖拽即可。
|
||||||
|
}
|
||||||
|
pointersRef.current.set(event.pointerId, {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
startAt: Date.now(),
|
||||||
|
moved: false,
|
||||||
|
})
|
||||||
|
if (pointersRef.current.size === 2) {
|
||||||
|
pinchDistanceRef.current = pinchDistance()
|
||||||
|
// 双指手势一律不算轻触,避免捏合缩放时误触发播放/暂停。
|
||||||
|
for (const pointer of pointersRef.current.values()) pointer.moved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerMove = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const pointer = pointersRef.current.get(event.pointerId)
|
||||||
|
if (!pointer) return
|
||||||
|
const dx = event.clientX - pointer.x
|
||||||
|
const dy = event.clientY - pointer.y
|
||||||
|
pointer.x = event.clientX
|
||||||
|
pointer.y = event.clientY
|
||||||
|
if (
|
||||||
|
Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY) > TAP_MAX_DISTANCE
|
||||||
|
) {
|
||||||
|
pointer.moved = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointersRef.current.size >= 2) {
|
||||||
|
const distance = pinchDistance()
|
||||||
|
const previous = pinchDistanceRef.current
|
||||||
|
pinchDistanceRef.current = distance
|
||||||
|
if (previous && distance > 0) zoomBy(previous / distance)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = viewRef.current
|
||||||
|
const scale = DRAG_RAD_PER_PIXEL * (view.fov / VR360_DEFAULT_FOV)
|
||||||
|
if (view.gyroActive && view.deviceCamera) {
|
||||||
|
view.gyroYawOffset -= dx * scale
|
||||||
|
view.gyroPitchOffset = clampNumber(
|
||||||
|
view.gyroPitchOffset + dy * scale,
|
||||||
|
-PITCH_LIMIT,
|
||||||
|
PITCH_LIMIT,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
view.yaw -= dx * scale
|
||||||
|
view.pitch = clampNumber(view.pitch + dy * scale, -PITCH_LIMIT, PITCH_LIMIT)
|
||||||
|
}
|
||||||
|
markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
const endPointer = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const pointer = pointersRef.current.get(event.pointerId)
|
||||||
|
pointersRef.current.delete(event.pointerId)
|
||||||
|
if (pointersRef.current.size < 2) pinchDistanceRef.current = null
|
||||||
|
if (!pointer) return
|
||||||
|
const isTap =
|
||||||
|
!pointer.moved &&
|
||||||
|
Date.now() - pointer.startAt <= TAP_MAX_DURATION_MS &&
|
||||||
|
pointersRef.current.size === 0
|
||||||
|
if (isTap) onSurfaceTap()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const onWheel = (event: WheelEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
zoomBy(1 + event.deltaY * WHEEL_ZOOM_SPEED)
|
||||||
|
}
|
||||||
|
canvas.addEventListener('wheel', onWheel, { passive: false })
|
||||||
|
return () => canvas.removeEventListener('wheel', onWheel)
|
||||||
|
}, [zoomBy])
|
||||||
|
|
||||||
|
const hint = gyroStateHint(gyroState)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
data-vr360-surface
|
||||||
|
className={`absolute inset-0 h-full w-full touch-none select-none ${
|
||||||
|
// 浮层隐藏时连鼠标指针一起藏起来:全景画面里光标一直悬在画面中间很出戏。
|
||||||
|
uiVisible ? 'cursor-grab active:cursor-grabbing' : 'cursor-none'
|
||||||
|
}`}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={endPointer}
|
||||||
|
onPointerCancel={endPointer}
|
||||||
|
// 轻触由指针逻辑自己处理(拖动时不能误触发播放/暂停),
|
||||||
|
// 同时阻止冒泡到舞台的点击逻辑,避免一次触摸切换两次播放状态。
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
// 阻止浮层内按钮/面板的点击冒泡到舞台的 handleSurfaceActivate:否则点击
|
||||||
|
// 投影方式/画幅布局等设置项会被误判为「单击收起控制栏」,面板在切换后立即消失。
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
className={`pointer-events-none absolute inset-x-0 top-2 z-20 flex flex-col items-center gap-1.5 px-2 transition-opacity duration-300 ${
|
||||||
|
uiVisible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap items-center justify-center gap-0.5 rounded-xl border border-white/10 bg-[#0d0e12]/90 p-1 text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl ${
|
||||||
|
// 浮层隐藏时必须彻底不接收指针事件:否则看不见的按钮既会在画面顶部
|
||||||
|
// 抢走拖动转视角的手势,也可能被误点(例如误开陀螺仪、误开设置面板)。
|
||||||
|
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
|
||||||
|
}`}
|
||||||
|
// 触摸设备不参与「悬停保持显示」:手指抬起后不会补发 mouseleave,
|
||||||
|
// 标记会永久挂在打开状态,控制栏和 VR 工具条就再也不自动隐藏。
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (isHoverlessDevice()) return
|
||||||
|
onUiHoldChange?.(true)
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => onUiHoldChange?.(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleGyro}
|
||||||
|
className={`${PLAYER_TEXT_BUTTON} ${
|
||||||
|
gyroState === 'active' ? 'bg-rose-500 text-white hover:bg-rose-500' : ''
|
||||||
|
}`}
|
||||||
|
title="用手机陀螺仪转动视角"
|
||||||
|
>
|
||||||
|
<Smartphone size={14} />
|
||||||
|
陀螺仪
|
||||||
|
{gyroState === 'active' && <Compass size={12} className="text-white/85" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetView}
|
||||||
|
className={PLAYER_TEXT_BUTTON}
|
||||||
|
title="恢复默认视角与视场角"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSettingsOpen((open) => !open)}
|
||||||
|
className={`${PLAYER_TEXT_BUTTON} ${settingsOpen ? 'bg-white/15 text-white' : ''}`}
|
||||||
|
title="投影方式与画幅布局"
|
||||||
|
>
|
||||||
|
<Settings2 size={14} />
|
||||||
|
画面
|
||||||
|
</button>
|
||||||
|
<div className="mx-0.5 h-5 w-px bg-white/10" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => zoomBy(1.15)}
|
||||||
|
className={PLAYER_ICON_BUTTON}
|
||||||
|
title="视野拉远"
|
||||||
|
>
|
||||||
|
<Minus size={15} />
|
||||||
|
</button>
|
||||||
|
<span className="w-9 text-center text-[10px] tabular-nums text-white/70">
|
||||||
|
{(VR360_DEFAULT_FOV / fovHint).toFixed(1)}×
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => zoomBy(1 / 1.15)}
|
||||||
|
className={PLAYER_ICON_BUTTON}
|
||||||
|
title="视野拉近"
|
||||||
|
>
|
||||||
|
<Plus size={15} />
|
||||||
|
</button>
|
||||||
|
{onExitVr ? (
|
||||||
|
<>
|
||||||
|
<div className="mx-0.5 h-5 w-px bg-white/10" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExitVr}
|
||||||
|
className={`${PLAYER_TEXT_BUTTON} hover:bg-rose-500/20 hover:text-rose-200`}
|
||||||
|
title="退出 VR 全景,回到普通播放"
|
||||||
|
>
|
||||||
|
<LogOut size={14} />
|
||||||
|
退出 VR
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="pointer-events-none rounded-md bg-black/45 px-3 py-1 text-[10px] text-white/65 backdrop-blur">
|
||||||
|
{hint || '拖动旋转视角 · 滚轮或双指缩放'}
|
||||||
|
</p>
|
||||||
|
{settingsOpen ? (
|
||||||
|
<div
|
||||||
|
className={`w-[min(92vw,420px)] rounded-xl border border-white/10 bg-[#0d0e12]/95 px-3 py-3 text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl ${
|
||||||
|
uiVisible ? 'pointer-events-auto' : 'pointer-events-none'
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (isHoverlessDevice()) return
|
||||||
|
onUiHoldChange?.(true)
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => onUiHoldChange?.(false)}
|
||||||
|
>
|
||||||
|
<p className="mb-1.5 text-[10px] uppercase tracking-wide text-white/40">投影方式</p>
|
||||||
|
<div className={PLAYER_SEGMENT_GROUP}>
|
||||||
|
{VR360_PROJECTION_OPTIONS.map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onProfileChange?.({ ...profile, projection: value })}
|
||||||
|
className={`${PLAYER_SEGMENT} ${
|
||||||
|
profile.projection === value ? PLAYER_SEGMENT_ON : PLAYER_SEGMENT_OFF
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mb-1.5 mt-3 text-[10px] uppercase tracking-wide text-white/40">
|
||||||
|
画幅布局
|
||||||
|
</p>
|
||||||
|
<div className={PLAYER_SEGMENT_GROUP}>
|
||||||
|
{VR360_STEREO_OPTIONS.map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onProfileChange?.({ ...profile, stereo: value })}
|
||||||
|
className={`${PLAYER_SEGMENT} ${
|
||||||
|
profile.stereo === value ? PLAYER_SEGMENT_ON : PLAYER_SEGMENT_OFF
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{profile.stereo !== 'mono' && (
|
||||||
|
<p className="mt-2.5 text-[10px] leading-relaxed text-white/45">
|
||||||
|
左右/上下并排素材在当前屏幕上只显示左眼画面;真正的双眼立体画面需要 VR
|
||||||
|
头显(WebXR)。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { createRoot } from 'react-dom/client'
|
|
||||||
|
|
||||||
import {
|
|
||||||
ManageLibraryTagsDialogView,
|
|
||||||
type ManageLibraryTagsDialogProps,
|
|
||||||
} from './ManageLibraryTagsDialogView'
|
|
||||||
|
|
||||||
export type OpenManageLibraryTagsDialogOptions = Omit<ManageLibraryTagsDialogProps, 'onClose'>
|
|
||||||
|
|
||||||
/** 以独立根节点打开标签管理对话框,关闭后 resolve。 */
|
|
||||||
export function openManageLibraryTagsDialog(
|
|
||||||
options: OpenManageLibraryTagsDialogOptions,
|
|
||||||
): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const host = document.createElement('div')
|
|
||||||
document.body.appendChild(host)
|
|
||||||
const root = createRoot(host)
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
root.unmount()
|
|
||||||
host.remove()
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
root.render(<ManageLibraryTagsDialogView {...options} onClose={close} />)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 播放器浮层的统一视觉语言。
|
||||||
|
*
|
||||||
|
* 播放器里现在有十几种浮层:底部操作栏、清晰度/倍速/字幕小弹层、设置面板、
|
||||||
|
* 选集抽屉、弹幕面板、VR 工具条、错误提示……以前每一处都自己写一遍圆角、
|
||||||
|
* 边框、透明度,于是同一屏里出现了好几种不同的浮层样式,看起来就「乱」。
|
||||||
|
*
|
||||||
|
* 这里把冒泡、排版、图标按钮等重复的类名收敛成常量:改一次全部同步,
|
||||||
|
* 新加控件也不会再各写一套。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 面板底色。比纯黑更「实」,配合 backdrop-blur 后压在画面上仍然清晰可读。 */
|
||||||
|
const PANEL_BG = 'bg-[#0d0e12]/95'
|
||||||
|
|
||||||
|
/** 浮层外圈阴影:暗背景下用大范围柔光,避免出现一圈生硬的黑边。 */
|
||||||
|
const PANEL_SHADOW = 'shadow-[0_10px_38px_rgba(0,0,0,0.6)]'
|
||||||
|
|
||||||
|
/** 小弹层(清晰度/倍速/字幕/设置):贴着操作栏向上弹出。 */
|
||||||
|
export const PLAYER_POPOVER =
|
||||||
|
`z-40 overflow-hidden rounded-xl border border-white/10 ${PANEL_BG} py-1 text-white ${PANEL_SHADOW} backdrop-blur-xl`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右侧抽屉(选集/弹幕设置):整条贴住播放区域右边缘,通到顶底。
|
||||||
|
*
|
||||||
|
* 小屏上留出 12% 的宽度而不是铺满:这样顶栏的返回按钮和标题仍然露在外面,
|
||||||
|
* 用户不用先关抽屉才能返回(铺满时唯一的出口只剩标题栏上的关闭按钮)。
|
||||||
|
*/
|
||||||
|
export const PLAYER_DRAWER =
|
||||||
|
`flex h-full w-[88%] flex-col border-white/10 ${PANEL_BG} text-white ${PANEL_SHADOW} backdrop-blur-xl sm:w-[380px] sm:border-l`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 底部动作面板(竖屏剧场模式):贴着视频区底部向上弹出,内容最多占
|
||||||
|
* 视频区高度的 72%,留出一些画面可见;底部垫上安全区高度,避免
|
||||||
|
* iPhone 小横条遮挡最后一行选项。
|
||||||
|
*/
|
||||||
|
export const PLAYER_SHEET =
|
||||||
|
`z-40 flex max-h-[72%] min-h-[8rem] w-full flex-col overflow-hidden rounded-t-2xl border border-white/10 border-b-0 ${PANEL_BG} text-white ${PANEL_SHADOW} backdrop-blur-xl`
|
||||||
|
|
||||||
|
/** 底部动作面板的标题栏(带关闭按钮)。 */
|
||||||
|
export const PLAYER_SHEET_HEADER =
|
||||||
|
'flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2.5'
|
||||||
|
|
||||||
|
/** 底部动作面板的可滚动内容区,底部垫上安全区高度。 */
|
||||||
|
export const PLAYER_SHEET_BODY =
|
||||||
|
'min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-1'
|
||||||
|
|
||||||
|
/** 操作栏里的图标按钮。所有图标按钮共用同一尺寸与悬停反馈,排在一起才整齐。 */
|
||||||
|
export const PLAYER_ICON_BUTTON =
|
||||||
|
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/85 transition hover:bg-white/15 hover:text-white disabled:cursor-not-allowed disabled:text-white/30 disabled:hover:bg-transparent sm:h-8 sm:w-8'
|
||||||
|
|
||||||
|
/** 带文字的按钮(清晰度/倍速/选集)。文字按钮和图标按钮同高,基线才对得齐。 */
|
||||||
|
export const PLAYER_TEXT_BUTTON =
|
||||||
|
'flex h-7 shrink-0 items-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-white/85 transition hover:bg-white/15 hover:text-white disabled:cursor-not-allowed disabled:text-white/30 disabled:hover:bg-transparent sm:h-8 sm:px-2 sm:text-xs'
|
||||||
|
|
||||||
|
/** 弹层/面板里的一行选项。 */
|
||||||
|
export const PLAYER_MENU_ITEM =
|
||||||
|
'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs text-white/85 transition hover:bg-white/10 hover:text-white'
|
||||||
|
|
||||||
|
/** 弹层里的小标题(「115 云端」「本地 HLS」这类分组名)。 */
|
||||||
|
export const PLAYER_MENU_LABEL =
|
||||||
|
'px-3 pb-1 pt-2 text-[10px] uppercase tracking-wide text-white/35'
|
||||||
|
|
||||||
|
/** 分组之间的分隔线。 */
|
||||||
|
export const PLAYER_MENU_DIVIDER = 'my-1 h-px bg-white/10'
|
||||||
|
|
||||||
|
/** 抽屉/面板的标题栏。 */
|
||||||
|
export const PLAYER_PANEL_HEADER =
|
||||||
|
'flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-3'
|
||||||
|
|
||||||
|
/** 分段选择(播放方式、投影方式这类互斥选项)。 */
|
||||||
|
export const PLAYER_SEGMENT_GROUP = 'flex items-center gap-1 rounded-lg bg-white/5 p-0.5'
|
||||||
|
export const PLAYER_SEGMENT = 'flex-1 rounded-md px-2 py-1 text-[11px] font-medium transition'
|
||||||
|
export const PLAYER_SEGMENT_ON = 'bg-rose-500 text-white'
|
||||||
|
export const PLAYER_SEGMENT_OFF = 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||||
|
|
||||||
|
/** 开关状态的绿色小点(例如弹幕已加载、VR 素材已识别)。 */
|
||||||
|
export const PLAYER_STATUS_DOT = 'h-1.5 w-1.5 shrink-0 rounded-full'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进度条/音量条上的隐形 range 输入。
|
||||||
|
*
|
||||||
|
* 视觉部分(轨道、已播放段、圆点手柄)全部自己画,原生 range 只负责三件事:
|
||||||
|
* 点击定位、拖拽、键盘与无障碍语义。所以它必须完全透明——但**不能**
|
||||||
|
* pointer-events-none,否则拖不动。
|
||||||
|
*/
|
||||||
|
export const PLAYER_RANGE_OVERLAY =
|
||||||
|
'absolute inset-0 h-full w-full cursor-pointer appearance-none bg-transparent outline-none ' +
|
||||||
|
'[&::-webkit-slider-runnable-track]:bg-transparent ' +
|
||||||
|
'[&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none ' +
|
||||||
|
'[&::-moz-range-track]:bg-transparent ' +
|
||||||
|
'[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:border-0 ' +
|
||||||
|
'[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:bg-transparent'
|
||||||
|
|
||||||
|
/** 自绘的细轨道。悬停时整条轨道加粗,是 B 站进度条最直观的那点反馈。 */
|
||||||
|
export const PLAYER_TRACK =
|
||||||
|
'pointer-events-none absolute inset-x-0 h-[3px] rounded-full bg-white/25 transition-[height] duration-150'
|
||||||
|
export const PLAYER_TRACK_FILL = 'pointer-events-none absolute inset-y-0 left-0 rounded-full'
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useCallback, useState, type DragEvent } from 'react'
|
||||||
|
|
||||||
|
/** 一组可直接展开到可拖拽元素上的事件处理器。 */
|
||||||
|
export type DragReorderHandlers = {
|
||||||
|
draggable: boolean
|
||||||
|
onDragStart: (event: DragEvent) => void
|
||||||
|
onDragOver: (event: DragEvent) => void
|
||||||
|
onDragLeave: (event: DragEvent) => void
|
||||||
|
onDrop: (event: DragEvent) => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 `fromId` 移动到 `toId` 当前所在的位置,返回新数组;不改动入参。
|
||||||
|
* 任一 id 不存在,或两者相同时返回 null,表示无需重排。
|
||||||
|
*/
|
||||||
|
export function moveIdToPosition(ids: string[], fromId: string, toId: string): string[] | null {
|
||||||
|
const fromIndex = ids.indexOf(fromId)
|
||||||
|
const toIndex = ids.indexOf(toId)
|
||||||
|
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null
|
||||||
|
const next = [...ids]
|
||||||
|
const [moved] = next.splice(fromIndex, 1)
|
||||||
|
next.splice(toIndex, 0, moved)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表拖拽排序的通用状态机:只负责「一串稳定 id」的拖拽与重排计算,
|
||||||
|
* 不关心渲染。调用方把 `dragProps(id)` 展开到拖拽柄上,并用返回的
|
||||||
|
* `draggingId` / `dragOverId` 决定高亮样式。
|
||||||
|
*
|
||||||
|
* 落点语义:把被拖项插入到目标项所在的位置。未传 `onReorder` 时整体禁用。
|
||||||
|
*/
|
||||||
|
export function useDragReorder(
|
||||||
|
ids: string[],
|
||||||
|
onReorder?: (orderedIds: string[]) => void | Promise<void>,
|
||||||
|
): {
|
||||||
|
draggingId: string | null
|
||||||
|
dragOverId: string | null
|
||||||
|
dragProps: (id: string) => DragReorderHandlers
|
||||||
|
} {
|
||||||
|
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||||
|
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const enabled = Boolean(onReorder)
|
||||||
|
|
||||||
|
const dragProps = useCallback(
|
||||||
|
(id: string): DragReorderHandlers => ({
|
||||||
|
draggable: enabled,
|
||||||
|
onDragStart: (event) => {
|
||||||
|
if (!enabled) return
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
event.dataTransfer.setData('text/plain', id)
|
||||||
|
setDraggingId(id)
|
||||||
|
setDragOverId(null)
|
||||||
|
},
|
||||||
|
onDragOver: (event) => {
|
||||||
|
if (!enabled || draggingId === id) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
setDragOverId((prev) => (prev === id ? prev : id))
|
||||||
|
},
|
||||||
|
onDragLeave: () => {
|
||||||
|
setDragOverId((prev) => (prev === id ? null : prev))
|
||||||
|
},
|
||||||
|
onDrop: (event) => {
|
||||||
|
if (!enabled) return
|
||||||
|
event.preventDefault()
|
||||||
|
// 拖拽期间 state 可能尚未提交,优先信任 dataTransfer 里的来源 id。
|
||||||
|
const fromId = draggingId ?? event.dataTransfer.getData('text/plain')
|
||||||
|
setDraggingId(null)
|
||||||
|
setDragOverId(null)
|
||||||
|
if (!fromId || fromId === id) return
|
||||||
|
const next = moveIdToPosition(ids, fromId, id)
|
||||||
|
if (next) void onReorder?.(next)
|
||||||
|
},
|
||||||
|
onDragEnd: () => {
|
||||||
|
setDraggingId(null)
|
||||||
|
setDragOverId(null)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[enabled, draggingId, ids, onReorder],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { draggingId, dragOverId, dragProps }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const THEATER_MAX_WIDTH = 768
|
||||||
|
|
||||||
|
function queryTheater(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
// 窄屏 + 竖屏才进剧场模式:横屏手机自动回到全屏沉浸式布局。
|
||||||
|
return window.innerWidth < THEATER_MAX_WIDTH && window.innerHeight >= window.innerWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否使用移动端竖屏剧场布局。
|
||||||
|
*
|
||||||
|
* 桌面端与横屏手机保持原来的全屏居中播放器;只有竖屏手机进
|
||||||
|
* 「视频贴顶 + 下方内容区」的剧场模式,避免 16:9 视频在竖屏里
|
||||||
|
* 上下大黑边、控制栏远离画面的问题。
|
||||||
|
*/
|
||||||
|
export function useIsMobileTheater(): boolean {
|
||||||
|
const [theater, setTheater] = useState(queryTheater)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setTheater(queryTheater())
|
||||||
|
window.addEventListener('resize', onChange)
|
||||||
|
window.addEventListener('orientationchange', onChange)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onChange)
|
||||||
|
window.removeEventListener('orientationchange', onChange)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return theater
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import toast from 'react-hot-toast'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ALL_TAG_ID,
|
ALL_TAG_ID,
|
||||||
|
assignLibrariesToTag,
|
||||||
attachLibraryToTag,
|
attachLibraryToTag,
|
||||||
dedupeLibraryTags,
|
dedupeLibraryTags,
|
||||||
loadLibraryTags,
|
loadLibraryTags,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
normalizeTagName,
|
normalizeTagName,
|
||||||
readCachedLibraryTags,
|
readCachedLibraryTags,
|
||||||
readSelectedTagId,
|
readSelectedTagId,
|
||||||
|
reorderLibraryTags,
|
||||||
resolveSelectedTagId,
|
resolveSelectedTagId,
|
||||||
saveLibraryTags,
|
saveLibraryTags,
|
||||||
writeCachedLibraryTags,
|
writeCachedLibraryTags,
|
||||||
@@ -31,11 +33,15 @@ export type UseLibraryTagsResult = {
|
|||||||
setSelectedTagId: (tagId: string) => void
|
setSelectedTagId: (tagId: string) => void
|
||||||
createTag: (name: string) => Promise<string>
|
createTag: (name: string) => Promise<string>
|
||||||
renameTag: (from: string, to: string) => Promise<void>
|
renameTag: (from: string, to: string) => Promise<void>
|
||||||
|
/** 按给定标签名顺序重排标签栏(拖拽排序的落点)。 */
|
||||||
|
reorderTags: (orderedNames: string[]) => Promise<void>
|
||||||
removeTag: (name: string) => Promise<void>
|
removeTag: (name: string) => Promise<void>
|
||||||
/** 覆盖某个标签下的媒体库集合(保持传入顺序)。 */
|
/** 覆盖某个标签下的媒体库集合(保持传入顺序)。 */
|
||||||
setTagLibraries: (name: string, libraryIds: string[]) => Promise<void>
|
setTagLibraries: (name: string, libraryIds: string[]) => Promise<void>
|
||||||
/** 把媒体库挂到标签下;tagName 为空表示移出所有标签。 */
|
/** 把媒体库挂到标签下;tagName 为空表示移出所有标签。 */
|
||||||
assignLibrary: (libraryId: string, tagName: string) => Promise<void>
|
assignLibrary: (libraryId: string, tagName: string) => Promise<void>
|
||||||
|
/** 批量把媒体库挂到标签下;tagName 为空表示批量移出所有标签。一次写入。 */
|
||||||
|
assignLibraries: (libraryIds: string[], tagName: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,13 +106,16 @@ export function useLibraryTags(): UseLibraryTagsResult {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 选中的标签可能被删除,落回「全部」以免标签栏空白。
|
// 选中的标签可能被删除,落回「全部」以免标签栏空白。
|
||||||
|
// 注意:标签首次加载完成前 tags 为空集合,此时不能据此判定标签已被删除,
|
||||||
|
// 否则从子页面返回时,持久化的选中标签会被误重置为「全部」。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (loading) return
|
||||||
const resolved = resolveSelectedTagId(tags, selectedTagId)
|
const resolved = resolveSelectedTagId(tags, selectedTagId)
|
||||||
if (resolved !== selectedTagId) {
|
if (resolved !== selectedTagId) {
|
||||||
selectedRef.current = resolved
|
selectedRef.current = resolved
|
||||||
setSelectedTagIdState(resolved)
|
setSelectedTagIdState(resolved)
|
||||||
}
|
}
|
||||||
}, [tags, selectedTagId])
|
}, [loading, tags, selectedTagId])
|
||||||
|
|
||||||
const setSelectedTagId = useCallback((tagId: string) => {
|
const setSelectedTagId = useCallback((tagId: string) => {
|
||||||
const next = tagId && tagId !== ALL_TAG_ID ? tagId : ALL_TAG_ID
|
const next = tagId && tagId !== ALL_TAG_ID ? tagId : ALL_TAG_ID
|
||||||
@@ -190,6 +199,13 @@ export function useLibraryTags(): UseLibraryTagsResult {
|
|||||||
[mutate, setSelectedTagId],
|
[mutate, setSelectedTagId],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const reorderTags = useCallback(
|
||||||
|
async (orderedNames: string[]) => {
|
||||||
|
await mutate((current) => reorderLibraryTags(current, orderedNames))
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
|
)
|
||||||
|
|
||||||
const removeTag = useCallback(
|
const removeTag = useCallback(
|
||||||
async (name: string) => {
|
async (name: string) => {
|
||||||
await mutate((current) => current.filter((tag) => tag.name !== name))
|
await mutate((current) => current.filter((tag) => tag.name !== name))
|
||||||
@@ -220,6 +236,13 @@ export function useLibraryTags(): UseLibraryTagsResult {
|
|||||||
[mutate],
|
[mutate],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const assignLibraries = useCallback(
|
||||||
|
async (libraryIds: string[], tagName: string) => {
|
||||||
|
await mutate((current) => assignLibrariesToTag(current, libraryIds, tagName))
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tags,
|
tags,
|
||||||
loading,
|
loading,
|
||||||
@@ -229,9 +252,11 @@ export function useLibraryTags(): UseLibraryTagsResult {
|
|||||||
setSelectedTagId,
|
setSelectedTagId,
|
||||||
createTag,
|
createTag,
|
||||||
renameTag,
|
renameTag,
|
||||||
|
reorderTags,
|
||||||
removeTag,
|
removeTag,
|
||||||
setTagLibraries,
|
setTagLibraries,
|
||||||
assignLibrary,
|
assignLibrary,
|
||||||
|
assignLibraries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-15
@@ -8,10 +8,11 @@ import type { SeriesCard } from '../utils/groupSeries'
|
|||||||
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
|
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
|
||||||
import { buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
|
import { buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
|
||||||
import { LibraryTagBar } from '../components/LibraryTagBar'
|
import { LibraryTagBar } from '../components/LibraryTagBar'
|
||||||
import { openManageLibraryTagsDialog } from '../components/manageLibraryTagsDialog'
|
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
|
||||||
import { useLibraryTags } from '../hooks/useLibraryTags'
|
import { useLibraryTags } from '../hooks/useLibraryTags'
|
||||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||||
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
import { useLibraryListSort } from '../hooks/useLibraryListSort'
|
||||||
|
import { sortLibrariesByField } from '../utils/libraryListSort'
|
||||||
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||||
import {
|
import {
|
||||||
ContinueWatchingSection,
|
ContinueWatchingSection,
|
||||||
@@ -34,6 +35,7 @@ export function HomePage() {
|
|||||||
const [librariesLoading, setLibrariesLoading] = useState(true)
|
const [librariesLoading, setLibrariesLoading] = useState(true)
|
||||||
const [historyLoading, setHistoryLoading] = useState(true)
|
const [historyLoading, setHistoryLoading] = useState(true)
|
||||||
const { pinnedIds } = usePinnedLibraries()
|
const { pinnedIds } = usePinnedLibraries()
|
||||||
|
const { field: sortField, order: sortOrder } = useLibraryListSort()
|
||||||
const libraryTags = useLibraryTags()
|
const libraryTags = useLibraryTags()
|
||||||
|
|
||||||
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
||||||
@@ -90,7 +92,12 @@ export function HomePage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
// 与 /libraries 共用同一份排序偏好(默认库名倒序):置顶库始终在前,
|
||||||
|
// 组内按偏好排序字段排。这样在媒体库页改过排序后,首页顺序也一致。
|
||||||
|
const sortedLibraries = useMemo(
|
||||||
|
() => sortLibrariesByField(libraries, pinnedIds, sortField, sortOrder),
|
||||||
|
[libraries, pinnedIds, sortField, sortOrder],
|
||||||
|
)
|
||||||
|
|
||||||
// 标签过滤只作用于展示层:选中的标签栏决定哪部分媒体库参与轮播/入口网格/内容行。
|
// 标签过滤只作用于展示层:选中的标签栏决定哪部分媒体库参与轮播/入口网格/内容行。
|
||||||
const effectiveTagId = libraryTags.selectedTagId
|
const effectiveTagId = libraryTags.selectedTagId
|
||||||
@@ -103,17 +110,7 @@ export function HomePage() {
|
|||||||
[sortedLibraries, libraryTags.tags, effectiveTagId],
|
[sortedLibraries, libraryTags.tags, effectiveTagId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleManageTags = useCallback(() => {
|
const [libraryTagsOpen, setLibraryTagsOpen] = useState(false)
|
||||||
void openManageLibraryTagsDialog({
|
|
||||||
tags: libraryTags.tags,
|
|
||||||
libraries,
|
|
||||||
saving: libraryTags.saving,
|
|
||||||
onCreate: libraryTags.createTag,
|
|
||||||
onRename: libraryTags.renameTag,
|
|
||||||
onRemove: libraryTags.removeTag,
|
|
||||||
onAssign: libraryTags.assignLibrary,
|
|
||||||
})
|
|
||||||
}, [libraries, libraryTags])
|
|
||||||
|
|
||||||
// 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格,
|
// 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格,
|
||||||
// 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。
|
// 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。
|
||||||
@@ -350,9 +347,23 @@ export function HomePage() {
|
|||||||
onCreate={(name) => {
|
onCreate={(name) => {
|
||||||
void libraryTags.createTag(name)
|
void libraryTags.createTag(name)
|
||||||
}}
|
}}
|
||||||
onManage={handleManageTags}
|
onManage={() => setLibraryTagsOpen(true)}
|
||||||
busy={libraryTags.saving}
|
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 ? (
|
{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)]">
|
<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}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。
|
「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { libraryAPI } from '../api/library'
|
import { libraryAPI } from '../api/library'
|
||||||
import { toolsAPI } from '../api/tools'
|
import { toolsAPI } from '../api/tools'
|
||||||
import { LibraryTagBar } from '../components/LibraryTagBar'
|
import { LibraryTagBar } from '../components/LibraryTagBar'
|
||||||
import { openManageLibraryTagsDialog } from '../components/manageLibraryTagsDialog'
|
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
|
||||||
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
||||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||||
import { useLibraryTags } from '../hooks/useLibraryTags'
|
import { useLibraryTags } from '../hooks/useLibraryTags'
|
||||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||||
|
import { useLibraryListSort } from '../hooks/useLibraryListSort'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import {
|
import {
|
||||||
LibrariesContent,
|
LibrariesContent,
|
||||||
@@ -17,22 +18,29 @@ import {
|
|||||||
import type { LibraryPreview } from './librariesPageModel'
|
import type { LibraryPreview } from './librariesPageModel'
|
||||||
import type { Library } from '../types'
|
import type { Library } from '../types'
|
||||||
import type { SeriesCard } from '../utils/groupSeries'
|
import type { SeriesCard } from '../utils/groupSeries'
|
||||||
|
import {
|
||||||
|
sortLibraryPreviewsByField,
|
||||||
|
type LibraryListSortField,
|
||||||
|
type LibraryListSortOrder,
|
||||||
|
} from '../utils/libraryListSort'
|
||||||
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
||||||
import { ALL_TAG_ID, buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
|
import { ALL_TAG_ID, buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
|
||||||
import type { LibraryTag } from '../utils/libraryTags'
|
import type { LibraryTag } from '../utils/libraryTags'
|
||||||
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
|
||||||
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||||
|
|
||||||
export function LibrariesPage() {
|
export function LibrariesPage() {
|
||||||
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
|
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
// 会话缓存命中时首屏即用缓存渲染(避免先闪一帧占位再被撑高),
|
||||||
|
// 这样 useScrollMemory 的布局期恢复才能在返回列表时立刻落到原位置。
|
||||||
|
const [libraries, setLibraries] = useState<Library[]>(() => peekLibraries() ?? [])
|
||||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
|
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
|
||||||
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
const { pinnedIds, togglePin } = usePinnedLibraries()
|
||||||
const libraryTags = useLibraryTags()
|
const libraryTags = useLibraryTags()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(() => peekLibraries() === null)
|
||||||
const [repairing, setRepairing] = useState(false)
|
const [repairing, setRepairing] = useState(false)
|
||||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
|
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||||
const [repairMsg, setRepairMsg] = useState('')
|
const [repairMsg, setRepairMsg] = useState('')
|
||||||
|
const { field: sortField, order: sortOrder, setSort: setLibrarySort } = useLibraryListSort()
|
||||||
|
|
||||||
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
|
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
|
||||||
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
||||||
@@ -146,24 +154,20 @@ export function LibrariesPage() {
|
|||||||
})
|
})
|
||||||
}, [libraries, libraryData])
|
}, [libraries, libraryData])
|
||||||
|
|
||||||
const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds])
|
const sortedPreviews = useMemo(
|
||||||
|
() => sortLibraryPreviewsByField(previews, pinnedIds, sortField, sortOrder),
|
||||||
|
[previews, pinnedIds, sortField, sortOrder],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSortChange = useCallback((field: LibraryListSortField, order: LibraryListSortOrder) => {
|
||||||
|
setLibrarySort(field, order)
|
||||||
|
}, [setLibrarySort])
|
||||||
|
|
||||||
const handleTogglePin = useCallback((libraryId: string) => {
|
const handleTogglePin = useCallback((libraryId: string) => {
|
||||||
void togglePin(libraryId)
|
void togglePin(libraryId)
|
||||||
}, [togglePin])
|
}, [togglePin])
|
||||||
|
|
||||||
const handleManageTags = useCallback(() => {
|
const [libraryTagsOpen, setLibraryTagsOpen] = useState(false)
|
||||||
void openManageLibraryTagsDialog({
|
|
||||||
tags: libraryTags.tags,
|
|
||||||
libraries,
|
|
||||||
saving: libraryTags.saving,
|
|
||||||
onCreate: libraryTags.createTag,
|
|
||||||
onRename: libraryTags.renameTag,
|
|
||||||
onRemove: libraryTags.removeTag,
|
|
||||||
onAssign: libraryTags.assignLibrary,
|
|
||||||
})
|
|
||||||
}, [libraries, libraryTags])
|
|
||||||
|
|
||||||
const effectiveTagId = libraryTags.selectedTagId
|
const effectiveTagId = libraryTags.selectedTagId
|
||||||
const tagTabs = useMemo(
|
const tagTabs = useMemo(
|
||||||
() => buildLibraryTagTabs(sortedPreviews.map((preview) => preview.library), libraryTags.tags),
|
() => buildLibraryTagTabs(sortedPreviews.map((preview) => preview.library), libraryTags.tags),
|
||||||
@@ -178,7 +182,7 @@ export function LibrariesPage() {
|
|||||||
[taggedPreviews],
|
[taggedPreviews],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (loading || pinnedLoading) {
|
if (loading) {
|
||||||
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
|
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +195,9 @@ export function LibrariesPage() {
|
|||||||
repairMsg={repairMsg}
|
repairMsg={repairMsg}
|
||||||
repairEpisodeArtwork={repairEpisodeArtwork}
|
repairEpisodeArtwork={repairEpisodeArtwork}
|
||||||
repairing={repairing}
|
repairing={repairing}
|
||||||
|
sortField={sortField}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
onRepairEpisodeArtworkChange={setRepairEpisodeArtwork}
|
onRepairEpisodeArtworkChange={setRepairEpisodeArtwork}
|
||||||
onRepairRescrape={handleRepairRescrape}
|
onRepairRescrape={handleRepairRescrape}
|
||||||
onManageLibraries={handleManageLibraries}
|
onManageLibraries={handleManageLibraries}
|
||||||
@@ -203,10 +210,25 @@ export function LibrariesPage() {
|
|||||||
onCreate={(name) => {
|
onCreate={(name) => {
|
||||||
void libraryTags.createTag(name)
|
void libraryTags.createTag(name)
|
||||||
}}
|
}}
|
||||||
onManage={handleManageTags}
|
onManage={() => setLibraryTagsOpen(true)}
|
||||||
busy={libraryTags.saving}
|
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 ? (
|
{previews.length === 0 ? (
|
||||||
<LibrariesEmptyState isAdmin={isAdmin} />
|
<LibrariesEmptyState isAdmin={isAdmin} />
|
||||||
) : taggedPreviews.length === 0 ? (
|
) : taggedPreviews.length === 0 ? (
|
||||||
@@ -225,19 +247,17 @@ export function LibrariesPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按选中标签过滤媒体库预览(ALL_TAG_ID 时原样返回,保留置顶/手动排序)。 */
|
/**
|
||||||
|
* 按选中标签过滤媒体库预览:复用共享的成员筛选(保持输入顺序),
|
||||||
|
* 这样排序下拉(含倒序)在标签页与「全部」页行为一致。
|
||||||
|
*/
|
||||||
function filterPreviewsByTag(previews: LibraryPreview[], tags: LibraryTag[], tagId: string): LibraryPreview[] {
|
function filterPreviewsByTag(previews: LibraryPreview[], tags: LibraryTag[], tagId: string): LibraryPreview[] {
|
||||||
if (!tagId || tagId === ALL_TAG_ID) return previews
|
if (!tagId || tagId === ALL_TAG_ID) return previews
|
||||||
const ordered = filterLibrariesByTag(
|
|
||||||
previews.map((preview) => ({ id: preview.library.id })),
|
|
||||||
tags,
|
|
||||||
tagId,
|
|
||||||
)
|
|
||||||
const byId = new Map(previews.map((preview) => [preview.library.id, preview]))
|
const byId = new Map(previews.map((preview) => [preview.library.id, preview]))
|
||||||
const out: LibraryPreview[] = []
|
const kept: LibraryPreview[] = []
|
||||||
for (const item of ordered) {
|
for (const library of filterLibrariesByTag(previews.map((preview) => preview.library), tags, tagId)) {
|
||||||
const preview = byId.get(item.id)
|
const preview = byId.get(library.id)
|
||||||
if (preview) out.push(preview)
|
if (preview) kept.push(preview)
|
||||||
}
|
}
|
||||||
return out
|
return kept
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { Link } from 'react-router-dom'
|
|||||||
import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react'
|
import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||||
|
import { LibraryListSortDropdown } from '../components/LibraryListSortDropdown'
|
||||||
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
|
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
|
||||||
|
import type { LibraryListSortField, LibraryListSortOrder } from '../utils/libraryListSort'
|
||||||
import { HomeLibrariesSection, HomeLibraryRowSection } from './HomePageSections'
|
import { HomeLibrariesSection, HomeLibraryRowSection } from './HomePageSections'
|
||||||
import type { LibraryPreview } from './librariesPageModel'
|
import type { LibraryPreview } from './librariesPageModel'
|
||||||
|
|
||||||
@@ -18,6 +20,9 @@ export function LibrariesHeader({
|
|||||||
repairMsg,
|
repairMsg,
|
||||||
repairEpisodeArtwork,
|
repairEpisodeArtwork,
|
||||||
repairing,
|
repairing,
|
||||||
|
sortField,
|
||||||
|
sortOrder,
|
||||||
|
onSortChange,
|
||||||
onRepairEpisodeArtworkChange,
|
onRepairEpisodeArtworkChange,
|
||||||
onRepairRescrape,
|
onRepairRescrape,
|
||||||
onManageLibraries,
|
onManageLibraries,
|
||||||
@@ -28,6 +33,9 @@ export function LibrariesHeader({
|
|||||||
repairMsg: string
|
repairMsg: string
|
||||||
repairEpisodeArtwork: boolean
|
repairEpisodeArtwork: boolean
|
||||||
repairing: boolean
|
repairing: boolean
|
||||||
|
sortField: LibraryListSortField
|
||||||
|
sortOrder: LibraryListSortOrder
|
||||||
|
onSortChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
|
||||||
onRepairEpisodeArtworkChange: (value: boolean) => void
|
onRepairEpisodeArtworkChange: (value: boolean) => void
|
||||||
onRepairRescrape: () => void
|
onRepairRescrape: () => void
|
||||||
onManageLibraries: () => void
|
onManageLibraries: () => void
|
||||||
@@ -40,42 +48,45 @@ export function LibrariesHeader({
|
|||||||
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
|
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
<LibraryListSortDropdown value={sortField} order={sortOrder} onChange={onSortChange} />
|
||||||
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
{isAdmin && (
|
||||||
<EpisodeArtworkToggle
|
<>
|
||||||
checked={repairEpisodeArtwork}
|
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
||||||
onChange={onRepairEpisodeArtworkChange}
|
<EpisodeArtworkToggle
|
||||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
checked={repairEpisodeArtwork}
|
||||||
className="h-9 sm:h-10 text-xs sm:text-sm"
|
onChange={onRepairEpisodeArtworkChange}
|
||||||
/>
|
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||||
<button
|
className="h-9 sm:h-10 text-xs sm:text-sm"
|
||||||
type="button"
|
/>
|
||||||
onClick={onRepairRescrape}
|
<button
|
||||||
disabled={repairing}
|
type="button"
|
||||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
onClick={onRepairRescrape}
|
||||||
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
|
disabled={repairing}
|
||||||
>
|
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
|
||||||
{repairing ? '正在启动…' : '全库修复+重刮'}
|
>
|
||||||
</button>
|
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
||||||
<Link
|
{repairing ? '正在启动…' : '全库修复+重刮'}
|
||||||
to="/scraper/queue"
|
</button>
|
||||||
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
<Link
|
||||||
title="查看正在进行的刮削任务与进度"
|
to="/scraper/queue"
|
||||||
>
|
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||||
<Sparkles size={14} className="text-brand-500" />
|
title="查看正在进行的刮削任务与进度"
|
||||||
<span>刮削队列</span>
|
>
|
||||||
</Link>
|
<Sparkles size={14} className="text-brand-500" />
|
||||||
<button
|
<span>刮削队列</span>
|
||||||
type="button"
|
</Link>
|
||||||
onClick={onManageLibraries}
|
<button
|
||||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
type="button"
|
||||||
>
|
onClick={onManageLibraries}
|
||||||
管理媒体库
|
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||||
</button>
|
>
|
||||||
</div>
|
管理媒体库
|
||||||
)}
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export function MediaDetailMainContent({
|
|||||||
onToggleFavourite={onToggleFavourite}
|
onToggleFavourite={onToggleFavourite}
|
||||||
playTargetId={playTargetId}
|
playTargetId={playTargetId}
|
||||||
/>
|
/>
|
||||||
<MediaVersionSwitcher media={media} mode="detail" />
|
<MediaVersionSwitcher media={media} />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<MediaDetailAdminPanel
|
<MediaDetailAdminPanel
|
||||||
media={media}
|
media={media}
|
||||||
|
|||||||
+297
-41
@@ -19,6 +19,7 @@ import { profileAPI } from '../api/profile'
|
|||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import type { Media, PlaybackInfo, PlaybackQuality } from '../types'
|
import type { Media, PlaybackInfo, PlaybackQuality } from '../types'
|
||||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||||
|
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
|
||||||
import { normalizePlaybackRate } from '../utils/playbackRate'
|
import { normalizePlaybackRate } from '../utils/playbackRate'
|
||||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||||
import {
|
import {
|
||||||
@@ -33,15 +34,23 @@ import {
|
|||||||
type SubtitlePosition,
|
type SubtitlePosition,
|
||||||
type SubtitleStylePreset,
|
type SubtitleStylePreset,
|
||||||
} from '../utils/subtitleDisplay'
|
} from '../utils/subtitleDisplay'
|
||||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
|
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel'
|
||||||
import { classifyDirectPlayError } from './directPlayError'
|
import { classifyDirectPlayError } from './directPlayError'
|
||||||
import { apiErrorMessage } from './StrmManagePage'
|
import { apiErrorMessage } from './StrmManagePage'
|
||||||
import { PlayerTopBar } from './PlayerTopBar'
|
import { PlayerTopBar } from './PlayerTopBar'
|
||||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||||
|
import { PlayerMobileTheaterInfo } from '../components/PlayerMobileTheaterInfo'
|
||||||
|
import { useIsMobileTheater } from '../hooks/useIsMobileTheater'
|
||||||
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||||
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
||||||
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
|
import {
|
||||||
import { mediaVersionsOf } from '../utils/mediaVersion'
|
detectVr360Profile,
|
||||||
|
loadVr360Preference,
|
||||||
|
sameVr360Profile,
|
||||||
|
saveVr360Preference,
|
||||||
|
type Vr360Detection,
|
||||||
|
type Vr360Profile,
|
||||||
|
} from '../utils/vr360'
|
||||||
|
|
||||||
// Fullscreen, dark-themed video page.
|
// Fullscreen, dark-themed video page.
|
||||||
//
|
//
|
||||||
@@ -96,6 +105,22 @@ export function PlayerPage() {
|
|||||||
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const [media, setMedia] = useState<Media | null>(null)
|
const [media, setMedia] = useState<Media | null>(null)
|
||||||
|
// VR 全景播放:null = 普通播放。识别结果与用户开关只存本机,
|
||||||
|
// 不改动数据库,换设备/换浏览器不会互相影响。
|
||||||
|
const [vr360, setVr360] = useState<Vr360Profile | null>(null)
|
||||||
|
const [vr360Detected, setVr360Detected] = useState(false)
|
||||||
|
const vr360TouchedRef = useRef(false)
|
||||||
|
// VR 取帧失败后的自愈:只尝试一次「切到 HLS」,并在切换窗口内忽略重复报错。
|
||||||
|
const vr360TextureRetryUsedRef = useRef(false)
|
||||||
|
const vr360TextureRetryUntilRef = useRef(0)
|
||||||
|
// VR 全景下 STRM/网盘直连必须由服务端同源转发,浏览器才允许 WebGL 读取画面;
|
||||||
|
// 普通播放仍然 302 直连,避免把网盘流量压到服务器。放在 ref 里是为了让
|
||||||
|
// handleVideoError 里的 src 比对始终拿到最新值。
|
||||||
|
const vr360DirectProxy = Boolean(vr360) && isStrmMedia(media)
|
||||||
|
const vr360DirectProxyRef = useRef(vr360DirectProxy)
|
||||||
|
useEffect(() => {
|
||||||
|
vr360DirectProxyRef.current = vr360DirectProxy
|
||||||
|
}, [vr360DirectProxy])
|
||||||
const [mode, setMode] = useState<PlayerMode>('direct')
|
const [mode, setMode] = useState<PlayerMode>('direct')
|
||||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||||
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
|
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
|
||||||
@@ -114,6 +139,9 @@ export function PlayerPage() {
|
|||||||
normalizePlaybackRate(authUser?.player_playback_rate),
|
normalizePlaybackRate(authUser?.player_playback_rate),
|
||||||
)
|
)
|
||||||
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
||||||
|
// VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来,
|
||||||
|
// 这时不弹说明,避免给老用户闪一下。
|
||||||
|
const [vr360GuideSeen, setVr360GuideSeen] = useState<boolean | null>(null)
|
||||||
const playerVolumeTouchedRef = useRef(false)
|
const playerVolumeTouchedRef = useRef(false)
|
||||||
const playerPlaybackRateTouchedRef = useRef(false)
|
const playerPlaybackRateTouchedRef = useRef(false)
|
||||||
const playerPlaybackRateSaveSeqRef = useRef(0)
|
const playerPlaybackRateSaveSeqRef = useRef(0)
|
||||||
@@ -260,6 +288,7 @@ export function PlayerPage() {
|
|||||||
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
||||||
setDanmakuArea(Number(cfg.area) || 1)
|
setDanmakuArea(Number(cfg.area) || 1)
|
||||||
setDanmakuMergeSources(Boolean(cfg.merge_sources))
|
setDanmakuMergeSources(Boolean(cfg.merge_sources))
|
||||||
|
setVr360GuideSeen(Boolean(cfg.vr360_guide_seen))
|
||||||
applyDanmakuConnectionConfig(cfg)
|
applyDanmakuConnectionConfig(cfg)
|
||||||
if (!playerVolumeTouchedRef.current) {
|
if (!playerVolumeTouchedRef.current) {
|
||||||
const current = useAuthStore.getState().user
|
const current = useAuthStore.getState().user
|
||||||
@@ -458,6 +487,11 @@ export function PlayerPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMedia(null)
|
setMedia(null)
|
||||||
setLoadError('')
|
setLoadError('')
|
||||||
|
vr360TouchedRef.current = false
|
||||||
|
vr360TextureRetryUsedRef.current = false
|
||||||
|
vr360TextureRetryUntilRef.current = 0
|
||||||
|
setVr360(null)
|
||||||
|
setVr360Detected(false)
|
||||||
setHlsStartSec(0)
|
setHlsStartSec(0)
|
||||||
setPlaybackInfo(null)
|
setPlaybackInfo(null)
|
||||||
setHlsSource('local')
|
setHlsSource('local')
|
||||||
@@ -537,6 +571,97 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
}, [id, modeParam, directOnly])
|
}, [id, modeParam, directOnly])
|
||||||
|
|
||||||
|
// VR 全景素材识别:只在用户没手动改过开关时自动进入 VR 模式。
|
||||||
|
// 识别完全基于文件名/路径关键词与画幅比例,误判时点一下 VR 按钮即可退出。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!media || media.id !== id) return
|
||||||
|
const detection = detectVr360Profile({
|
||||||
|
title: media.title,
|
||||||
|
originalName: media.original_name,
|
||||||
|
path: media.path,
|
||||||
|
relativePath: media.relative_path,
|
||||||
|
width: media.width,
|
||||||
|
height: media.height,
|
||||||
|
})
|
||||||
|
setVr360Detected(detection.confident)
|
||||||
|
if (vr360TouchedRef.current) return
|
||||||
|
const preference = loadVr360Preference()
|
||||||
|
if (!preference.autoDetect || !detection.confident) {
|
||||||
|
setVr360(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (detection.profile.projection !== preference.profile.projection) {
|
||||||
|
// 投影方式由文件名决定(360 / 180 / 鱼眼),记住它作为下次的默认值。
|
||||||
|
saveVr360Preference({ ...preference, profile: detection.profile })
|
||||||
|
}
|
||||||
|
setVr360(detection.profile)
|
||||||
|
}, [id, media])
|
||||||
|
|
||||||
|
// 远端 Emby 挂载的媒体常常没有宽高元数据(width/height 为 0),等播放器拿到
|
||||||
|
// 真实画面尺寸后再补一次识别。HLS 转码是等比缩放,所以比例依然可信。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!media || media.id !== id) return
|
||||||
|
if (media.width > 0 && media.height > 0) return
|
||||||
|
const video = ref.current
|
||||||
|
if (!video) return
|
||||||
|
const refine = () => {
|
||||||
|
if (vr360TouchedRef.current || video.videoWidth <= 0 || video.videoHeight <= 0) return
|
||||||
|
const detection = detectVr360Profile({
|
||||||
|
title: media.title,
|
||||||
|
originalName: media.original_name,
|
||||||
|
path: media.path,
|
||||||
|
relativePath: media.relative_path,
|
||||||
|
width: video.videoWidth,
|
||||||
|
height: video.videoHeight,
|
||||||
|
})
|
||||||
|
setVr360Detected(detection.confident)
|
||||||
|
const preference = loadVr360Preference()
|
||||||
|
if (!preference.autoDetect || !detection.confident) return
|
||||||
|
setVr360((current) =>
|
||||||
|
current && sameVr360Profile(current, detection.profile) ? current : detection.profile,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
video.addEventListener('loadedmetadata', refine)
|
||||||
|
refine()
|
||||||
|
return () => video.removeEventListener('loadedmetadata', refine)
|
||||||
|
}, [id, media])
|
||||||
|
|
||||||
|
const toggleVr360 = useCallback(() => {
|
||||||
|
vr360TouchedRef.current = true
|
||||||
|
if (vr360) {
|
||||||
|
setVr360(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const current = mediaRef.current
|
||||||
|
const detection: Vr360Detection | null = current
|
||||||
|
? detectVr360Profile({
|
||||||
|
title: current.title,
|
||||||
|
originalName: current.original_name,
|
||||||
|
path: current.path,
|
||||||
|
relativePath: current.relative_path,
|
||||||
|
width: current.width,
|
||||||
|
height: current.height,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
const preference = loadVr360Preference()
|
||||||
|
const profile = detection?.profile ?? preference.profile
|
||||||
|
saveVr360Preference({ ...preference, profile })
|
||||||
|
setVr360(profile)
|
||||||
|
}, [vr360])
|
||||||
|
|
||||||
|
const changeVr360Profile = useCallback((profile: Vr360Profile) => {
|
||||||
|
vr360TouchedRef.current = true
|
||||||
|
setVr360(profile)
|
||||||
|
saveVr360Preference({ ...loadVr360Preference(), profile })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// VR 操作说明是「按用户只弹一次」:用户关掉说明时就落库,之后进入 VR 不再显示。
|
||||||
|
// 保存失败只影响下次是否再弹一次,不打断播放,所以本地状态不回滚。
|
||||||
|
const dismissVr360Guide = useCallback(() => {
|
||||||
|
setVr360GuideSeen(true)
|
||||||
|
void danmakuAPI.updateSettings({ vr360_guide_seen: true }).catch(() => undefined)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Wire up the actual <video> element when we know the mode.
|
// Wire up the actual <video> element when we know the mode.
|
||||||
// Depend on media.id (not the media object): refreshing duration after
|
// Depend on media.id (not the media object): refreshing duration after
|
||||||
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
||||||
@@ -608,6 +733,33 @@ export function PlayerPage() {
|
|||||||
[mediaId, setPlaybackMode, switchToLocalHLS],
|
[mediaId, setPlaybackMode, switchToLocalHLS],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 切到 HLS 播放(优先 115 云端,其次本地转码)。toggleMode 与「VR 需要同源帧」
|
||||||
|
// 的自愈逻辑共用,避免两处各写一遍云端/本地选择。
|
||||||
|
const enterHlsPlayback = useCallback(
|
||||||
|
(startSec: number) => {
|
||||||
|
setHlsStartSec(startSec)
|
||||||
|
const preferred =
|
||||||
|
playbackInfo?.provider === 'cloud115'
|
||||||
|
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
|
||||||
|
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
|
||||||
|
: undefined
|
||||||
|
if (preferred?.source === 'cloud') {
|
||||||
|
setHlsSource('cloud')
|
||||||
|
if (preferred.available) {
|
||||||
|
setCloudWaiting(false)
|
||||||
|
} else {
|
||||||
|
setCloudWaiting(true)
|
||||||
|
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
|
||||||
|
void startCloudTranscode(Number(preferred.id) || 4)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switchToLocalHLS(startSec)
|
||||||
|
}
|
||||||
|
setPlaybackMode('hls')
|
||||||
|
},
|
||||||
|
[playbackInfo, selectedQuality, setPlaybackMode, startCloudTranscode, switchToLocalHLS],
|
||||||
|
)
|
||||||
|
|
||||||
// 云端转码等待:按后端建议间隔轮询,完成后自动切到云 HLS。
|
// 云端转码等待:按后端建议间隔轮询,完成后自动切到云 HLS。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cloudWaiting || !mediaId) return
|
if (!cloudWaiting || !mediaId) return
|
||||||
@@ -792,13 +944,30 @@ export function PlayerPage() {
|
|||||||
setPlaybackMode('direct')
|
setPlaybackMode('direct')
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const url = streamURL(mediaId)
|
// VR 全景需要浏览器能读帧:STRM/网盘直链会被 302 到跨域 CDN,此时改为
|
||||||
|
// 服务端同源转发(原画,不转码);普通播放仍走 302 直连,省服务器流量。
|
||||||
|
const url = streamURL(mediaId, { proxy: vr360DirectProxyRef.current })
|
||||||
const absoluteURL = new URL(url, window.location.href).href
|
const absoluteURL = new URL(url, window.location.href).href
|
||||||
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
|
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
|
||||||
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
|
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
|
||||||
if (video.src !== absoluteURL) {
|
if (video.src !== absoluteURL) {
|
||||||
directRetryRef.current = false
|
directRetryRef.current = false
|
||||||
clearFallbackTimer()
|
clearFallbackTimer()
|
||||||
|
// 进出 VR 会切换播放源地址,这里保住当前播放位置。
|
||||||
|
const resumeAt = video.currentTime > 2 ? video.currentTime : 0
|
||||||
|
if (resumeAt > 0) {
|
||||||
|
video.addEventListener(
|
||||||
|
'loadedmetadata',
|
||||||
|
() => {
|
||||||
|
try {
|
||||||
|
video.currentTime = resumeAt
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
video.src = url
|
video.src = url
|
||||||
void video.play().catch(() => undefined)
|
void video.play().catch(() => undefined)
|
||||||
}
|
}
|
||||||
@@ -827,6 +996,7 @@ export function PlayerPage() {
|
|||||||
setPlaybackMode,
|
setPlaybackMode,
|
||||||
switchToLocalHLS,
|
switchToLocalHLS,
|
||||||
teardownHls,
|
teardownHls,
|
||||||
|
vr360DirectProxy,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
|
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
|
||||||
@@ -1015,9 +1185,12 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
}, [id, media])
|
}, [id, media])
|
||||||
|
|
||||||
|
// 选集列表按版本组折叠,行的 id 是组内最优版本;用户切到同一条目的其它
|
||||||
|
// 版本后 media.id 与行 id 不再相等,这里必须按版本组比对,否则上一集/下一集
|
||||||
|
// 与自动连播都会失效。
|
||||||
const currentEpisodeIndex = useMemo(() => {
|
const currentEpisodeIndex = useMemo(() => {
|
||||||
if (!media || playlistEpisodes.length === 0) return -1
|
if (!media || playlistEpisodes.length === 0) return -1
|
||||||
return playlistEpisodes.findIndex((e) => e.id === media.id)
|
return playlistEpisodes.findIndex((e) => mediaVersionMatches(e, media.id))
|
||||||
}, [media, playlistEpisodes])
|
}, [media, playlistEpisodes])
|
||||||
|
|
||||||
const prevEpisode = useMemo(() => {
|
const prevEpisode = useMemo(() => {
|
||||||
@@ -1055,8 +1228,8 @@ export function PlayerPage() {
|
|||||||
[navigate, location.search, location.state],
|
[navigate, location.search, location.state],
|
||||||
)
|
)
|
||||||
|
|
||||||
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本入口。
|
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本信息。
|
||||||
const versionList = useMemo(
|
const currentVersions = useMemo(
|
||||||
() => (media?.id === id ? mediaVersionsOf(media) : []),
|
() => (media?.id === id ? mediaVersionsOf(media) : []),
|
||||||
[id, media],
|
[id, media],
|
||||||
)
|
)
|
||||||
@@ -1155,7 +1328,47 @@ export function PlayerPage() {
|
|||||||
playbackInfo?.provider === 'cloud115'
|
playbackInfo?.provider === 'cloud115'
|
||||||
? [...(playbackInfo.cloud_qualities ?? []), ...(playbackInfo.local_qualities ?? [])]
|
? [...(playbackInfo.cloud_qualities ?? []), ...(playbackInfo.local_qualities ?? [])]
|
||||||
: (playbackInfo?.local_qualities ?? [])
|
: (playbackInfo?.local_qualities ?? [])
|
||||||
const showQuality = mode !== 'direct' && qualityOptions.length > 0
|
// 直连解码模式下宿主机不转码,档位选择没有意义(远程 Emby 挂载同理):直接隐藏。
|
||||||
|
const showQuality = !directOnly && qualityOptions.length > 0
|
||||||
|
// 清晰度按钮上显示的文字。直接播放时按「原画」呈现,和档位列表里的原画项一致,
|
||||||
|
// 避免出现「明明是原文件却显示 1080P」这种误导。
|
||||||
|
const originalQualityLabel =
|
||||||
|
qualityOptions.find((quality) => quality.source === 'original')?.label || '原画'
|
||||||
|
const selectedQualityLabel = findPlaybackQualityById(playbackInfo, selectedQuality)?.label || ''
|
||||||
|
const qualityLabel =
|
||||||
|
mode === 'direct' ? originalQualityLabel : selectedQualityLabel || '清晰度'
|
||||||
|
// 播放方式只描述状态,切换入口在控制栏的「设置」面板里(见 onTogglePlaybackMode)。
|
||||||
|
const playbackModeLabel = isDirectStream
|
||||||
|
? isRemoteEmbyID(media?.id)
|
||||||
|
? 'Emby 直连播放'
|
||||||
|
: '直连播放'
|
||||||
|
: directOnly
|
||||||
|
? '客户端直连解码'
|
||||||
|
: mode === 'hls'
|
||||||
|
? 'HLS 转码'
|
||||||
|
: '直接播放'
|
||||||
|
const canTogglePlaybackMode = !isDirectStream && !directOnly
|
||||||
|
|
||||||
|
// 顶栏标题下的次要信息:集数进度与版本数量,让用户一眼知道「在看什么、在哪」。
|
||||||
|
const topBarTitle = media?.title?.trim() || ''
|
||||||
|
const topBarSubtitle = useMemo(() => {
|
||||||
|
if (!media) return ''
|
||||||
|
const parts: string[] = []
|
||||||
|
if (prevEpisode || nextEpisode) {
|
||||||
|
const total = playlistEpisodes.length
|
||||||
|
const index = currentEpisodeIndex + 1
|
||||||
|
parts.push(total > 0 ? `第 ${index} / ${total} 集` : `第 ${index} 集`)
|
||||||
|
}
|
||||||
|
if (currentVersions.length > 1) parts.push(`${currentVersions.length} 个版本`)
|
||||||
|
return parts.join(' · ')
|
||||||
|
}, [
|
||||||
|
media,
|
||||||
|
currentEpisodeIndex,
|
||||||
|
currentVersions.length,
|
||||||
|
nextEpisode,
|
||||||
|
playlistEpisodes.length,
|
||||||
|
prevEpisode,
|
||||||
|
])
|
||||||
|
|
||||||
// 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。
|
// 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1186,38 +1399,49 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||||
if (next === 'hls') {
|
if (next === 'hls') {
|
||||||
setHlsStartSec(0)
|
enterHlsPlayback(0)
|
||||||
const preferred =
|
|
||||||
playbackInfo?.provider === 'cloud115'
|
|
||||||
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
|
|
||||||
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
|
|
||||||
: undefined
|
|
||||||
if (preferred?.source === 'cloud') {
|
|
||||||
setHlsSource('cloud')
|
|
||||||
if (preferred.available) {
|
|
||||||
setCloudWaiting(false)
|
|
||||||
} else {
|
|
||||||
setCloudWaiting(true)
|
|
||||||
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
|
|
||||||
void startCloudTranscode(Number(preferred.id) || 4)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
switchToLocalHLS()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setCloudWaiting(false)
|
setCloudWaiting(false)
|
||||||
}
|
}
|
||||||
setPlaybackMode(next)
|
setPlaybackMode(next)
|
||||||
}, [
|
}, [
|
||||||
|
enterHlsPlayback,
|
||||||
isDirectStream,
|
isDirectStream,
|
||||||
mode,
|
mode,
|
||||||
playbackInfo,
|
|
||||||
selectedQuality,
|
|
||||||
setPlaybackMode,
|
setPlaybackMode,
|
||||||
startCloudTranscode,
|
|
||||||
switchToLocalHLS,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// VR 渲染取不到帧时:网盘/STRM 的直连会 302 跳到 CDN,视频就变成跨域资源,
|
||||||
|
// 浏览器禁止 WebGL 读取(texImage2D 抛 SecurityError)。此时自动切到同源的
|
||||||
|
// HLS(云端优先)再继续 VR,只有确实做不到时才如实报错并退出 VR。
|
||||||
|
const handleVr360Error = useCallback(
|
||||||
|
(message: string) => {
|
||||||
|
const now = Date.now()
|
||||||
|
// 切换 HLS 的过程中旧的直连帧还会继续上报失败,先忽略这一窗口内的重复错误。
|
||||||
|
if (now < vr360TextureRetryUntilRef.current) return
|
||||||
|
const canRetryWithHls =
|
||||||
|
!vr360TextureRetryUsedRef.current &&
|
||||||
|
modeRef.current === 'direct' &&
|
||||||
|
!directOnly &&
|
||||||
|
!isRemoteEmbyID(mediaRef.current?.id)
|
||||||
|
if (canRetryWithHls) {
|
||||||
|
vr360TextureRetryUsedRef.current = true
|
||||||
|
vr360TextureRetryUntilRef.current = now + 15_000
|
||||||
|
setPlayerError('')
|
||||||
|
toast('直连源跨域,浏览器无法取帧;正在切到 HLS 转码后继续 VR 全景')
|
||||||
|
enterHlsPlayback(ref.current?.currentTime || 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vr360TouchedRef.current = true
|
||||||
|
setVr360(null)
|
||||||
|
setPlayerError(
|
||||||
|
`${message}。若当前是网盘/STRM 直连(会 302 跳转到 CDN),浏览器不允许 WebGL 读取跨域画面,请改用 HLS 播放后再开启 VR。`,
|
||||||
|
)
|
||||||
|
toast.error(message)
|
||||||
|
},
|
||||||
|
[directOnly, enterHlsPlayback],
|
||||||
|
)
|
||||||
|
|
||||||
const handleSeekAbsolute = useCallback(
|
const handleSeekAbsolute = useCallback(
|
||||||
(absoluteSec: number) => {
|
(absoluteSec: number) => {
|
||||||
if (mode !== 'hls') return false
|
if (mode !== 'hls') return false
|
||||||
@@ -1343,7 +1567,9 @@ export function PlayerPage() {
|
|||||||
|
|
||||||
if (retryingDirectRef.current) return
|
if (retryingDirectRef.current) return
|
||||||
|
|
||||||
const expectedSrc = mediaId ? new URL(streamURL(mediaId), window.location.href).href : ''
|
const expectedSrc = mediaId
|
||||||
|
? new URL(streamURL(mediaId, { proxy: vr360DirectProxyRef.current }), window.location.href).href
|
||||||
|
: ''
|
||||||
const action = classifyDirectPlayError({
|
const action = classifyDirectPlayError({
|
||||||
errorCode: video?.error?.code,
|
errorCode: video?.error?.code,
|
||||||
readyState: video?.readyState ?? 0,
|
readyState: video?.readyState ?? 0,
|
||||||
@@ -1431,24 +1657,23 @@ export function PlayerPage() {
|
|||||||
media?.title?.trim() ||
|
media?.title?.trim() ||
|
||||||
''
|
''
|
||||||
|
|
||||||
|
// 竖屏手机走剧场布局:视频贴顶按 16:9 自适应,下方是标题/选集/简介。
|
||||||
|
// 桌面端与横屏手机保持原来的全屏居中沉浸式布局。
|
||||||
|
const isMobileTheater = useIsMobileTheater()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
|
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
|
||||||
<PlayerTopBar
|
<PlayerTopBar
|
||||||
|
title={topBarTitle}
|
||||||
|
subtitle={topBarSubtitle}
|
||||||
directOnly={directOnly}
|
directOnly={directOnly}
|
||||||
isDirectStream={isDirectStream}
|
isDirectStream={isDirectStream}
|
||||||
directStreamLabel={isRemoteEmbyID(media?.id) ? 'Emby 直连播放' : undefined}
|
directStreamLabel={isRemoteEmbyID(media?.id) ? 'Emby 直连播放' : undefined}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onBack={goBack}
|
onBack={goBack}
|
||||||
onToggleMode={toggleMode}
|
|
||||||
/>
|
/>
|
||||||
{versionList.length > 1 && (
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-16 z-20 flex justify-center px-4 sm:top-20">
|
|
||||||
<div className="pointer-events-auto max-w-3xl rounded-2xl border border-white/15 bg-black/70 px-3 py-2 shadow-xl backdrop-blur">
|
|
||||||
<MediaVersionSwitcher media={media!} mode="player" onSelect={switchVersion} className="text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<PlayerVideoStage
|
<PlayerVideoStage
|
||||||
|
theater={isMobileTheater}
|
||||||
media={media}
|
media={media}
|
||||||
loadError={loadError}
|
loadError={loadError}
|
||||||
playerError={playerError}
|
playerError={playerError}
|
||||||
@@ -1477,7 +1702,7 @@ export function PlayerPage() {
|
|||||||
danmakuEpisodeId={danmakuEpisodeId}
|
danmakuEpisodeId={danmakuEpisodeId}
|
||||||
danmakuSearchTrigger={danmakuSearchTrigger}
|
danmakuSearchTrigger={danmakuSearchTrigger}
|
||||||
danmakuOpen={danmakuOpen}
|
danmakuOpen={danmakuOpen}
|
||||||
onToggleDanmaku={toggleDanmakuOpen}
|
onOpenDanmaku={toggleDanmakuOpen}
|
||||||
onDanmakuLoaded={danmakuLoaded}
|
onDanmakuLoaded={danmakuLoaded}
|
||||||
onDanmakuCandidates={danmakuGotCandidates}
|
onDanmakuCandidates={danmakuGotCandidates}
|
||||||
onDanmakuAlternatives={danmakuGotAlternatives}
|
onDanmakuAlternatives={danmakuGotAlternatives}
|
||||||
@@ -1488,28 +1713,43 @@ export function PlayerPage() {
|
|||||||
prevEpisodeTitle={prevEpisodeTitle}
|
prevEpisodeTitle={prevEpisodeTitle}
|
||||||
nextEpisodeTitle={nextEpisodeTitle}
|
nextEpisodeTitle={nextEpisodeTitle}
|
||||||
playlistOpen={playlistOpen}
|
playlistOpen={playlistOpen}
|
||||||
hasPlaylist={playlistEpisodes.length > 0}
|
hasPlaylist={playlistEpisodes.length > 1 || currentVersions.length > 1}
|
||||||
|
hasVersions={currentVersions.length > 1}
|
||||||
onTogglePlaylist={togglePlaylistOpen}
|
onTogglePlaylist={togglePlaylistOpen}
|
||||||
knownDuration={media?.duration_sec || 0}
|
knownDuration={media?.duration_sec || 0}
|
||||||
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
|
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
|
||||||
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
|
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
|
||||||
qualities={qualityOptions}
|
qualities={qualityOptions}
|
||||||
|
qualityLabel={qualityLabel}
|
||||||
selectedQuality={selectedQuality}
|
selectedQuality={selectedQuality}
|
||||||
onSelectQuality={selectPlaybackQuality}
|
onSelectQuality={selectPlaybackQuality}
|
||||||
showQuality={showQuality}
|
showQuality={showQuality}
|
||||||
|
playbackModeLabel={playbackModeLabel}
|
||||||
|
onTogglePlaybackMode={canTogglePlaybackMode ? toggleMode : undefined}
|
||||||
waiting={cloudWaiting}
|
waiting={cloudWaiting}
|
||||||
waitingMessage={cloudWaitMessage}
|
waitingMessage={cloudWaitMessage}
|
||||||
|
vr360={vr360}
|
||||||
|
vr360Detected={vr360Detected}
|
||||||
|
onToggleVr360={toggleVr360}
|
||||||
|
onVr360ProfileChange={changeVr360Profile}
|
||||||
|
onVr360Error={handleVr360Error}
|
||||||
|
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
|
||||||
|
onDismissVr360Guide={dismissVr360Guide}
|
||||||
playlistPanel={
|
playlistPanel={
|
||||||
<PlayerPlaylistPanel
|
<PlayerPlaylistPanel
|
||||||
|
theater={isMobileTheater}
|
||||||
open={playlistOpen}
|
open={playlistOpen}
|
||||||
onClose={() => setPlaylistOpen(false)}
|
onClose={() => setPlaylistOpen(false)}
|
||||||
currentMediaId={media?.id ?? ''}
|
currentMediaId={media?.id ?? ''}
|
||||||
|
currentVersions={currentVersions}
|
||||||
episodes={playlistEpisodes}
|
episodes={playlistEpisodes}
|
||||||
onSelectEpisode={playEpisode}
|
onSelectEpisode={playEpisode}
|
||||||
|
onSelectVersion={switchVersion}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
danmakuPanel={
|
danmakuPanel={
|
||||||
<PlayerDanmakuPanel
|
<PlayerDanmakuPanel
|
||||||
|
theater={isMobileTheater}
|
||||||
open={danmakuOpen}
|
open={danmakuOpen}
|
||||||
onClose={() => setDanmakuOpen(false)}
|
onClose={() => setDanmakuOpen(false)}
|
||||||
enabled={danmakuEnabled}
|
enabled={danmakuEnabled}
|
||||||
@@ -1544,6 +1784,22 @@ export function PlayerPage() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{isMobileTheater ? (
|
||||||
|
<PlayerMobileTheaterInfo
|
||||||
|
media={media?.id === id ? media : null}
|
||||||
|
title={topBarTitle}
|
||||||
|
subtitle={topBarSubtitle}
|
||||||
|
playbackModeLabel={playbackModeLabel}
|
||||||
|
qualityLabel={showQuality ? qualityLabel : undefined}
|
||||||
|
episodes={playlistEpisodes}
|
||||||
|
currentMediaId={media?.id ?? ''}
|
||||||
|
currentEpisodeIndex={currentEpisodeIndex}
|
||||||
|
currentVersions={currentVersions}
|
||||||
|
onSelectEpisode={playEpisode}
|
||||||
|
onSelectVersion={switchVersion}
|
||||||
|
onOpenPlaylist={togglePlaylistOpen}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,93 @@
|
|||||||
import { ArrowLeft, RefreshCw, Sparkles } from 'lucide-react'
|
import { ArrowLeft, Gauge, RefreshCw, Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
import type { PlayerMode } from './playerPageModel'
|
import type { PlayerMode } from './playerPageModel'
|
||||||
|
import { PLAYER_ICON_BUTTON } from '../components/playerTheme'
|
||||||
|
|
||||||
|
// PlayerTopBar — 播放器顶部的极简浮层。
|
||||||
|
//
|
||||||
|
// 以前这里只有「返回」和一个播放方式药丸按钮,用户看不到自己在看什么;而播放方式
|
||||||
|
// 又要和控制栏里的画质/倍速混在一起点。现在改成 B 站那样:左边是返回图标 + 当前
|
||||||
|
// 媒体标题,右边只留一个只读的播放方式状态标签(真正的切换入口收进了控制栏的
|
||||||
|
// 「设置」面板),顶栏因此不再参与「一堆药丸按钮」的视觉噪音。
|
||||||
|
|
||||||
type PlayerTopBarProps = {
|
type PlayerTopBarProps = {
|
||||||
|
/** 当前播放的媒体标题。 */
|
||||||
|
title?: string
|
||||||
|
/** 标题下的次要信息(集数、版本等)。 */
|
||||||
|
subtitle?: string
|
||||||
directOnly: boolean
|
directOnly: boolean
|
||||||
isDirectStream?: boolean
|
isDirectStream?: boolean
|
||||||
directStreamLabel?: string
|
directStreamLabel?: string
|
||||||
mode: PlayerMode
|
mode: PlayerMode
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onToggleMode: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlayerTopBar({
|
export function PlayerTopBar({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
directOnly,
|
directOnly,
|
||||||
isDirectStream,
|
isDirectStream,
|
||||||
directStreamLabel,
|
directStreamLabel,
|
||||||
mode,
|
mode,
|
||||||
onBack,
|
onBack,
|
||||||
onToggleMode,
|
|
||||||
}: PlayerTopBarProps) {
|
}: PlayerTopBarProps) {
|
||||||
return (
|
const status = isDirectStream
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between p-4 sm:p-6">
|
? {
|
||||||
<button
|
icon: <Sparkles size={12} />,
|
||||||
onClick={onBack}
|
label: directStreamLabel || '直连播放',
|
||||||
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
title: directStreamLabel
|
||||||
>
|
? `${directStreamLabel},默认直连播放,不进行转码`
|
||||||
<ArrowLeft size={16} /> 返回
|
: '远程直连播放,不进行转码',
|
||||||
</button>
|
}
|
||||||
|
: directOnly
|
||||||
|
? {
|
||||||
|
icon: <Sparkles size={12} />,
|
||||||
|
label: '客户端直连解码',
|
||||||
|
title: '宿主机不转码,由客户端本地解码直连',
|
||||||
|
}
|
||||||
|
: mode === 'hls'
|
||||||
|
? {
|
||||||
|
icon: <RefreshCw size={12} />,
|
||||||
|
label: 'HLS 转码',
|
||||||
|
title: '当前为 HLS 转码播放,可在控制栏「设置」里切回直接播放',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
icon: <Gauge size={12} />,
|
||||||
|
label: '直接播放',
|
||||||
|
title: '当前直接播放原始文件,可在控制栏「设置」里切到 HLS 转码',
|
||||||
|
}
|
||||||
|
|
||||||
<div className="pointer-events-auto flex items-center gap-2">
|
return (
|
||||||
{isDirectStream ? (
|
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 bg-gradient-to-b from-black/60 via-black/20 to-transparent px-2.5 pb-10 pt-2.5 sm:px-3.5 sm:pt-3.5">
|
||||||
<span
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
|
<button
|
||||||
title={directStreamLabel ? `${directStreamLabel},默认直连播放,不进行转码` : '远程直连播放,不进行转码'}
|
onClick={onBack}
|
||||||
>
|
className={`${PLAYER_ICON_BUTTON} pointer-events-auto h-8 w-8 bg-black/35 backdrop-blur hover:bg-black/60 sm:h-9 sm:w-9`}
|
||||||
<Sparkles size={14} /> {directStreamLabel || '直连播放'}
|
title="返回 (Esc)"
|
||||||
</span>
|
>
|
||||||
) : directOnly ? (
|
<ArrowLeft size={18} />
|
||||||
<span
|
</button>
|
||||||
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
|
{title ? (
|
||||||
title="宿主机不转码,由客户端本地解码直连"
|
<div className="min-w-0">
|
||||||
>
|
<p className="truncate text-sm font-medium text-white/95 drop-shadow-[0_1px_4px_rgba(0,0,0,0.8)]">
|
||||||
<Sparkles size={14} /> 客户端直连解码
|
{title}
|
||||||
</span>
|
</p>
|
||||||
) : (
|
{subtitle ? (
|
||||||
<button
|
<p className="truncate text-[11px] text-white/65 drop-shadow-[0_1px_4px_rgba(0,0,0,0.8)]">
|
||||||
onClick={onToggleMode}
|
{subtitle}
|
||||||
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
</p>
|
||||||
title="切换播放模式"
|
) : null}
|
||||||
>
|
</div>
|
||||||
{mode === 'hls' ? (
|
) : null}
|
||||||
<>
|
|
||||||
<RefreshCw size={14} /> HLS 转码中
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Sparkles size={14} /> 直接播放
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="pointer-events-auto flex shrink-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-[11px] font-medium text-white/80 backdrop-blur"
|
||||||
|
title={status.title}
|
||||||
|
>
|
||||||
|
{status.icon}
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
||||||
import { Loader2 } from 'lucide-react'
|
import {
|
||||||
|
CircleAlert,
|
||||||
|
Hand,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
|
LockOpen,
|
||||||
|
MousePointerClick,
|
||||||
|
Rotate3d,
|
||||||
|
Smartphone,
|
||||||
|
ZoomIn,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||||
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||||
@@ -12,11 +22,14 @@ import {
|
|||||||
import { AssSubtitleStage } from '../components/AssSubtitleStage'
|
import { AssSubtitleStage } from '../components/AssSubtitleStage'
|
||||||
import { DanmakuStage } from '../components/DanmakuStage'
|
import { DanmakuStage } from '../components/DanmakuStage'
|
||||||
import { PlayerControls } from '../components/PlayerControls'
|
import { PlayerControls } from '../components/PlayerControls'
|
||||||
|
import { Vr360Stage } from '../components/Vr360Stage'
|
||||||
|
import type { Vr360Profile } from '../utils/vr360'
|
||||||
import {
|
import {
|
||||||
subtitleTextStyle,
|
subtitleTextStyle,
|
||||||
type SubtitlePosition,
|
type SubtitlePosition,
|
||||||
type SubtitleStylePreset,
|
type SubtitleStylePreset,
|
||||||
} from '../utils/subtitleDisplay'
|
} from '../utils/subtitleDisplay'
|
||||||
|
import { isPointerInLockZone } from '../utils/playerLockZone'
|
||||||
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
|
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
|
||||||
|
|
||||||
type SubtitleRenderGroup = {
|
type SubtitleRenderGroup = {
|
||||||
@@ -25,6 +38,16 @@ type SubtitleRenderGroup = {
|
|||||||
style: CSSProperties
|
style: CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锁按钮每次被唤出后停留的时间(毫秒)。
|
||||||
|
*
|
||||||
|
* 锁本身默认隐身,只在「鼠标悬停感应区」或「点击画面」之后露一会儿。解锁的瞬间
|
||||||
|
* 操作栏会立刻消失,如果锁也一起消失,用户就不知道锁在哪、也没法再锁回去。
|
||||||
|
*/
|
||||||
|
const LOCK_REVEAL_MS = 2600
|
||||||
|
/** 首次操作说明的最长停留时间:到这里仍未点「知道了」也自动收起并记录为已看过。 */
|
||||||
|
const VR_GUIDE_AUTO_DISMISS_MS = 20_000
|
||||||
|
|
||||||
function uniqueSubtitleCues(cues: SubtitleCue[]): SubtitleCue[] {
|
function uniqueSubtitleCues(cues: SubtitleCue[]): SubtitleCue[] {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const unique: SubtitleCue[] = []
|
const unique: SubtitleCue[] = []
|
||||||
@@ -133,6 +156,11 @@ function buildSubtitleRenderGroups(
|
|||||||
|
|
||||||
type PlayerVideoStageProps = {
|
type PlayerVideoStageProps = {
|
||||||
media: Media | null
|
media: Media | null
|
||||||
|
/**
|
||||||
|
* 竖屏剧场模式:舞台不再全屏居中,而是贴顶按 16:9 自适应高度;
|
||||||
|
* 弹层改为底部动作面板,避免右侧抽屉与悬浮小弹层在窄屏下被裁剪。
|
||||||
|
*/
|
||||||
|
theater?: boolean
|
||||||
/** 媒体元数据加载失败提示(非空时替代「加载中」展示)。 */
|
/** 媒体元数据加载失败提示(非空时替代「加载中」展示)。 */
|
||||||
loadError?: string
|
loadError?: string
|
||||||
playerError: string
|
playerError: string
|
||||||
@@ -162,7 +190,8 @@ type PlayerVideoStageProps = {
|
|||||||
danmakuEpisodeId: number | string | null
|
danmakuEpisodeId: number | string | null
|
||||||
danmakuSearchTrigger?: number
|
danmakuSearchTrigger?: number
|
||||||
danmakuOpen: boolean
|
danmakuOpen: boolean
|
||||||
onToggleDanmaku: () => void
|
/** 打开弹幕设置面板(搜索弹幕库、调整渲染参数)。 */
|
||||||
|
onOpenDanmaku: () => void
|
||||||
onDanmakuLoaded: (info: DanmakuLoadedInfo | null) => void
|
onDanmakuLoaded: (info: DanmakuLoadedInfo | null) => void
|
||||||
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
|
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
|
||||||
onDanmakuAlternatives: (alternatives: DanmakuAnime[]) => void
|
onDanmakuAlternatives: (alternatives: DanmakuAnime[]) => void
|
||||||
@@ -178,20 +207,39 @@ type PlayerVideoStageProps = {
|
|||||||
nextEpisodeTitle?: string
|
nextEpisodeTitle?: string
|
||||||
playlistOpen?: boolean
|
playlistOpen?: boolean
|
||||||
hasPlaylist?: boolean
|
hasPlaylist?: boolean
|
||||||
|
/** 当前条目存在多个版本(选集面板里提供版本切换,按钮提示随之变化)。 */
|
||||||
|
hasVersions?: boolean
|
||||||
onTogglePlaylist?: () => void
|
onTogglePlaylist?: () => void
|
||||||
knownDuration?: number
|
knownDuration?: number
|
||||||
streamOffset?: number
|
streamOffset?: number
|
||||||
onSeekAbsolute?: (seconds: number) => boolean
|
onSeekAbsolute?: (seconds: number) => boolean
|
||||||
qualities?: PlaybackQuality[]
|
qualities?: PlaybackQuality[]
|
||||||
|
/** 清晰度按钮上显示的文字(播放页按当前播放方式算好)。 */
|
||||||
|
qualityLabel?: string
|
||||||
selectedQuality?: string
|
selectedQuality?: string
|
||||||
onSelectQuality?: (quality: PlaybackQuality) => void
|
onSelectQuality?: (quality: PlaybackQuality) => void
|
||||||
showQuality?: boolean
|
showQuality?: boolean
|
||||||
|
/** 当前播放方式的文字描述,展示在设置面板里。 */
|
||||||
|
playbackModeLabel?: string
|
||||||
|
/** 可切换播放方式时提供;不提供则设置面板里只显示状态。 */
|
||||||
|
onTogglePlaybackMode?: () => void
|
||||||
|
/** VR 全景播放配置;非 null 表示当前处于 VR 模式。 */
|
||||||
|
vr360?: Vr360Profile | null
|
||||||
|
/** 是否由文件名/画幅自动识别出 VR 素材(用于在菜单里提示)。 */
|
||||||
|
vr360Detected?: boolean
|
||||||
|
onToggleVr360?: () => void
|
||||||
|
onVr360ProfileChange?: (profile: Vr360Profile) => void
|
||||||
|
onVr360Error?: (message: string) => void
|
||||||
|
/** 是否展示 VR 全景播放的首次操作说明(按用户只弹一次)。 */
|
||||||
|
showVr360Guide?: boolean
|
||||||
|
onDismissVr360Guide?: () => void
|
||||||
waiting?: boolean
|
waiting?: boolean
|
||||||
waitingMessage?: string
|
waitingMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlayerVideoStage({
|
export function PlayerVideoStage({
|
||||||
media,
|
media,
|
||||||
|
theater = false,
|
||||||
loadError,
|
loadError,
|
||||||
playerError,
|
playerError,
|
||||||
subs,
|
subs,
|
||||||
@@ -219,7 +267,7 @@ export function PlayerVideoStage({
|
|||||||
danmakuEpisodeId,
|
danmakuEpisodeId,
|
||||||
danmakuSearchTrigger = 0,
|
danmakuSearchTrigger = 0,
|
||||||
danmakuOpen,
|
danmakuOpen,
|
||||||
onToggleDanmaku,
|
onOpenDanmaku,
|
||||||
onDanmakuLoaded,
|
onDanmakuLoaded,
|
||||||
onDanmakuCandidates,
|
onDanmakuCandidates,
|
||||||
onDanmakuAlternatives,
|
onDanmakuAlternatives,
|
||||||
@@ -233,14 +281,25 @@ export function PlayerVideoStage({
|
|||||||
nextEpisodeTitle,
|
nextEpisodeTitle,
|
||||||
playlistOpen,
|
playlistOpen,
|
||||||
hasPlaylist,
|
hasPlaylist,
|
||||||
|
hasVersions = false,
|
||||||
onTogglePlaylist,
|
onTogglePlaylist,
|
||||||
knownDuration,
|
knownDuration,
|
||||||
streamOffset,
|
streamOffset,
|
||||||
onSeekAbsolute,
|
onSeekAbsolute,
|
||||||
qualities = [],
|
qualities = [],
|
||||||
|
qualityLabel = '',
|
||||||
selectedQuality = '',
|
selectedQuality = '',
|
||||||
onSelectQuality,
|
onSelectQuality,
|
||||||
showQuality = false,
|
showQuality = false,
|
||||||
|
playbackModeLabel = '',
|
||||||
|
onTogglePlaybackMode,
|
||||||
|
vr360 = null,
|
||||||
|
vr360Detected = false,
|
||||||
|
onToggleVr360,
|
||||||
|
onVr360ProfileChange,
|
||||||
|
onVr360Error,
|
||||||
|
showVr360Guide = false,
|
||||||
|
onDismissVr360Guide,
|
||||||
waiting = false,
|
waiting = false,
|
||||||
waitingMessage = '',
|
waitingMessage = '',
|
||||||
}: PlayerVideoStageProps) {
|
}: PlayerVideoStageProps) {
|
||||||
@@ -248,6 +307,27 @@ export function PlayerVideoStage({
|
|||||||
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
||||||
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
|
||||||
const [controlsVisible, setControlsVisible] = useState(true)
|
const [controlsVisible, setControlsVisible] = useState(true)
|
||||||
|
// 鼠标是否停在 VR 工具条/设置面板上:停住时控制栏不自动隐藏,否则设置面板会在点击前消失。
|
||||||
|
const [vrUiHovered, setVrUiHovered] = useState(false)
|
||||||
|
// 画面是否被「锁住」:锁上之后操作栏、VR 工具条、进度框全部隐藏,只剩干净的
|
||||||
|
// 画面,轻触画面也不会把它们唤回来,也不会误触发播放/暂停;想看操作栏必须先
|
||||||
|
// 点左边的锁解锁。VR 和普通播放共用这一套状态。
|
||||||
|
const [locked, setLocked] = useState(false)
|
||||||
|
// 锁按钮本身默认隐身(见下方的 lockVisible),这三个状态决定它什么时候露面:
|
||||||
|
// lockHovered —— 桌面鼠标停在感应区里(移开就收);
|
||||||
|
// lockShown —— 点了画面之后的临时显示窗口;
|
||||||
|
// lockHint —— 刚解锁,顺带显示「已解锁」提示。
|
||||||
|
const [lockHovered, setLockHovered] = useState(false)
|
||||||
|
const [lockShown, setLockShown] = useState(false)
|
||||||
|
const [lockHint, setLockHint] = useState(false)
|
||||||
|
const lockRevealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const lockVisible = lockHovered || lockShown
|
||||||
|
const vrActive = Boolean(vr360)
|
||||||
|
// VR 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
|
||||||
|
// 只按「媒体 ID」记录:切换投影方式/画幅布局不会重建画布,也就不能清掉这个
|
||||||
|
// 状态——渲染器只在挂载后上报一次就绪,清掉之后提示会一直停在「正在启动」。
|
||||||
|
const [vrReadyMediaId, setVrReadyMediaId] = useState<string | null>(null)
|
||||||
|
const vrReady = Boolean(media && vrReadyMediaId === media.id)
|
||||||
const revealControlsOnlyRef = useRef(false)
|
const revealControlsOnlyRef = useRef(false)
|
||||||
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
||||||
const [activeCues, setActiveCues] = useState<SubtitleCue[]>([])
|
const [activeCues, setActiveCues] = useState<SubtitleCue[]>([])
|
||||||
@@ -389,18 +469,117 @@ export function PlayerVideoStage({
|
|||||||
if (video.paused) void video.play()?.catch(() => undefined)
|
if (video.paused) void video.play()?.catch(() => undefined)
|
||||||
else video.pause()
|
else video.pause()
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 临时把锁按钮唤出来(移动端点击画面、以及刚解锁时)。
|
||||||
|
*
|
||||||
|
* 每次都重新计时:连续点击时不会因为第一次的计时器到点而提前隐身。
|
||||||
|
*/
|
||||||
|
const revealLock = useCallback((withUnlockHint: boolean) => {
|
||||||
|
setLockShown(true)
|
||||||
|
setLockHint(withUnlockHint)
|
||||||
|
if (lockRevealTimerRef.current) clearTimeout(lockRevealTimerRef.current)
|
||||||
|
lockRevealTimerRef.current = setTimeout(() => {
|
||||||
|
lockRevealTimerRef.current = null
|
||||||
|
setLockShown(false)
|
||||||
|
setLockHint(false)
|
||||||
|
}, LOCK_REVEAL_MS)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (lockRevealTimerRef.current) clearTimeout(lockRevealTimerRef.current)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
revealControlsOnlyRef.current = event.pointerType === 'touch' && !controlsVisible
|
// 触摸端没有悬停,靠点击画面把锁唤出来(顺带给刚上锁的用户一个解锁入口)。
|
||||||
|
if (event.pointerType === 'touch') revealLock(false)
|
||||||
|
// 锁住时轻触画面不做任何事;VR 模式下也不负责唤出控制栏(画面默认是干净的)。
|
||||||
|
if (locked) {
|
||||||
|
revealControlsOnlyRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 未锁的移动端:控制栏隐藏时首次轻触只唤出控制栏,不切换播放。
|
||||||
|
revealControlsOnlyRef.current =
|
||||||
|
!vrActive && event.pointerType === 'touch' && !controlsVisible
|
||||||
}
|
}
|
||||||
const handleStageClick = () => {
|
|
||||||
|
// 桌面端:鼠标移到画面左侧中间就把锁露出来,移开就收回去。
|
||||||
|
const handleStagePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (event.pointerType !== 'mouse') return
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect()
|
||||||
|
setLockHovered(
|
||||||
|
isPointerInLockZone(event.clientX - rect.left, event.clientY - rect.top, rect.height),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 舞台任意位置的「激活」(轻触/单击):
|
||||||
|
// 普通播放:桌面端直接切换播放/暂停;移动端控制栏隐藏时首次轻触只唤出控制栏,
|
||||||
|
// 控制栏已显示时再次轻触才切换播放/暂停。
|
||||||
|
// VR 模式:单击只负责显示/收起操作栏,不切换播放。
|
||||||
|
// 两种情况都在锁住时完全无反应——只能点锁解锁(锁由上面的 pointerdown 唤出)。
|
||||||
|
const handleSurfaceActivate = () => {
|
||||||
|
if (locked) return
|
||||||
if (revealControlsOnlyRef.current) {
|
if (revealControlsOnlyRef.current) {
|
||||||
revealControlsOnlyRef.current = false
|
revealControlsOnlyRef.current = false
|
||||||
setControlsVisible(true)
|
setControlsVisible(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
revealControlsOnlyRef.current = false
|
revealControlsOnlyRef.current = false
|
||||||
|
if (vrActive) {
|
||||||
|
setControlsVisible((visible) => !visible)
|
||||||
|
return
|
||||||
|
}
|
||||||
togglePlay()
|
togglePlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleLock = () => {
|
||||||
|
const next = !locked
|
||||||
|
setLocked(next)
|
||||||
|
// 锁上时立刻收起操作栏;解锁时也保持收起,这样画面在任何一侧都不会突然
|
||||||
|
// 弹出一整条浮层。解锁那一下顺带显示「已解锁」提示。
|
||||||
|
setControlsVisible(false)
|
||||||
|
revealLock(!next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复位锁定状态:切模式/换片时调用,避免上一次的锁挡住新的内容。 */
|
||||||
|
const resetLock = useCallback(() => {
|
||||||
|
setLocked(false)
|
||||||
|
setLockShown(false)
|
||||||
|
setLockHint(false)
|
||||||
|
setLockHovered(false)
|
||||||
|
if (lockRevealTimerRef.current) {
|
||||||
|
clearTimeout(lockRevealTimerRef.current)
|
||||||
|
lockRevealTimerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 进入 VR 全景播放时先亮一次操作栏与 VR 工具条,让用户知道入口在哪;
|
||||||
|
// 退出 VR 时清掉 VR 浮层的悬停状态,否则控制栏会以为还有浮层被悬停而不隐藏。
|
||||||
|
// 两种情况都要解除锁定:切模式是个明确的操作意图,不该被上一次的锁挡住。
|
||||||
|
useEffect(() => {
|
||||||
|
resetLock()
|
||||||
|
if (vrActive) {
|
||||||
|
setControlsVisible(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setVrUiHovered(false)
|
||||||
|
}, [resetLock, vrActive])
|
||||||
|
|
||||||
|
// 换集/换片时同样复位:新内容应该从干净的、可操作的状态开始。
|
||||||
|
useEffect(() => {
|
||||||
|
resetLock()
|
||||||
|
}, [media?.id, resetLock])
|
||||||
|
|
||||||
|
// 首次操作说明只出现一次:读完了点「知道了」会立即落库;放着不管也会在
|
||||||
|
// VR_GUIDE_AUTO_DISMISS_MS 之后自动收起并同样记录为已看过,避免每次进 VR 都弹。
|
||||||
|
const vrGuideVisible = vrActive && vrReady && showVr360Guide
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vrGuideVisible) return
|
||||||
|
const timer = setTimeout(() => onDismissVr360Guide?.(), VR_GUIDE_AUTO_DISMISS_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [vrGuideVisible, onDismissVr360Guide])
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
const stage = stageRef.current
|
const stage = stageRef.current
|
||||||
if (!stage) return
|
if (!stage) return
|
||||||
@@ -550,26 +729,49 @@ export function PlayerVideoStage({
|
|||||||
: true
|
: true
|
||||||
|
|
||||||
const assTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
const assTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||||
const wrapperStyle = videoRatio
|
// VR 模式下画面由 WebGL 画布输出,舞台窗口本身就是「镜头」,
|
||||||
|
// 不再按视频宽高比留黑边。
|
||||||
|
//
|
||||||
|
// 竖屏剧场模式(theater):舞台不再全屏居中,视频贴顶按容器宽度
|
||||||
|
// 16:9 自适应高度,下方留给标题/选集/简介内容区。stage 本体的
|
||||||
|
// 高度只包住视频区,控制栏与弹层都相对这块区域定位。
|
||||||
|
const wrapperStyle = vr360
|
||||||
? {
|
? {
|
||||||
aspectRatio: `${videoRatio}`,
|
|
||||||
width: isWiderThanStage ? '100%' : 'auto',
|
|
||||||
height: isWiderThanStage ? 'auto' : '100%',
|
|
||||||
maxWidth: '100%',
|
|
||||||
maxHeight: '100%',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
}
|
}
|
||||||
|
: theater
|
||||||
|
? {
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
}
|
||||||
|
: videoRatio
|
||||||
|
? {
|
||||||
|
aspectRatio: `${videoRatio}`,
|
||||||
|
width: isWiderThanStage ? '100%' : 'auto',
|
||||||
|
height: isWiderThanStage ? 'auto' : '100%',
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={stageRef}
|
ref={stageRef}
|
||||||
data-player-stage
|
data-player-stage
|
||||||
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
className={
|
||||||
|
theater
|
||||||
|
? 'relative flex w-full shrink-0 items-start justify-center overflow-hidden bg-black'
|
||||||
|
: 'relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black'
|
||||||
|
}
|
||||||
onPointerDown={handleStagePointerDown}
|
onPointerDown={handleStagePointerDown}
|
||||||
onClick={handleStageClick}
|
onPointerMove={handleStagePointerMove}
|
||||||
|
onMouseLeave={() => setLockHovered(false)}
|
||||||
|
onClick={handleSurfaceActivate}
|
||||||
onDoubleClick={toggleFullscreen}
|
onDoubleClick={toggleFullscreen}
|
||||||
>
|
>
|
||||||
{media ? (
|
{media ? (
|
||||||
@@ -602,6 +804,20 @@ export function PlayerVideoStage({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</video>
|
</video>
|
||||||
|
{vr360 ? (
|
||||||
|
<Vr360Stage
|
||||||
|
key={`${media.id}:vr360`}
|
||||||
|
videoRef={videoRef}
|
||||||
|
profile={vr360}
|
||||||
|
uiVisible={controlsVisible}
|
||||||
|
onSurfaceTap={handleSurfaceActivate}
|
||||||
|
onReady={() => setVrReadyMediaId(media.id)}
|
||||||
|
onError={(message) => onVr360Error?.(message)}
|
||||||
|
onProfileChange={onVr360ProfileChange}
|
||||||
|
onExitVr={onToggleVr360}
|
||||||
|
onUiHoldChange={setVrUiHovered}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<DanmakuStage
|
<DanmakuStage
|
||||||
key={media.id}
|
key={media.id}
|
||||||
media={media}
|
media={media}
|
||||||
@@ -655,6 +871,7 @@ export function PlayerVideoStage({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<PlayerControls
|
<PlayerControls
|
||||||
|
theater={theater}
|
||||||
videoRef={videoRef}
|
videoRef={videoRef}
|
||||||
volume={playerVolume}
|
volume={playerVolume}
|
||||||
onVolumeChange={onPlayerVolumeChange}
|
onVolumeChange={onPlayerVolumeChange}
|
||||||
@@ -662,8 +879,10 @@ export function PlayerVideoStage({
|
|||||||
playbackRate={playerPlaybackRate}
|
playbackRate={playerPlaybackRate}
|
||||||
onPlaybackRateChange={onPlayerPlaybackRateChange}
|
onPlaybackRateChange={onPlayerPlaybackRateChange}
|
||||||
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
|
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
|
||||||
uiVisible={controlsVisible}
|
uiVisible={controlsVisible && !locked}
|
||||||
onUiVisibleChange={setControlsVisible}
|
onUiVisibleChange={setControlsVisible}
|
||||||
|
uiHold={vrUiHovered}
|
||||||
|
uiLocked={locked}
|
||||||
subs={subs}
|
subs={subs}
|
||||||
subtitleIndex={subtitleIndex}
|
subtitleIndex={subtitleIndex}
|
||||||
onSelectSubtitle={onSelectSubtitle}
|
onSelectSubtitle={onSelectSubtitle}
|
||||||
@@ -675,7 +894,7 @@ export function PlayerVideoStage({
|
|||||||
onSubtitleStyleChange={onSubtitleStyleChange}
|
onSubtitleStyleChange={onSubtitleStyleChange}
|
||||||
danmakuOpen={danmakuOpen}
|
danmakuOpen={danmakuOpen}
|
||||||
danmakuEnabled={danmakuEnabled}
|
danmakuEnabled={danmakuEnabled}
|
||||||
onToggleDanmaku={onToggleDanmaku}
|
onOpenDanmaku={onOpenDanmaku}
|
||||||
hasPrevEpisode={hasPrevEpisode}
|
hasPrevEpisode={hasPrevEpisode}
|
||||||
hasNextEpisode={hasNextEpisode}
|
hasNextEpisode={hasNextEpisode}
|
||||||
onPrevEpisode={onPrevEpisode}
|
onPrevEpisode={onPrevEpisode}
|
||||||
@@ -684,14 +903,21 @@ export function PlayerVideoStage({
|
|||||||
nextEpisodeTitle={nextEpisodeTitle}
|
nextEpisodeTitle={nextEpisodeTitle}
|
||||||
playlistOpen={playlistOpen}
|
playlistOpen={playlistOpen}
|
||||||
hasPlaylist={hasPlaylist}
|
hasPlaylist={hasPlaylist}
|
||||||
|
hasVersions={hasVersions}
|
||||||
onTogglePlaylist={onTogglePlaylist}
|
onTogglePlaylist={onTogglePlaylist}
|
||||||
knownDuration={knownDuration}
|
knownDuration={knownDuration}
|
||||||
streamOffset={streamOffset}
|
streamOffset={streamOffset}
|
||||||
onSeekAbsolute={onSeekAbsolute}
|
onSeekAbsolute={onSeekAbsolute}
|
||||||
qualities={qualities}
|
qualities={qualities}
|
||||||
|
qualityLabel={qualityLabel}
|
||||||
selectedQuality={selectedQuality}
|
selectedQuality={selectedQuality}
|
||||||
onSelectQuality={onSelectQuality}
|
onSelectQuality={onSelectQuality}
|
||||||
showQuality={showQuality}
|
showQuality={showQuality}
|
||||||
|
playbackModeLabel={playbackModeLabel}
|
||||||
|
onTogglePlaybackMode={onTogglePlaybackMode}
|
||||||
|
vr360={vr360}
|
||||||
|
vr360Detected={vr360Detected}
|
||||||
|
onToggleVr360={onToggleVr360}
|
||||||
/>
|
/>
|
||||||
{danmakuPanel}
|
{danmakuPanel}
|
||||||
{playlistPanel}
|
{playlistPanel}
|
||||||
@@ -701,17 +927,105 @@ export function PlayerVideoStage({
|
|||||||
) : (
|
) : (
|
||||||
<p className="text-sand-500">加载中…</p>
|
<p className="text-sand-500">加载中…</p>
|
||||||
)}
|
)}
|
||||||
|
{vr360 && media && !vrReady ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-[#0d0e12]/95 px-4 py-3 text-sm text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl">
|
||||||
|
<Loader2 className="animate-spin text-rose-400" size={18} />
|
||||||
|
正在启动 VR 全景渲染…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* VR 首次操作说明:按用户只弹一次。刻意不铺全屏遮罩,用户可以一边看说明
|
||||||
|
一边拖动画面试操作。 */}
|
||||||
|
{vrGuideVisible ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center px-4">
|
||||||
|
<div className="pointer-events-auto w-[min(92vw,400px)] rounded-2xl border border-white/10 bg-[#0d0e12]/95 px-5 py-4 text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<Rotate3d size={16} className="text-rose-400" />
|
||||||
|
VR 全景播放
|
||||||
|
</p>
|
||||||
|
<ul className="mt-3 space-y-2 text-xs leading-relaxed text-white/80">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Hand size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||||
|
按住拖动画面:转动视角
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<ZoomIn size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||||
|
滚轮 / 双指捏合:缩放视野
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<MousePointerClick size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||||
|
单击画面:显示或收起操作栏(进度条、清晰度、倍速、设置)
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Lock size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||||
|
锁默认隐身:电脑把鼠标移到左侧中间、手机点一下画面就会浮现
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Smartphone size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||||
|
手机上点顶部「陀螺仪」:转动设备就能转动视角
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismissVr360Guide}
|
||||||
|
className="mt-4 w-full rounded-xl bg-rose-500 px-4 py-2 text-xs font-medium text-white transition hover:bg-rose-600"
|
||||||
|
>
|
||||||
|
知道了,不再提示
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* 锁定按钮:贴在画面左侧正中,VR 和普通播放都有,但默认隐身。
|
||||||
|
桌面端把鼠标移到左侧中间就会浮现,移开即收;移动端点一下画面唤出,并在
|
||||||
|
LOCK_REVEAL_MS 后自动隐身。
|
||||||
|
未锁时点一下就把操作栏、进度框和 VR 工具条全部藏起来,同时屏蔽画面轻触
|
||||||
|
(不会误触发播放/暂停);锁住后按钮变淡,提醒「这里是锁」。
|
||||||
|
注意:隐身时必须 pointer-events-none——否则这块隐形按钮会吃掉画面单击,
|
||||||
|
手机上想点画面唤出锁反而会误触上锁。 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
toggleLock()
|
||||||
|
}}
|
||||||
|
// 舞台在 pointerdown 上判断「是不是移动端轻触唤出控制栏」,锁按钮上的
|
||||||
|
// 触摸不应该参与那套判断,否则点锁会被当成一次画面轻触。
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
|
aria-hidden={!lockVisible}
|
||||||
|
tabIndex={lockVisible ? 0 : -1}
|
||||||
|
aria-label={locked ? '解锁画面,恢复操作栏' : '锁定画面,隐藏操作栏和进度框'}
|
||||||
|
title={locked ? '点一下解锁,恢复操作栏与进度框' : '锁上后画面轻触无效,只留这个锁'}
|
||||||
|
className={`absolute left-2.5 top-1/2 z-30 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border shadow-[0_6px_20px_rgba(0,0,0,0.45)] backdrop-blur transition-opacity duration-200 sm:left-4 ${
|
||||||
|
lockVisible ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'
|
||||||
|
} ${
|
||||||
|
locked
|
||||||
|
? // 锁住时按钮退到很淡的状态:既提示「这里是锁」,又不打扰观看。
|
||||||
|
'border-white/5 bg-black/30 text-white/40 hover:bg-black/60 hover:text-white'
|
||||||
|
: 'border-white/10 bg-black/50 text-white/85 hover:bg-black/75 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{locked ? <Lock size={18} /> : <LockOpen size={18} />}
|
||||||
|
</button>
|
||||||
|
{/* 解锁提示:操作栏此时已经藏起来了,用户点锁之后需要知道发生了什么。 */}
|
||||||
|
{lockHint ? (
|
||||||
|
<div className="pointer-events-none absolute left-16 top-1/2 z-30 -translate-y-1/2 rounded-lg border border-white/10 bg-[#0d0e12]/95 px-3 py-1.5 text-xs text-white/85 shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl sm:left-20">
|
||||||
|
已解锁,点画面可显示操作栏
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{playerError ? (
|
{playerError ? (
|
||||||
<div className="absolute bottom-20 left-1/2 w-[min(92vw,720px)] -translate-x-1/2 rounded-2xl border border-white/15 bg-black/75 px-5 py-4 text-sm text-white shadow-2xl backdrop-blur">
|
<div className="absolute bottom-20 left-1/2 flex w-[min(92vw,720px)] -translate-x-1/2 items-start gap-2.5 rounded-xl border border-rose-500/25 bg-[#0d0e12]/95 px-4 py-3 text-xs leading-relaxed text-white/85 shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl">
|
||||||
{playerError}
|
<CircleAlert size={15} className="mt-0.5 shrink-0 text-rose-400" />
|
||||||
|
<span className="min-w-0 flex-1">{playerError}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{waiting ? (
|
{waiting ? (
|
||||||
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/60 px-4 backdrop-blur-sm">
|
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/60 px-4 backdrop-blur-sm">
|
||||||
<div className="max-w-md rounded-xl border border-white/15 bg-black/85 px-6 py-5 text-center text-white shadow-2xl">
|
<div className="max-w-md rounded-2xl border border-white/10 bg-[#0d0e12]/95 px-6 py-5 text-center text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)]">
|
||||||
<Loader2 className="mx-auto mb-3 animate-spin text-rose-400" size={28} />
|
<Loader2 className="mx-auto mb-3 animate-spin text-rose-400" size={26} />
|
||||||
<p className="text-sm font-medium">{waitingMessage || '115 正在转码…'}</p>
|
<p className="text-sm font-medium">{waitingMessage || '115 正在转码…'}</p>
|
||||||
<p className="mt-1 text-xs text-white/60">转码完成后会自动播放</p>
|
<p className="mt-1 text-xs text-white/55">转码完成后会自动播放</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_LIBRARY_LIST_SORT_FIELD,
|
||||||
|
DEFAULT_LIBRARY_LIST_SORT_ORDER,
|
||||||
|
readLibraryListSort,
|
||||||
|
sortLibrariesByField,
|
||||||
|
sortLibraryPreviewsByField,
|
||||||
|
} from './libraryListSort.ts'
|
||||||
|
import type { Library } from '../types/index.ts'
|
||||||
|
|
||||||
|
function check(name: string, condition: boolean) {
|
||||||
|
if (!condition) throw new Error(`libraryListSort: ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function lib(id: string, name: string, sortOrder = 0): Library {
|
||||||
|
return { id, name, sort_order: sortOrder } as Library
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = (libs: Library[]) => libs.map((item) => item.id).join(',')
|
||||||
|
|
||||||
|
// 首页默认就是「库名倒序」,所以没写过偏好时要拿到 name/desc。
|
||||||
|
const fallback = readLibraryListSort()
|
||||||
|
check(
|
||||||
|
'default preference is name descending',
|
||||||
|
fallback.field === 'name' && fallback.order === 'desc',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'default constants stay in sync with the fallback',
|
||||||
|
DEFAULT_LIBRARY_LIST_SORT_FIELD === 'name' && DEFAULT_LIBRARY_LIST_SORT_ORDER === 'desc',
|
||||||
|
)
|
||||||
|
|
||||||
|
const alpha = [lib('a', '动画'), lib('b', '电影'), lib('c', '纪录片')]
|
||||||
|
// zh-CN 拼音升序是 电影(b) → 动画(a) → 纪录片(c)。
|
||||||
|
check(
|
||||||
|
'name descending is the default order',
|
||||||
|
ordered(sortLibrariesByField(alpha, [], 'name', 'desc')) === 'c,a,b',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'name ascending reverses it',
|
||||||
|
ordered(sortLibrariesByField(alpha, [], 'name', 'asc')) === 'b,a,c',
|
||||||
|
)
|
||||||
|
|
||||||
|
const manual = [lib('a', '动画', 2), lib('b', '电影', 0), lib('c', '纪录片', 1)]
|
||||||
|
check(
|
||||||
|
'manual order ascending follows sort_order',
|
||||||
|
ordered(sortLibrariesByField(manual, [], 'sort_order', 'asc')) === 'b,c,a',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'manual order descending flips sort_order',
|
||||||
|
ordered(sortLibrariesByField(manual, [], 'sort_order', 'desc')) === 'a,c,b',
|
||||||
|
)
|
||||||
|
|
||||||
|
// 置顶只决定分组:置顶库整体在前,组内仍然按当前字段/方向排。
|
||||||
|
check(
|
||||||
|
'pinned libraries always come first',
|
||||||
|
ordered(sortLibrariesByField(alpha, ['c'], 'name', 'desc')) === 'c,a,b',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'pinned group is sorted by the active field too',
|
||||||
|
ordered(sortLibrariesByField(alpha, ['a', 'c'], 'name', 'desc')) === 'c,a,b',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'pinned ids that no longer exist are ignored',
|
||||||
|
ordered(sortLibrariesByField(alpha, ['missing'], 'name', 'desc')) === 'c,a,b',
|
||||||
|
)
|
||||||
|
|
||||||
|
// 同名时按 sort_order 兜底,再按 id 兜底,保证顺序稳定。
|
||||||
|
const sameName = [lib('b', '动画', 1), lib('a', '动画', 1), lib('c', '动画', 0)]
|
||||||
|
check(
|
||||||
|
'equal names fall back to sort_order then id',
|
||||||
|
ordered(sortLibrariesByField(sameName, [], 'name', 'desc')) === 'c,a,b',
|
||||||
|
)
|
||||||
|
|
||||||
|
check('single library is returned as-is', sortLibrariesByField([lib('a', '动画')], [], 'name', 'desc').length === 1)
|
||||||
|
|
||||||
|
const previews = [
|
||||||
|
{ library: lib('a', '动画'), total: 1 },
|
||||||
|
{ library: lib('b', '电影'), total: 2 },
|
||||||
|
]
|
||||||
|
check(
|
||||||
|
'previews follow the library order',
|
||||||
|
sortLibraryPreviewsByField(previews, [], 'name', 'desc').map((item) => item.library.id).join(',') === 'a,b',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'previews keep their payload after sorting',
|
||||||
|
sortLibraryPreviewsByField(previews, [], 'name', 'desc')[0].total === 1,
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'previews respect pinning',
|
||||||
|
sortLibraryPreviewsByField(previews, ['b'], 'name', 'desc').map((item) => item.library.id).join(',') === 'b,a',
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('libraryListSort.test.ts ok')
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { Library } from '../types'
|
||||||
|
|
||||||
|
export type LibraryListSortField = 'name' | 'sort_order'
|
||||||
|
export type LibraryListSortOrder = 'asc' | 'desc'
|
||||||
|
|
||||||
|
export type LibraryListSortOption = {
|
||||||
|
id: LibraryListSortField
|
||||||
|
label: string
|
||||||
|
defaultOrder: LibraryListSortOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LIBRARY_LIST_SORT_OPTIONS: LibraryListSortOption[] = [
|
||||||
|
{ id: 'name', label: '库名', defaultOrder: 'desc' },
|
||||||
|
{ id: 'sort_order', label: '手动顺序', defaultOrder: 'asc' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEFAULT_LIBRARY_LIST_SORT_FIELD: LibraryListSortField = 'name'
|
||||||
|
export const DEFAULT_LIBRARY_LIST_SORT_ORDER: LibraryListSortOrder = 'desc'
|
||||||
|
|
||||||
|
const FIELD_KEY = 'mebox_libraries_sort_field'
|
||||||
|
const ORDER_KEY = 'mebox_libraries_sort_order'
|
||||||
|
|
||||||
|
export function readLibraryListSort(): { field: LibraryListSortField; order: LibraryListSortOrder } {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return { field: DEFAULT_LIBRARY_LIST_SORT_FIELD, order: DEFAULT_LIBRARY_LIST_SORT_ORDER }
|
||||||
|
}
|
||||||
|
const rawField = window.localStorage.getItem(FIELD_KEY)
|
||||||
|
const rawOrder = window.localStorage.getItem(ORDER_KEY)
|
||||||
|
const field: LibraryListSortField =
|
||||||
|
rawField === 'name' || rawField === 'sort_order' ? rawField : DEFAULT_LIBRARY_LIST_SORT_FIELD
|
||||||
|
const order: LibraryListSortOrder =
|
||||||
|
rawOrder === 'asc' || rawOrder === 'desc'
|
||||||
|
? rawOrder
|
||||||
|
: field === 'name'
|
||||||
|
? DEFAULT_LIBRARY_LIST_SORT_ORDER
|
||||||
|
: 'asc'
|
||||||
|
return { field, order }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeLibraryListSort(field: LibraryListSortField, order: LibraryListSortOrder): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem(FIELD_KEY, field)
|
||||||
|
window.localStorage.setItem(ORDER_KEY, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareNames(a: string, b: string, order: LibraryListSortOrder): number {
|
||||||
|
const cmp = a.localeCompare(b, 'zh-CN', { numeric: true, sensitivity: 'base' })
|
||||||
|
return order === 'asc' ? cmp : -cmp
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSortOrder(a: number, b: number, order: LibraryListSortOrder): number {
|
||||||
|
const cmp = a - b
|
||||||
|
return order === 'asc' ? cmp : -cmp
|
||||||
|
}
|
||||||
|
|
||||||
|
function librarySortKey(library: Library): { name: string; sortOrder: number } {
|
||||||
|
return {
|
||||||
|
name: (library.name || '').trim(),
|
||||||
|
sortOrder: library.sort_order ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareLibraries(a: Library, b: Library, field: LibraryListSortField, order: LibraryListSortOrder): number {
|
||||||
|
const ka = librarySortKey(a)
|
||||||
|
const kb = librarySortKey(b)
|
||||||
|
if (field === 'name') {
|
||||||
|
const byName = compareNames(ka.name, kb.name, order)
|
||||||
|
if (byName !== 0) return byName
|
||||||
|
return compareSortOrder(ka.sortOrder, kb.sortOrder, 'asc') || a.id.localeCompare(b.id)
|
||||||
|
}
|
||||||
|
const byOrder = compareSortOrder(ka.sortOrder, kb.sortOrder, order)
|
||||||
|
if (byOrder !== 0) return byOrder
|
||||||
|
return compareNames(ka.name, kb.name, 'asc') || a.id.localeCompare(b.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 置顶组始终在前;组内按 field/order 排序。
|
||||||
|
* pinnedIds 的相对顺序只决定「是否置顶」,组内不再保留置顶拖拽序。
|
||||||
|
*
|
||||||
|
* 首页与 /libraries 共用这份排序,保证两处的媒体库顺序一致。
|
||||||
|
*/
|
||||||
|
export function sortLibrariesByField(
|
||||||
|
libraries: Library[],
|
||||||
|
pinnedIds: string[],
|
||||||
|
field: LibraryListSortField,
|
||||||
|
order: LibraryListSortOrder,
|
||||||
|
): Library[] {
|
||||||
|
if (libraries.length < 2) return libraries
|
||||||
|
|
||||||
|
const pinnedRank = new Map(pinnedIds.map((id, index) => [id, index]))
|
||||||
|
const pinned: Library[] = []
|
||||||
|
const rest: Library[] = []
|
||||||
|
for (const library of libraries) {
|
||||||
|
if (pinnedRank.has(library.id)) pinned.push(library)
|
||||||
|
else rest.push(library)
|
||||||
|
}
|
||||||
|
|
||||||
|
const byField = (a: Library, b: Library) => compareLibraries(a, b, field, order)
|
||||||
|
pinned.sort(byField)
|
||||||
|
rest.sort(byField)
|
||||||
|
return [...pinned, ...rest]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览列表按库排序:先排库,再按排好的库顺序取回对应预览。 */
|
||||||
|
export function sortLibraryPreviewsByField<T extends { library: Library }>(
|
||||||
|
items: T[],
|
||||||
|
pinnedIds: string[],
|
||||||
|
field: LibraryListSortField,
|
||||||
|
order: LibraryListSortOrder,
|
||||||
|
): T[] {
|
||||||
|
if (items.length < 2) return items
|
||||||
|
const ordered = sortLibrariesByField(
|
||||||
|
items.map((item) => item.library),
|
||||||
|
pinnedIds,
|
||||||
|
field,
|
||||||
|
order,
|
||||||
|
)
|
||||||
|
const byId = new Map(items.map((item) => [item.library.id, item]))
|
||||||
|
return ordered
|
||||||
|
.map((library) => byId.get(library.id))
|
||||||
|
.filter((item): item is T => item !== undefined)
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
ALL_TAG_ID,
|
ALL_TAG_ID,
|
||||||
|
assignLibrariesToTag,
|
||||||
|
attachLibrariesToTag,
|
||||||
attachLibraryToTag,
|
attachLibraryToTag,
|
||||||
buildLibraryTagTabs,
|
buildLibraryTagTabs,
|
||||||
dedupeLibraryTags,
|
dedupeLibraryTags,
|
||||||
|
detachLibrariesFromTag,
|
||||||
detachLibraryFromTag,
|
detachLibraryFromTag,
|
||||||
filterLibrariesByTag,
|
filterLibrariesByTag,
|
||||||
|
filterLibrariesForTagging,
|
||||||
normalizeLibraryTags,
|
normalizeLibraryTags,
|
||||||
normalizeTagName,
|
normalizeTagName,
|
||||||
|
reorderLibraryTags,
|
||||||
resolveSelectedTagId,
|
resolveSelectedTagId,
|
||||||
tagNameError,
|
tagNameError,
|
||||||
} from './libraryTags.ts'
|
} from './libraryTags.ts'
|
||||||
@@ -45,10 +50,10 @@ check('tag tab keeps creation order', tabs[1].name === '动画' && tabs[2].name
|
|||||||
check('tag count ignores unavailable libraries', tabs[1].count === 1)
|
check('tag count ignores unavailable libraries', tabs[1].count === 1)
|
||||||
|
|
||||||
check(
|
check(
|
||||||
'filtering keeps tag order and drops unknown ids',
|
'filtering drops unknown ids and keeps input order',
|
||||||
filterLibrariesByTag(libs, [{ name: '动画', library_ids: ['lib-c', 'missing', 'lib-a'] }], '动画')
|
filterLibrariesByTag(libs, [{ name: '动画', library_ids: ['lib-c', 'missing', 'lib-a'] }], '动画')
|
||||||
.map((lib) => lib.id)
|
.map((lib) => lib.id)
|
||||||
.join(',') === 'lib-c,lib-a',
|
.join(',') === 'lib-a,lib-c',
|
||||||
)
|
)
|
||||||
check('全部 returns every library in original order', filterLibrariesByTag(libs, [], ALL_TAG_ID).length === 3)
|
check('全部 returns every library in original order', filterLibrariesByTag(libs, [], ALL_TAG_ID).length === 3)
|
||||||
check('unknown tag yields no libraries', filterLibrariesByTag(libs, [], 'nope').length === 0)
|
check('unknown tag yields no libraries', filterLibrariesByTag(libs, [], 'nope').length === 0)
|
||||||
@@ -84,6 +89,46 @@ const detached = detachLibraryFromTag(
|
|||||||
)
|
)
|
||||||
check('detach removes only the requested library', detached[0].library_ids.join(',') === 'b')
|
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('blank name is rejected', tagNameError(' ', []) !== '')
|
||||||
check('duplicate name is rejected', tagNameError('动画', [{ name: '动画', library_ids: [] }]) !== '')
|
check('duplicate name is rejected', tagNameError('动画', [{ name: '动画', library_ids: [] }]) !== '')
|
||||||
check(
|
check(
|
||||||
@@ -91,4 +136,31 @@ check(
|
|||||||
tagNameError('动画', [{ name: '动画', library_ids: [] }], '动画') === '',
|
tagNameError('动画', [{ name: '动画', library_ids: [] }], '动画') === '',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const reorderable = [
|
||||||
|
{ name: '动画', library_ids: [] },
|
||||||
|
{ name: '电影', library_ids: [] },
|
||||||
|
{ name: '音乐', library_ids: [] },
|
||||||
|
]
|
||||||
|
check(
|
||||||
|
'reorder moves a tag to the requested slot',
|
||||||
|
reorderLibraryTags(reorderable, ['电影', '动画', '音乐']).map((tag) => tag.name).join(',') === '电影,动画,音乐',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'reorder can move a tag to the front',
|
||||||
|
reorderLibraryTags(reorderable, ['音乐', '动画', '电影']).map((tag) => tag.name).join(',') === '音乐,动画,电影',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'names missing from the order keep relative order at the end',
|
||||||
|
reorderLibraryTags(reorderable, ['电影']).map((tag) => tag.name).join(',') === '电影,动画,音乐',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'empty order leaves tags untouched',
|
||||||
|
reorderLibraryTags(reorderable, []).map((tag) => tag.name).join(',') === '动画,电影,音乐',
|
||||||
|
)
|
||||||
|
check('reorder ignores extra unknown names', reorderLibraryTags(reorderable, ['不存在', '音乐']).map((tag) => tag.name).join(',') === '音乐,动画,电影')
|
||||||
|
check(
|
||||||
|
'reordering with fewer than two tags is a no-op',
|
||||||
|
reorderLibraryTags([{ name: '动画', library_ids: [] }], []).map((tag) => tag.name).join(',') === '动画',
|
||||||
|
)
|
||||||
|
|
||||||
console.log('libraryTags.test.ts ok')
|
console.log('libraryTags.test.ts ok')
|
||||||
|
|||||||
@@ -164,8 +164,9 @@ export function isLibraryInTag(tags: LibraryTag[], tagId: string, libraryId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按标签过滤媒体库:`tagId` 为「全部」时原样返回(保持原有置顶/手动排序),
|
* 按标签过滤媒体库:`tagId` 为「全部」时原样返回。
|
||||||
* 否则只保留该标签下的库,并保持标签里记录的顺序。
|
* 只做成员筛选、保持输入顺序,展示顺序(置顶、排序下拉等)由调用方决定;
|
||||||
|
* 标签里记录的 library_ids 顺序只表示归属,不作为展示排序。
|
||||||
*/
|
*/
|
||||||
export function filterLibrariesByTag<T extends { id: string }>(
|
export function filterLibrariesByTag<T extends { id: string }>(
|
||||||
libraries: T[],
|
libraries: T[],
|
||||||
@@ -173,15 +174,9 @@ export function filterLibrariesByTag<T extends { id: string }>(
|
|||||||
tagId: string,
|
tagId: string,
|
||||||
): T[] {
|
): T[] {
|
||||||
if (!tagId || tagId === ALL_TAG_ID) return libraries
|
if (!tagId || tagId === ALL_TAG_ID) return libraries
|
||||||
const ids = tagLibraryIds(tags, tagId)
|
const ids = new Set(tagLibraryIds(tags, tagId))
|
||||||
if (ids.length === 0) return []
|
if (ids.size === 0) return []
|
||||||
const byId = new Map(libraries.map((lib) => [lib.id, lib]))
|
return libraries.filter((lib) => ids.has(lib.id))
|
||||||
const out: T[] = []
|
|
||||||
for (const id of ids) {
|
|
||||||
const lib = byId.get(id)
|
|
||||||
if (lib) out.push(lib)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 生成标签栏模型:首项固定为「全部」,其后按标签顺序追加。 */
|
/** 生成标签栏模型:首项固定为「全部」,其后按标签顺序追加。 */
|
||||||
@@ -202,6 +197,22 @@ export function buildLibraryTagTabs(libraries: Library[], tags: LibraryTag[]): L
|
|||||||
return tabs
|
return tabs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按给定标签名顺序重排标签栏:`orderedNames` 是期望的标签名顺序(通常来自拖拽后的顺序)。
|
||||||
|
* 未出现在顺序里的标签(例如新建、旧数据并发写入)保持原相对顺序,追加到末尾,绝不丢失。
|
||||||
|
*/
|
||||||
|
export function reorderLibraryTags(tags: LibraryTag[], orderedNames: string[]): LibraryTag[] {
|
||||||
|
if (tags.length < 2) return tags
|
||||||
|
const rank = new Map(orderedNames.map((name, index) => [name, index]))
|
||||||
|
const ranked: LibraryTag[] = []
|
||||||
|
const rest: LibraryTag[] = []
|
||||||
|
for (const tag of tags) {
|
||||||
|
;(rank.has(tag.name) ? ranked : rest).push(tag)
|
||||||
|
}
|
||||||
|
ranked.sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0))
|
||||||
|
return [...ranked, ...rest]
|
||||||
|
}
|
||||||
|
|
||||||
/** 选中的标签是否仍存在;被删除或被过滤掉时回落到「全部」。 */
|
/** 选中的标签是否仍存在;被删除或被过滤掉时回落到「全部」。 */
|
||||||
export function resolveSelectedTagId(tags: LibraryTag[], selectedId: string): string {
|
export function resolveSelectedTagId(tags: LibraryTag[], selectedId: string): string {
|
||||||
if (!selectedId || selectedId === ALL_TAG_ID) return ALL_TAG_ID
|
if (!selectedId || selectedId === ALL_TAG_ID) return ALL_TAG_ID
|
||||||
@@ -232,3 +243,67 @@ export function detachLibraryFromTag(tags: LibraryTag[], libraryId: string, tagN
|
|||||||
tag.name === tagName ? { ...tag, library_ids: tag.library_ids.filter((id) => id !== libraryId) } : 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
isStrmMedia,
|
||||||
|
mediaVersionFileName,
|
||||||
|
mediaVersionLabel,
|
||||||
|
mediaVersionMatches,
|
||||||
|
mediaVersionSourceLabel,
|
||||||
|
} 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'))
|
||||||
|
|
||||||
|
// 折叠后的选集行 id 是最优版本;播放中的可能是组内另一个版本。
|
||||||
|
const local4k = media({ id: 'v-4k', path: '/media/Inception.2010.2160p.mkv', height: 2160 })
|
||||||
|
const local1080 = media({ id: 'v-1080', path: '/media/Inception.2010.1080p.mkv', height: 1080 })
|
||||||
|
const cloudRow = media({ id: 'v-cloud', path: '/strm/Inception.2010.mkv.strm', strm_url: '/api/strm/play/cloud115/video.mkv?pickcode=a' })
|
||||||
|
const groupedRow = media({ id: 'v-4k', versions: [local4k, local1080, cloudRow] })
|
||||||
|
|
||||||
|
check('matches own id', mediaVersionMatches(groupedRow, 'v-4k'))
|
||||||
|
check('matches nested version id', mediaVersionMatches(groupedRow, 'v-1080'))
|
||||||
|
check('rejects unrelated id', !mediaVersionMatches(groupedRow, 'v-other'))
|
||||||
|
check('rejects empty id', !mediaVersionMatches(groupedRow, ''))
|
||||||
|
|
||||||
|
check('strm is cloud source', mediaVersionSourceLabel(cloudRow) === '云端直链')
|
||||||
|
check('mkv is local source', mediaVersionSourceLabel(local1080) === '本地文件')
|
||||||
|
check('strm filename drops outer extension', mediaVersionFileName(cloudRow) === 'Inception.2010.mkv')
|
||||||
|
check('missing path has no filename', mediaVersionFileName(media({ path: '' })) === '')
|
||||||
|
check('isStrmMedia detects strm url', isStrmMedia(cloudRow))
|
||||||
|
|
||||||
|
console.log('mediaVersion.test.ts: ok')
|
||||||
@@ -107,6 +107,15 @@ function cleanFallbackFilename(raw: string): string {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isIndistinctVersionLabel(parts: string[]): boolean {
|
||||||
|
if (parts.length === 0) return true
|
||||||
|
return parts.every((part) => {
|
||||||
|
const upper = part.toUpperCase()
|
||||||
|
if (upper === '云端直链' || upper === 'STRM') return true
|
||||||
|
return VIDEO_EXTENSIONS.has(part.toLowerCase())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积 / 规格 / 来源标签。 */
|
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积 / 规格 / 来源标签。 */
|
||||||
export function mediaVersionLabel(media: Media): string {
|
export function mediaVersionLabel(media: Media): string {
|
||||||
const isStrm = isStrmMedia(media)
|
const isStrm = isStrmMedia(media)
|
||||||
@@ -153,11 +162,11 @@ export function mediaVersionLabel(media: Media): string {
|
|||||||
parts.push('云端直链')
|
parts.push('云端直链')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parts.length > 0) {
|
// 只有容器 /「云端直链」时无法区分多分片,回退文件名(sivr-270-1 / …part1)
|
||||||
|
if (parts.length > 0 && !isIndistinctVersionLabel(parts)) {
|
||||||
return parts.join(' · ')
|
return parts.join(' · ')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兜底:使用清理后的文件名
|
|
||||||
const cleaned = cleanFallbackFilename(media.path || '')
|
const cleaned = cleanFallbackFilename(media.path || '')
|
||||||
return cleaned || media.title || media.id
|
return cleaned || media.title || media.id
|
||||||
}
|
}
|
||||||
@@ -167,3 +176,30 @@ export function mediaVersionsOf(media: Media | null | undefined): Media[] {
|
|||||||
if (media.versions && media.versions.length > 1) return media.versions
|
if (media.versions && media.versions.length > 1) return media.versions
|
||||||
return [media]
|
return [media]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* media 自身或它的任一版本是否命中 id。
|
||||||
|
*
|
||||||
|
* 选集列表按「版本组」折叠后,行的 id 是该组首个(最优)版本;用户切到
|
||||||
|
* 组内其它版本时播放中的 id 与行 id 不再相等,必须连带比对 versions,
|
||||||
|
* 否则当前集高亮、上一集/下一集都会失效。
|
||||||
|
*/
|
||||||
|
export function mediaVersionMatches(media: Media | null | undefined, id: string): boolean {
|
||||||
|
if (!media || !id) return false
|
||||||
|
if (media.id === id) return true
|
||||||
|
return Boolean(media.versions?.some((version) => version.id === id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 版本来源:云端(STRM / 网盘直链)或本地文件。 */
|
||||||
|
export function mediaVersionSourceLabel(media: Media): string {
|
||||||
|
return isStrmMedia(media) ? '云端直链' : '本地文件'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 版本文件名(去掉目录,并剥掉 .strm 外层扩展名),用作版本选项的次要说明。 */
|
||||||
|
export function mediaVersionFileName(media: Media): string {
|
||||||
|
const path = (media.path || '').replace(/\\/g, '/').trim()
|
||||||
|
if (!path) return ''
|
||||||
|
const base = path.split('/').filter(Boolean).pop() || ''
|
||||||
|
if (!base) return ''
|
||||||
|
return base.replace(/\.strm$/i, '')
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,31 +67,3 @@ export function togglePinnedLibraryId(current: string[], id: string): string[] {
|
|||||||
export function isLibraryPinned(id: string, pinnedIds: string[]): boolean {
|
export function isLibraryPinned(id: string, pinnedIds: string[]): boolean {
|
||||||
return pinnedIds.includes(id)
|
return pinnedIds.includes(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortByPinnedIds<T extends { id: string }>(items: T[], pinnedIds: string[]): T[] {
|
|
||||||
if (pinnedIds.length === 0) return items
|
|
||||||
const rank = new Map(pinnedIds.map((pinnedId, index) => [pinnedId, index]))
|
|
||||||
return [...items].sort((a, b) => {
|
|
||||||
const aRank = rank.get(a.id)
|
|
||||||
const bRank = rank.get(b.id)
|
|
||||||
const aPinned = aRank !== undefined
|
|
||||||
const bPinned = bRank !== undefined
|
|
||||||
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
|
||||||
if (aPinned && bPinned) return aRank - bRank
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sortLibraryPreviews<T extends { library: { id: string } }>(items: T[], pinnedIds: string[]): T[] {
|
|
||||||
if (pinnedIds.length === 0) return items
|
|
||||||
const rank = new Map(pinnedIds.map((id, index) => [id, index]))
|
|
||||||
return [...items].sort((a, b) => {
|
|
||||||
const aRank = rank.get(a.library.id)
|
|
||||||
const bRank = rank.get(b.library.id)
|
|
||||||
const aPinned = aRank !== undefined
|
|
||||||
const bPinned = bRank !== undefined
|
|
||||||
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
|
||||||
if (aPinned && bPinned) return aRank - bRank
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
LOCK_ZONE_HEIGHT,
|
||||||
|
LOCK_ZONE_WIDTH,
|
||||||
|
isPointerInLockZone,
|
||||||
|
} from './playerLockZone.ts'
|
||||||
|
|
||||||
|
function check(name: string, condition: boolean) {
|
||||||
|
if (!condition) throw new Error(`playerLockZone: ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const H = 800
|
||||||
|
const MID = H / 2
|
||||||
|
|
||||||
|
// 命中:紧贴左边缘、垂直正中。
|
||||||
|
check('left edge at vertical center hits', isPointerInLockZone(0, MID, H))
|
||||||
|
check('inside the zone near the left hits', isPointerInLockZone(LOCK_ZONE_WIDTH - 1, MID, H))
|
||||||
|
check('top of the zone still hits', isPointerInLockZone(10, MID - LOCK_ZONE_HEIGHT / 2, H))
|
||||||
|
check('bottom of the zone still hits', isPointerInLockZone(10, MID + LOCK_ZONE_HEIGHT / 2, H))
|
||||||
|
|
||||||
|
// 未命中:右侧超出宽度、上下超出高度、中心之外。
|
||||||
|
check('beyond the width misses', !isPointerInLockZone(LOCK_ZONE_WIDTH + 1, MID, H))
|
||||||
|
check('just past the width edge misses', !isPointerInLockZone(LOCK_ZONE_WIDTH + 0.5, MID, H))
|
||||||
|
check('above the zone misses', !isPointerInLockZone(10, MID - LOCK_ZONE_HEIGHT / 2 - 1, H))
|
||||||
|
check('below the zone misses', !isPointerInLockZone(10, MID + LOCK_ZONE_HEIGHT / 2 + 1, H))
|
||||||
|
check('negative x misses', !isPointerInLockZone(-1, MID, H))
|
||||||
|
check('top-left corner misses', !isPointerInLockZone(0, 0, H))
|
||||||
|
check('right edge at center misses', !isPointerInLockZone(1200, MID, H))
|
||||||
|
|
||||||
|
// 不同画面高度下感应区跟着中心移动。
|
||||||
|
check('tracks center on a short stage', isPointerInLockZone(10, 300, 600))
|
||||||
|
check('short stage: off-center misses', !isPointerInLockZone(10, MID, 600))
|
||||||
|
|
||||||
|
// 脏数据一律不命中,避免锁在奇怪的位置冒出来。
|
||||||
|
check('NaN x misses', !isPointerInLockZone(Number.NaN, MID, H))
|
||||||
|
check('NaN y misses', !isPointerInLockZone(10, Number.NaN, H))
|
||||||
|
check('zero stage height misses', !isPointerInLockZone(10, 0, 0))
|
||||||
|
check('negative stage height misses', !isPointerInLockZone(10, 0, -100))
|
||||||
|
check('NaN stage height misses', !isPointerInLockZone(10, 0, Number.NaN))
|
||||||
|
|
||||||
|
console.log('playerLockZone.test.ts ok')
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// 播放器「锁」按钮的几何判定。
|
||||||
|
//
|
||||||
|
// 锁按钮默认隐身:桌面端靠鼠标移到画面左侧中间把它唤出来,移动端靠点击画面。
|
||||||
|
// 这里用坐标判断鼠标是否落在感应区里,而不是在画面上摆一个透明的热区元素——
|
||||||
|
// 隐形热区会吃掉全景拖动的手势,也会挡住画面本身的单击。
|
||||||
|
|
||||||
|
/** 感应区宽度(像素),自画面左边缘向右展开。 */
|
||||||
|
export const LOCK_ZONE_WIDTH = 72
|
||||||
|
/** 感应区高度(像素),以画面垂直中心为基准上下各展开一半。 */
|
||||||
|
export const LOCK_ZONE_HEIGHT = 88
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断鼠标位置(相对画面左上角)是否落在锁按钮的感应区内。
|
||||||
|
*
|
||||||
|
* 参数异常(NaN、画面高度为 0 等)一律返回 false:宁可锁不出现,也不要因为
|
||||||
|
* 拿到脏数据让它在莫名其妙的位置冒出来。
|
||||||
|
*/
|
||||||
|
export function isPointerInLockZone(
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number,
|
||||||
|
stageHeight: number,
|
||||||
|
): boolean {
|
||||||
|
if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) return false
|
||||||
|
if (!Number.isFinite(stageHeight) || stageHeight <= 0) return false
|
||||||
|
if (offsetX < 0 || offsetX > LOCK_ZONE_WIDTH) return false
|
||||||
|
return Math.abs(offsetY - stageHeight / 2) <= LOCK_ZONE_HEIGHT / 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import {
|
||||||
|
detectVr360Profile,
|
||||||
|
normalizeVr360Profile,
|
||||||
|
sameVr360Profile,
|
||||||
|
vr360FrameUv,
|
||||||
|
type Vr360Profile,
|
||||||
|
} from './vr360.ts'
|
||||||
|
import {
|
||||||
|
applyDirection,
|
||||||
|
applyViewOffset,
|
||||||
|
cameraForward,
|
||||||
|
cameraFromDeviceOrientation,
|
||||||
|
cameraFromYawPitch,
|
||||||
|
forwardPitch,
|
||||||
|
forwardYaw,
|
||||||
|
multiply3,
|
||||||
|
perspectiveMatrix,
|
||||||
|
rotationY,
|
||||||
|
rotationZ,
|
||||||
|
transpose3,
|
||||||
|
viewMatrixFromCamera,
|
||||||
|
} from './vr360Math.ts'
|
||||||
|
|
||||||
|
function check(name: string, condition: boolean) {
|
||||||
|
if (!condition) throw new Error(`vr360: ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(a: number, b: number, tolerance = 1e-6): boolean {
|
||||||
|
return Math.abs(a - b) <= tolerance
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAll(a: number[], b: number[], tolerance = 1e-6): boolean {
|
||||||
|
return a.length === b.length && a.every((value, index) => close(value, b[index], tolerance))
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileOf(profile: Vr360Profile): string {
|
||||||
|
return `${profile.projection}/${profile.stereo}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 命名/画幅识别 ----------
|
||||||
|
|
||||||
|
const plain = detectVr360Profile({ title: '普通电影', path: '/media/电影/movie.mkv', width: 1920, height: 1080 })
|
||||||
|
check('普通素材不自动进入 VR', !plain.confident)
|
||||||
|
check('普通素材默认 360 单眼', profileOf(plain.profile) === 'equirect360/mono')
|
||||||
|
|
||||||
|
const pano360 = detectVr360Profile({
|
||||||
|
title: '全景演示',
|
||||||
|
path: '/media/360/equirect_demo.mp4',
|
||||||
|
width: 3840,
|
||||||
|
height: 1920,
|
||||||
|
})
|
||||||
|
check('360 关键词可识别', pano360.confident && pano360.profile.projection === 'equirect360')
|
||||||
|
check('2:1 的 360 保持单眼', pano360.profile.stereo === 'mono')
|
||||||
|
|
||||||
|
const vr360Sbs = detectVr360Profile({ path: '/media/vr/vr360-sbs-8k.mp4', width: 7680, height: 1920 })
|
||||||
|
check('4:1 画幅识别为左右并排', vr360Sbs.profile.stereo === 'sbs')
|
||||||
|
|
||||||
|
const ou360 = detectVr360Profile({ path: '/media/vr/pano360-ou.mp4', width: 3840, height: 3840 })
|
||||||
|
check('1:1 的 360 识别为上下并排', ou360.profile.stereo === 'ou')
|
||||||
|
|
||||||
|
const fisheye = detectVr360Profile({ path: '/media/vr/abc-fisheye-180.mp4', width: 1920, height: 1920 })
|
||||||
|
check('鱼眼关键词优先于其它信号', fisheye.profile.projection === 'fisheye180')
|
||||||
|
|
||||||
|
const vr180Sbs = detectVr360Profile({
|
||||||
|
path: '/media/云下载/sivr-270/sivr-270-1.mp4',
|
||||||
|
width: 3840,
|
||||||
|
height: 1920,
|
||||||
|
})
|
||||||
|
check('编号里的 vr 可识别为 VR180', vr180Sbs.confident && vr180Sbs.profile.projection === 'equirect180')
|
||||||
|
check('VR180 的 2:1 画幅为左右并排', vr180Sbs.profile.stereo === 'sbs')
|
||||||
|
|
||||||
|
const vr180Mono = detectVr360Profile({ path: '/media/vr/vr180-mono.mp4', width: 1920, height: 1920 })
|
||||||
|
check('VR180 的 1:1 画幅为单眼', vr180Mono.profile.stereo === 'mono')
|
||||||
|
|
||||||
|
const vr180Ou = detectVr360Profile({ path: '/media/vr/vr180-tb.mp4', width: 1920, height: 3840 })
|
||||||
|
check('VR180 的 1:2 画幅为上下并排', vr180Ou.profile.stereo === 'ou')
|
||||||
|
|
||||||
|
const topBottom = detectVr360Profile({ path: '/media/vr/vr360-top-bottom.mp4' })
|
||||||
|
check('top-bottom 关键词为上下并排', topBottom.profile.stereo === 'ou')
|
||||||
|
|
||||||
|
// 回归:真实媒体库里出现过的误判(单词里含 vr、分辨率写法含 360)。
|
||||||
|
const valvrave = detectVr360Profile({
|
||||||
|
title: '革命机Valvrave',
|
||||||
|
path: '/media/anime/2013/[革命机] Valvrave the Liberator (2013)/01.mkv',
|
||||||
|
})
|
||||||
|
check('单词里的 vr 不误判(Valvrave)', !valvrave.confident)
|
||||||
|
check('误判时仍给出可用的默认配置', profileOf(valvrave.profile) === 'equirect360/mono')
|
||||||
|
|
||||||
|
const resolution360p = detectVr360Profile({ path: '/media/movie/movie-360p.mp4', width: 640, height: 360 })
|
||||||
|
check('360p 分辨率写法不误判', !resolution360p.confident)
|
||||||
|
|
||||||
|
const titleWithVrWord = detectVr360Profile({ title: '某动画 VR 特别篇', path: '/media/anime/01.mkv' })
|
||||||
|
check('标题里的独立 VR 词元可识别', titleWithVrWord.confident)
|
||||||
|
|
||||||
|
const ipvr = detectVr360Profile({ path: '/media/云下载/ipvr00192pl/ipvr00192.part2.mp4' })
|
||||||
|
check('编号形态 ipvr00192 可识别', ipvr.confident && ipvr.profile.projection === 'equirect180')
|
||||||
|
|
||||||
|
// ---------- 立体裁切与偏好归一化 ----------
|
||||||
|
|
||||||
|
check('单眼不裁切', closeAll(vr360FrameUv('mono').scale, [1, 1]))
|
||||||
|
check('左右并排取左半张', closeAll(vr360FrameUv('sbs').scale, [0.5, 1]))
|
||||||
|
check('上下并排取上半张', closeAll(vr360FrameUv('ou').scale, [1, 0.5]))
|
||||||
|
check('并排画面不做偏移', closeAll(vr360FrameUv('sbs').offset, [0, 0]))
|
||||||
|
|
||||||
|
check('未知投影回退到 360 全景', normalizeVr360Profile({ projection: 'nope', stereo: 'nope' }).projection === 'equirect360')
|
||||||
|
check('未知布局回退到单眼', normalizeVr360Profile({}).stereo === 'mono')
|
||||||
|
check(
|
||||||
|
'相同配置判定相等',
|
||||||
|
sameVr360Profile({ projection: 'equirect180', stereo: 'sbs' }, { projection: 'equirect180', stereo: 'sbs' }),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'不同配置判定不等',
|
||||||
|
!sameVr360Profile({ projection: 'equirect180', stereo: 'sbs' }, { projection: 'equirect180', stereo: 'ou' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- 相机姿态与设备陀螺仪 ----------
|
||||||
|
|
||||||
|
const identityForward = cameraForward(new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]))
|
||||||
|
check('单位姿态朝向地面(设备平放)', closeAll([...identityForward], [0, 0, -1]))
|
||||||
|
|
||||||
|
const level = cameraFromYawPitch(0, 0)
|
||||||
|
check('默认视角水平看向正前方', closeAll([...cameraForward(level)], [0, 1, 0]))
|
||||||
|
|
||||||
|
const east = cameraFromYawPitch(Math.PI / 2, 0)
|
||||||
|
check('方位角 90° 转向正东', closeAll([...cameraForward(east)], [1, 0, 0], 1e-6))
|
||||||
|
|
||||||
|
const up = cameraFromYawPitch(0, Math.PI / 2)
|
||||||
|
check('俯仰角 90° 抬头看天', closeAll([...cameraForward(up)], [0, 0, 1], 1e-6))
|
||||||
|
|
||||||
|
const northEast = cameraFromYawPitch(Math.PI / 4, 0)
|
||||||
|
check('方位角 45° 落在东北方向', close(forwardYaw(cameraForward(northEast)), Math.PI / 4))
|
||||||
|
|
||||||
|
const tilted = cameraFromYawPitch(0.3, -0.4)
|
||||||
|
check('朝向角可反解出方位角', close(forwardYaw(cameraForward(tilted)), 0.3, 1e-6))
|
||||||
|
check('朝向角可反解出俯仰角', close(forwardPitch(cameraForward(tilted)), -0.4, 1e-6))
|
||||||
|
|
||||||
|
check(
|
||||||
|
'旋转矩阵转置等于反向旋转',
|
||||||
|
closeAll([...transpose3(rotationY(0.7))], [...rotationY(-0.7)], 1e-6),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
'正反旋转相乘为单位矩阵',
|
||||||
|
closeAll(
|
||||||
|
[...multiply3(rotationZ(1.1), rotationZ(-1.1))],
|
||||||
|
[1, 0, 0, 0, 1, 0, 0, 0, 1],
|
||||||
|
1e-6,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 视图矩阵 = 相机姿态的逆(纯旋转)
|
||||||
|
const view = viewMatrixFromCamera(east)
|
||||||
|
check('视图矩阵为相机矩阵的转置', closeAll([...view], [...transpose3(east)], 1e-6))
|
||||||
|
|
||||||
|
// 设备姿态约定(与 W3C 规范一致):相机看向设备背面,
|
||||||
|
// 因此设备平放时(alpha 任意、beta = 0)视线朝下。
|
||||||
|
const flat = cameraFromDeviceOrientation(Math.PI / 3, 0, 0, 0)
|
||||||
|
check('设备平放时看向地面', closeAll([...cameraForward(flat)], [0, 0, -1], 1e-6))
|
||||||
|
|
||||||
|
// 竖持手机(beta = 90°)时视线水平
|
||||||
|
const portrait = cameraFromDeviceOrientation(0, Math.PI / 2, 0, 0)
|
||||||
|
check('竖持手机时视线水平', close(cameraForward(portrait)[2], 0, 1e-6))
|
||||||
|
check('竖持手机时视线朝北', close(forwardYaw(cameraForward(portrait)), 0, 1e-6))
|
||||||
|
|
||||||
|
// 横屏(屏幕旋转 90°)时相机「上方向」跟随屏幕
|
||||||
|
const landscape = cameraFromDeviceOrientation(0, Math.PI / 2, 0, Math.PI / 2)
|
||||||
|
check('横屏时画面随屏幕转向', closeAll([...applyDirection(landscape, 0, 1, 0)], [1, 0, 0], 1e-6))
|
||||||
|
|
||||||
|
// 陀螺仪开启瞬间的视角对齐:叠加偏移后朝向应等于对齐前记录的拖拽视角
|
||||||
|
const aligned = applyViewOffset(cameraFromDeviceOrientation(0, Math.PI / 2, 0, 0), 0.5, 0.25)
|
||||||
|
check('叠加偏移后方位角对齐', close(forwardYaw(cameraForward(aligned)), 0.5, 1e-6))
|
||||||
|
check('叠加偏移后俯仰角对齐', close(forwardPitch(cameraForward(aligned)), 0.25, 1e-6))
|
||||||
|
|
||||||
|
// ---------- 透视投影 ----------
|
||||||
|
|
||||||
|
const projection = perspectiveMatrix(Math.PI / 2, 2, 0.1, 10)
|
||||||
|
check('透视矩阵缩放项随视场角变化', close(projection[0], 0.5))
|
||||||
|
check('透视矩阵写入 w = -1(右手系)', projection[11] === -1)
|
||||||
|
|
||||||
|
console.log('vr360.test.ts: ok')
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// VR360(全景)播放的配置模型、命名启发式识别与本地偏好。
|
||||||
|
//
|
||||||
|
// 网页端只能从文件名/路径和画幅比例推断「这段视频是不是全景/VR」,任何信号
|
||||||
|
// 都不绝对可靠,所以这里只负责给出「大概率正确」的初始配置:识别结果不写入
|
||||||
|
// 数据库,用户随时可以在播放器菜单里改投影方式与立体布局,改动只影响本机。
|
||||||
|
|
||||||
|
export type Vr360Projection = 'equirect360' | 'equirect180' | 'fisheye180'
|
||||||
|
export type Vr360Stereo = 'mono' | 'sbs' | 'ou'
|
||||||
|
|
||||||
|
export type Vr360Profile = {
|
||||||
|
/** 画面投影方式:360 等距柱状、180 等距柱状、180 鱼眼。 */
|
||||||
|
projection: Vr360Projection
|
||||||
|
/** 立体布局:单眼、左右并排、上下并排。 */
|
||||||
|
stereo: Vr360Stereo
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_VR360_PROFILE: Vr360Profile = {
|
||||||
|
projection: 'equirect360',
|
||||||
|
stereo: 'mono',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视场角可调范围(度):数值越小画面越「拉近」。 */
|
||||||
|
export const VR360_MIN_FOV = 35
|
||||||
|
export const VR360_MAX_FOV = 110
|
||||||
|
export const VR360_DEFAULT_FOV = 75
|
||||||
|
|
||||||
|
export const VR360_PROJECTION_OPTIONS: Array<[Vr360Projection, string]> = [
|
||||||
|
['equirect360', '360° 全景'],
|
||||||
|
['equirect180', '180° 半景'],
|
||||||
|
['fisheye180', '180° 鱼眼'],
|
||||||
|
]
|
||||||
|
|
||||||
|
export const VR360_STEREO_OPTIONS: Array<[Vr360Stereo, string]> = [
|
||||||
|
['mono', '单眼'],
|
||||||
|
['sbs', '左右并排'],
|
||||||
|
['ou', '上下并排'],
|
||||||
|
]
|
||||||
|
|
||||||
|
export function vr360ProjectionLabel(projection: Vr360Projection): string {
|
||||||
|
return VR360_PROJECTION_OPTIONS.find(([value]) => value === projection)?.[1] ?? '360° 全景'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vr360StereoLabel(stereo: Vr360Stereo): string {
|
||||||
|
return VR360_STEREO_OPTIONS.find(([value]) => value === stereo)?.[1] ?? '单眼'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeVr360Projection(value: unknown): Vr360Projection {
|
||||||
|
return value === 'equirect180' || value === 'fisheye180' ? value : 'equirect360'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeVr360Stereo(value: unknown): Vr360Stereo {
|
||||||
|
return value === 'sbs' || value === 'ou' ? value : 'mono'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeVr360Profile(value: unknown): Vr360Profile {
|
||||||
|
const profile = (value ?? {}) as Partial<Vr360Profile>
|
||||||
|
return {
|
||||||
|
projection: normalizeVr360Projection(profile.projection),
|
||||||
|
stereo: normalizeVr360Stereo(profile.stereo),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameVr360Profile(a: Vr360Profile, b: Vr360Profile): boolean {
|
||||||
|
return a.projection === b.projection && a.stereo === b.stereo
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立体素材(左右/上下并排)在普通屏幕上只取左眼画面:真正的双眼渲染需要
|
||||||
|
// WebXR 头显,网页里把两半都画出来只会得到重复画面。
|
||||||
|
export function vr360FrameUv(stereo: Vr360Stereo): {
|
||||||
|
scale: [number, number]
|
||||||
|
offset: [number, number]
|
||||||
|
} {
|
||||||
|
if (stereo === 'sbs') return { scale: [0.5, 1], offset: [0, 0] }
|
||||||
|
if (stereo === 'ou') return { scale: [1, 0.5], offset: [0, 0] }
|
||||||
|
return { scale: [1, 1], offset: [0, 0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Vr360DetectInput = {
|
||||||
|
title?: string
|
||||||
|
originalName?: string
|
||||||
|
path?: string
|
||||||
|
relativePath?: string
|
||||||
|
/** 视频画面宽度(媒体元数据或 ffprobe 结果)。 */
|
||||||
|
width?: number
|
||||||
|
/** 视频画面高度。 */
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Vr360Detection = {
|
||||||
|
profile: Vr360Profile
|
||||||
|
/** 文件名/路径里出现了明确的 VR 或全景关键词,可以放心自动进入 VR 模式。 */
|
||||||
|
confident: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键词分两类:明确的关键词按子串匹配("vr360" 里也含 "360"),
|
||||||
|
// 容易误伤的两字母缩写按作品编号形态匹配。
|
||||||
|
const PANORAMA_SUBSTRINGS = ['equirect', 'insta360', 'pano', 'vuze', 'theta', '全景']
|
||||||
|
const FISHEYE_SUBSTRINGS = ['fisheye', '鱼眼']
|
||||||
|
const VR180_SUBSTRINGS = ['vr180', '180vr', '180°', '180度', '半景']
|
||||||
|
const SBS_SUBSTRINGS = ['side-by-side', 'sidebyside', 'side_by_side', '左右']
|
||||||
|
const OU_SUBSTRINGS = ['over-under', 'top-bottom', 'topbottom', '上下']
|
||||||
|
const SBS_TOKENS = ['sbs', 'hsbs', 'lr']
|
||||||
|
const OU_TOKENS = ['ou', 'tb', 'vou', 'tou']
|
||||||
|
|
||||||
|
// 「VR」必须落在作品编号形态上,不能只是单词里的字母组合:
|
||||||
|
// vr180 / vr00192(后面跟数字)、sivr-270 / kmvr.mkv(跟在字母后并以非字母数字收尾)。
|
||||||
|
// 这样 "Valvrave"、"VRoid" 这类单词不会被误判成 VR 素材。
|
||||||
|
const VR_NAME_PATTERN = /vr[0-9]|[a-z]vr(?![a-z0-9])/
|
||||||
|
// 「360」需要排除 "360p" 之类的分辨率写法和 "1360" 之类的数字片段。
|
||||||
|
const PANORAMA_360_PATTERN = /(?<![0-9])360(?![0-9p])/
|
||||||
|
|
||||||
|
function detectText(input: Vr360DetectInput): string {
|
||||||
|
return [input.path, input.relativePath, input.originalName, input.title]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件名比标题可靠:标题里可能出现「VR」之类的普通单词,文件名里的 "vr" 通常
|
||||||
|
// 是作品编号的一部分(如 sivr-270、ipvr00192)。
|
||||||
|
function detectNameText(input: Vr360DetectInput): string {
|
||||||
|
return [input.path, input.relativePath, input.originalName]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectTokens(text: string): Set<string> {
|
||||||
|
return new Set(text.split(/[^a-z0-9]+/).filter(Boolean))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSubstring(text: string, needles: string[]): boolean {
|
||||||
|
return needles.some((needle) => text.includes(needle))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasToken(tokens: Set<string>, needles: string[]): boolean {
|
||||||
|
return needles.some((needle) => tokens.has(needle))
|
||||||
|
}
|
||||||
|
|
||||||
|
function aspectRatio(input: Vr360DetectInput): number | null {
|
||||||
|
const width = input.width ?? 0
|
||||||
|
const height = input.height ?? 0
|
||||||
|
if (width <= 0 || height <= 0) return null
|
||||||
|
const ratio = width / height
|
||||||
|
return Number.isFinite(ratio) && ratio > 0 ? ratio : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依据文件名/路径关键词与画幅比例推断 VR 配置。
|
||||||
|
*
|
||||||
|
* 关键词比画幅可靠:2:1 既可能是 360 单眼,也可能是 180 左右并排,所以比例只
|
||||||
|
* 在没有明确关键词时补默认值(4:1 ⇒ 左右并排;约 1:1 的 360 ⇒ 上下并排)。
|
||||||
|
*/
|
||||||
|
export function detectVr360Profile(input: Vr360DetectInput): Vr360Detection {
|
||||||
|
const text = detectText(input)
|
||||||
|
const nameText = detectNameText(input)
|
||||||
|
const tokens = detectTokens(text)
|
||||||
|
|
||||||
|
const fisheye = hasSubstring(text, FISHEYE_SUBSTRINGS)
|
||||||
|
const vr180 = hasSubstring(text, VR180_SUBSTRINGS)
|
||||||
|
const panorama =
|
||||||
|
hasSubstring(text, PANORAMA_SUBSTRINGS) || PANORAMA_360_PATTERN.test(text)
|
||||||
|
// 标题里只认独立的 "vr" 词元;文件名/路径里允许作品编号形态。
|
||||||
|
const vrToken = tokens.has('vr') || VR_NAME_PATTERN.test(nameText)
|
||||||
|
const confident = fisheye || vr180 || panorama || vrToken
|
||||||
|
|
||||||
|
let projection: Vr360Projection = DEFAULT_VR360_PROFILE.projection
|
||||||
|
if (fisheye) projection = 'fisheye180'
|
||||||
|
else if (vr180) projection = 'equirect180'
|
||||||
|
// 只有 "VR" 一个信号、没有 360/全景关键词时,按更常见的 VR180 处理。
|
||||||
|
else if (vrToken && !panorama) projection = 'equirect180'
|
||||||
|
|
||||||
|
const sbs = hasSubstring(text, SBS_SUBSTRINGS) || hasToken(tokens, SBS_TOKENS)
|
||||||
|
const ou = hasSubstring(text, OU_SUBSTRINGS) || hasToken(tokens, OU_TOKENS)
|
||||||
|
|
||||||
|
let stereo: Vr360Stereo = 'mono'
|
||||||
|
if (sbs !== ou) stereo = sbs ? 'sbs' : 'ou'
|
||||||
|
|
||||||
|
const ratio = aspectRatio(input)
|
||||||
|
if (ratio !== null && stereo === 'mono') {
|
||||||
|
if (projection === 'equirect360') {
|
||||||
|
// 4:1 基本只可能是 360 左右并排(如 7680x1920);
|
||||||
|
// 约 1:1 的 360 素材多为上下并排(如 3840x3840)。
|
||||||
|
if (ratio >= 3.4) stereo = 'sbs'
|
||||||
|
else if (ratio <= 1.05) stereo = 'ou'
|
||||||
|
} else {
|
||||||
|
// VR180 单眼是 1:1,左右并排是 2:1,上下并排是 1:2。
|
||||||
|
if (ratio >= 1.8 && ratio <= 2.2) stereo = 'sbs'
|
||||||
|
else if (ratio <= 0.6) stereo = 'ou'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { profile: { projection, stereo }, confident }
|
||||||
|
}
|
||||||
|
|
||||||
|
const VR360_PREF_KEY = 'mebox.player.vr360'
|
||||||
|
|
||||||
|
export type Vr360Preference = {
|
||||||
|
/** 是否按文件名/画幅自动进入 VR 模式;用户可以关掉,避免误判干扰正常播放。 */
|
||||||
|
autoDetect: boolean
|
||||||
|
/** 用户上次选定的投影与布局,作为下次手动进入 VR 模式时的默认值。 */
|
||||||
|
profile: Vr360Profile
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_VR360_PREFERENCE: Vr360Preference = {
|
||||||
|
autoDetect: true,
|
||||||
|
profile: DEFAULT_VR360_PROFILE,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadVr360Preference(): Vr360Preference {
|
||||||
|
if (typeof window === 'undefined') return DEFAULT_VR360_PREFERENCE
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(VR360_PREF_KEY)
|
||||||
|
if (!raw) return DEFAULT_VR360_PREFERENCE
|
||||||
|
const parsed = JSON.parse(raw) as Partial<Vr360Preference>
|
||||||
|
return {
|
||||||
|
autoDetect: parsed.autoDetect !== false,
|
||||||
|
profile: normalizeVr360Profile(parsed.profile),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_VR360_PREFERENCE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveVr360Preference(preference: Vr360Preference): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
VR360_PREF_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
autoDetect: preference.autoDetect !== false,
|
||||||
|
profile: normalizeVr360Profile(preference.profile),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// 隐私模式下 localStorage 不可用;播放器仍可正常使用 VR 模式。
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// VR360 播放用到的纯数学:3x3 旋转矩阵、设备姿态到相机姿态、透视投影。
|
||||||
|
//
|
||||||
|
// 坐标约定(与 W3C DeviceOrientation 规范一致,右手系):
|
||||||
|
// x = 东(设备右侧),y = 北(设备顶部),z = 天空(设备屏幕朝外)
|
||||||
|
// 相机看向相机局部坐标的 -z,设备平放时 -z 指向地面,因此设备平放时视角朝下,
|
||||||
|
// 竖持手机(beta=90°)时视角水平向前 —— 与手机陀螺仪的实际手感一致。
|
||||||
|
//
|
||||||
|
// 所有 3x3 矩阵都是 WebGL 默认的列主序(column-major)Float32Array(9),
|
||||||
|
// 可以直接交给 gl.uniformMatrix3fv,不需要转置。
|
||||||
|
|
||||||
|
export type Mat3 = Float32Array
|
||||||
|
|
||||||
|
export function degToRad(degrees: number): number {
|
||||||
|
return (degrees * Math.PI) / 180
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampNumber(value: number, min: number, max: number): number {
|
||||||
|
if (!Number.isFinite(value)) return min
|
||||||
|
return Math.min(max, Math.max(min, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMat3(
|
||||||
|
m00: number, m10: number, m20: number,
|
||||||
|
m01: number, m11: number, m21: number,
|
||||||
|
m02: number, m12: number, m22: number,
|
||||||
|
): Mat3 {
|
||||||
|
// WebGL 列主序内存布局:[col0, col1, col2]
|
||||||
|
return new Float32Array([m00, m10, m20, m01, m11, m21, m02, m12, m22])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identity3(): Mat3 {
|
||||||
|
return createMat3(1, 0, 0, 0, 1, 0, 0, 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotationX(radians: number): Mat3 {
|
||||||
|
const c = Math.cos(radians)
|
||||||
|
const s = Math.sin(radians)
|
||||||
|
return createMat3(1, 0, 0, 0, c, s, 0, -s, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotationY(radians: number): Mat3 {
|
||||||
|
const c = Math.cos(radians)
|
||||||
|
const s = Math.sin(radians)
|
||||||
|
return createMat3(c, 0, -s, 0, 1, 0, s, 0, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotationZ(radians: number): Mat3 {
|
||||||
|
const c = Math.cos(radians)
|
||||||
|
const s = Math.sin(radians)
|
||||||
|
return createMat3(c, s, 0, -s, c, 0, 0, 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 先施加 b,再施加 a(即矩阵乘积 a·b)。 */
|
||||||
|
export function multiply3(a: Mat3, b: Mat3): Mat3 {
|
||||||
|
const out = new Float32Array(9)
|
||||||
|
for (let col = 0; col < 3; col++) {
|
||||||
|
for (let row = 0; row < 3; row++) {
|
||||||
|
let sum = 0
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
sum += a[k * 3 + row] * b[col * 3 + k]
|
||||||
|
}
|
||||||
|
out[col * 3 + row] = sum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旋转矩阵的逆等于转置。 */
|
||||||
|
export function transpose3(m: Mat3): Mat3 {
|
||||||
|
const out = new Float32Array(9)
|
||||||
|
for (let col = 0; col < 3; col++) {
|
||||||
|
for (let row = 0; row < 3; row++) {
|
||||||
|
out[col * 3 + row] = m[row * 3 + col]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyDirection(m: Mat3, x: number, y: number, z: number): [number, number, number] {
|
||||||
|
return [
|
||||||
|
m[0] * x + m[3] * y + m[6] * z,
|
||||||
|
m[1] * x + m[4] * y + m[7] * z,
|
||||||
|
m[2] * x + m[5] * y + m[8] * z,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相机在世界坐标中的朝向(相机局部 -z)。 */
|
||||||
|
export function cameraForward(camera: Mat3): [number, number, number] {
|
||||||
|
const forward = applyDirection(camera, 0, 0, -1)
|
||||||
|
const length = Math.hypot(forward[0], forward[1], forward[2]) || 1
|
||||||
|
return [forward[0] / length, forward[1] / length, forward[2] / length]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相机朝向的水平方位角(0 = +y 即正北,顺时针转向 +x 即正东)。 */
|
||||||
|
export function forwardYaw(forward: [number, number, number]): number {
|
||||||
|
return Math.atan2(forward[0], forward[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相机朝向的俯仰角(+ 抬头,- 低头)。 */
|
||||||
|
export function forwardPitch(forward: [number, number, number]): number {
|
||||||
|
return Math.asin(clampNumber(forward[2], -1, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由「用户拖拽/默认视角」的方位角与俯仰角构造相机姿态。
|
||||||
|
* 默认(yaw = 0, pitch = 0)时相机水平看向 +y,画面中的地平线保持水平。
|
||||||
|
*/
|
||||||
|
export function cameraFromYawPitch(yaw: number, pitch: number): Mat3 {
|
||||||
|
return multiply3(rotationZ(-yaw), rotationX(Math.PI / 2 + pitch))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由设备姿态构造相机姿态(W3C 规范:R = Rz(α)·Rx(β)·Ry(γ)),
|
||||||
|
* 再按屏幕旋转角做一次横滚校正,使画面正向与屏幕正向一致。
|
||||||
|
*/
|
||||||
|
export function cameraFromDeviceOrientation(
|
||||||
|
alpha: number,
|
||||||
|
beta: number,
|
||||||
|
gamma: number,
|
||||||
|
screenAngle: number,
|
||||||
|
): Mat3 {
|
||||||
|
const device = multiply3(multiply3(rotationZ(alpha), rotationX(beta)), rotationY(gamma))
|
||||||
|
return multiply3(device, rotationZ(-screenAngle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在设备姿态之上叠加用户拖拽产生的视角偏移:水平方向围绕世界 z 轴旋转,
|
||||||
|
* 垂直方向围绕相机自身的右轴倾斜(小角度下等效于抬头/低头)。
|
||||||
|
*/
|
||||||
|
export function applyViewOffset(camera: Mat3, yawOffset: number, pitchOffset: number): Mat3 {
|
||||||
|
return multiply3(rotationZ(-yawOffset), multiply3(camera, rotationX(pitchOffset)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 世界坐标 → 相机坐标的视图矩阵(纯旋转,所以就是转置)。 */
|
||||||
|
export function viewMatrixFromCamera(camera: Mat3): Mat3 {
|
||||||
|
return transpose3(camera)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标准透视投影矩阵(列主序 4x4)。 */
|
||||||
|
export function perspectiveMatrix(
|
||||||
|
fovY: number,
|
||||||
|
aspect: number,
|
||||||
|
near: number,
|
||||||
|
far: number,
|
||||||
|
): Float32Array {
|
||||||
|
const f = 1 / Math.tan(fovY / 2)
|
||||||
|
const rangeInv = 1 / (near - far)
|
||||||
|
return new Float32Array([
|
||||||
|
f / aspect, 0, 0, 0,
|
||||||
|
0, f, 0, 0,
|
||||||
|
0, 0, (far + near) * rangeInv, -1,
|
||||||
|
0, 0, 2 * far * near * rangeInv, 0,
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
// 用一段 WebGL 把 <video> 贴到球面/半球面上,实现 360°/180° 全景播放。
|
||||||
|
//
|
||||||
|
// 不引入 three.js:整个渲染器只是一段着色器 + 一个网格 + 一张视频纹理,依赖
|
||||||
|
// 极小,同时也不会把播放页首屏体积撑大(该模块由播放器按需懒加载)。
|
||||||
|
//
|
||||||
|
// 视频元素始终是同一个正在播放的 <video>,这里只把它当前的帧采样到球面上,
|
||||||
|
// 因此 HLS、直连、115 云转码等所有播放方式都能直接复用。
|
||||||
|
|
||||||
|
import type { Mat3 } from './vr360Math'
|
||||||
|
import { perspectiveMatrix } from './vr360Math'
|
||||||
|
import {
|
||||||
|
VR360_DEFAULT_FOV,
|
||||||
|
VR360_MAX_FOV,
|
||||||
|
VR360_MIN_FOV,
|
||||||
|
type Vr360Projection,
|
||||||
|
} from './vr360'
|
||||||
|
|
||||||
|
export type Vr360Renderer = {
|
||||||
|
setProjection(projection: Vr360Projection): void
|
||||||
|
/** 立体素材裁切:只取左眼所在的半张画面。 */
|
||||||
|
setFrameUv(scale: [number, number], offset: [number, number]): void
|
||||||
|
setView(view: Mat3): void
|
||||||
|
setFov(fovDeg: number): void
|
||||||
|
resize(width: number, height: number): void
|
||||||
|
draw(): void
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Vr360RendererOptions = {
|
||||||
|
/** WebGL 上下文丢失(移动端切后台、系统回收 GPU 资源)时回调,由调用方退出 VR 模式。 */
|
||||||
|
onContextLost?: () => void
|
||||||
|
/** 视频帧无法写入纹理(跨域污染等)时回调。 */
|
||||||
|
onTextureError?: (error: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERTEX_SHADER = `
|
||||||
|
attribute vec3 aPosition;
|
||||||
|
attribute vec2 aUv;
|
||||||
|
uniform mat3 uView;
|
||||||
|
uniform mat4 uProjection;
|
||||||
|
uniform vec2 uUvScale;
|
||||||
|
uniform vec2 uUvOffset;
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
vUv = aUv * uUvScale + uUvOffset;
|
||||||
|
gl_Position = uProjection * vec4(uView * aPosition, 1.0);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const FRAGMENT_SHADER = `
|
||||||
|
precision mediump float;
|
||||||
|
uniform sampler2D uTexture;
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
gl_FragColor = texture2D(uTexture, vUv);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// 球面半径为 1 个单位,相机固定在球心,远近裁剪面据此选取。
|
||||||
|
const NEAR_PLANE = 0.1
|
||||||
|
const FAR_PLANE = 10
|
||||||
|
|
||||||
|
type Mesh = {
|
||||||
|
positions: Float32Array
|
||||||
|
uvs: Float32Array
|
||||||
|
indices: Uint16Array
|
||||||
|
indexCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type GridPoint = {
|
||||||
|
position: [number, number, number]
|
||||||
|
uv: [number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGrid(
|
||||||
|
columns: number,
|
||||||
|
rows: number,
|
||||||
|
point: (col: number, row: number) => GridPoint,
|
||||||
|
): Mesh {
|
||||||
|
const positions = new Float32Array((columns + 1) * (rows + 1) * 3)
|
||||||
|
const uvs = new Float32Array((columns + 1) * (rows + 1) * 2)
|
||||||
|
for (let row = 0; row <= rows; row++) {
|
||||||
|
for (let col = 0; col <= columns; col++) {
|
||||||
|
const index = row * (columns + 1) + col
|
||||||
|
const { position, uv } = point(col, row)
|
||||||
|
positions[index * 3] = position[0]
|
||||||
|
positions[index * 3 + 1] = position[1]
|
||||||
|
positions[index * 3 + 2] = position[2]
|
||||||
|
uvs[index * 2] = uv[0]
|
||||||
|
uvs[index * 2 + 1] = uv[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const indices = new Uint16Array(columns * rows * 6)
|
||||||
|
let cursor = 0
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < columns; col++) {
|
||||||
|
const topLeft = row * (columns + 1) + col
|
||||||
|
const topRight = topLeft + 1
|
||||||
|
const bottomLeft = topLeft + columns + 1
|
||||||
|
const bottomRight = bottomLeft + 1
|
||||||
|
indices[cursor++] = topLeft
|
||||||
|
indices[cursor++] = bottomLeft
|
||||||
|
indices[cursor++] = topRight
|
||||||
|
indices[cursor++] = topRight
|
||||||
|
indices[cursor++] = bottomLeft
|
||||||
|
indices[cursor++] = bottomRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { positions, uvs, indices, indexCount: indices.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等距柱状投影:水平覆盖 ±span,垂直覆盖 -90°~+90°。
|
||||||
|
* u 从 0(左端)到 1(右端),v 从 0(画面顶部)到 1(画面底部)。
|
||||||
|
*/
|
||||||
|
function buildEquirectMesh(span: number): Mesh {
|
||||||
|
const columns = 96
|
||||||
|
const rows = 48
|
||||||
|
return buildGrid(columns, rows, (col, row) => {
|
||||||
|
const lambda = -span + (2 * span * col) / columns
|
||||||
|
const phi = -Math.PI / 2 + (Math.PI * row) / rows
|
||||||
|
const cosPhi = Math.cos(phi)
|
||||||
|
return {
|
||||||
|
position: [Math.sin(lambda) * cosPhi, Math.cos(lambda) * cosPhi, Math.sin(phi)],
|
||||||
|
uv: [(lambda + span) / (2 * span), 0.5 - phi / Math.PI],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 180° 鱼眼(等距投影):画面中心是正前方,纹理半径 r 与偏角 θ 成正比。
|
||||||
|
* 方画幅内切圆之外的区域不参与采样,标准 VR180 鱼眼素材即如此。
|
||||||
|
*/
|
||||||
|
function buildFisheyeMesh(): Mesh {
|
||||||
|
const radial = 64
|
||||||
|
const azimuth = 96
|
||||||
|
const thetaMax = Math.PI / 2
|
||||||
|
return buildGrid(azimuth, radial, (col, row) => {
|
||||||
|
const psi = (2 * Math.PI * col) / azimuth
|
||||||
|
const theta = (thetaMax * row) / radial
|
||||||
|
const sinTheta = Math.sin(theta)
|
||||||
|
const radius = theta / thetaMax
|
||||||
|
return {
|
||||||
|
position: [sinTheta * Math.cos(psi), Math.cos(theta), sinTheta * Math.sin(psi)],
|
||||||
|
uv: [0.5 + 0.5 * radius * Math.cos(psi), 0.5 - 0.5 * radius * Math.sin(psi)],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMesh(projection: Vr360Projection): Mesh {
|
||||||
|
if (projection === 'fisheye180') return buildFisheyeMesh()
|
||||||
|
if (projection === 'equirect180') return buildEquirectMesh(Math.PI / 2)
|
||||||
|
return buildEquirectMesh(Math.PI)
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
|
||||||
|
const shader = gl.createShader(type)
|
||||||
|
if (!shader) return null
|
||||||
|
gl.shaderSource(shader, source)
|
||||||
|
gl.compileShader(shader)
|
||||||
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||||
|
gl.deleteShader(shader)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return shader
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在给定画布上创建全景渲染器;浏览器不支持 WebGL 时返回 null,调用方据此
|
||||||
|
* 退回普通播放,而不是给用户一个黑屏。
|
||||||
|
*/
|
||||||
|
export function createVr360Renderer(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
video: HTMLVideoElement,
|
||||||
|
options: Vr360RendererOptions = {},
|
||||||
|
): Vr360Renderer | null {
|
||||||
|
const attributes: WebGLContextAttributes = {
|
||||||
|
alpha: false,
|
||||||
|
antialias: false,
|
||||||
|
depth: false,
|
||||||
|
stencil: false,
|
||||||
|
premultipliedAlpha: false,
|
||||||
|
preserveDrawingBuffer: false,
|
||||||
|
powerPreference: 'high-performance',
|
||||||
|
}
|
||||||
|
const gl = (canvas.getContext('webgl2', attributes) ??
|
||||||
|
canvas.getContext('webgl', attributes)) as WebGLRenderingContext | null
|
||||||
|
if (!gl) return null
|
||||||
|
|
||||||
|
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER)
|
||||||
|
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER)
|
||||||
|
if (!vertexShader || !fragmentShader) return null
|
||||||
|
const program = gl.createProgram()
|
||||||
|
if (!program) return null
|
||||||
|
gl.attachShader(program, vertexShader)
|
||||||
|
gl.attachShader(program, fragmentShader)
|
||||||
|
gl.linkProgram(program)
|
||||||
|
gl.deleteShader(vertexShader)
|
||||||
|
gl.deleteShader(fragmentShader)
|
||||||
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||||
|
gl.deleteProgram(program)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionLocation = gl.getAttribLocation(program, 'aPosition')
|
||||||
|
const uvLocation = gl.getAttribLocation(program, 'aUv')
|
||||||
|
const viewLocation = gl.getUniformLocation(program, 'uView')
|
||||||
|
const projectionLocation = gl.getUniformLocation(program, 'uProjection')
|
||||||
|
const uvScaleLocation = gl.getUniformLocation(program, 'uUvScale')
|
||||||
|
const uvOffsetLocation = gl.getUniformLocation(program, 'uUvOffset')
|
||||||
|
const textureLocation = gl.getUniformLocation(program, 'uTexture')
|
||||||
|
|
||||||
|
const positionBuffer = gl.createBuffer()
|
||||||
|
const uvBuffer = gl.createBuffer()
|
||||||
|
const indexBuffer = gl.createBuffer()
|
||||||
|
const texture = gl.createTexture()
|
||||||
|
if (!positionBuffer || !uvBuffer || !indexBuffer || !texture) return null
|
||||||
|
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||||
|
// 先放一张 1x1 黑色占位图,视频帧尚未就绪时也不会出现未定义内容。
|
||||||
|
gl.texImage2D(
|
||||||
|
gl.TEXTURE_2D,
|
||||||
|
0,
|
||||||
|
gl.RGBA,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
gl.RGBA,
|
||||||
|
gl.UNSIGNED_BYTE,
|
||||||
|
new Uint8Array([0, 0, 0, 255]),
|
||||||
|
)
|
||||||
|
|
||||||
|
gl.disable(gl.CULL_FACE)
|
||||||
|
gl.disable(gl.DEPTH_TEST)
|
||||||
|
gl.disable(gl.BLEND)
|
||||||
|
gl.clearColor(0, 0, 0, 1)
|
||||||
|
|
||||||
|
let indexCount = 0
|
||||||
|
let uvScale: [number, number] = [1, 1]
|
||||||
|
let uvOffset: [number, number] = [0, 0]
|
||||||
|
let view: Mat3 = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1])
|
||||||
|
let fov = VR360_DEFAULT_FOV
|
||||||
|
let displayWidth = 1
|
||||||
|
let displayHeight = 1
|
||||||
|
let disposed = false
|
||||||
|
let contextLost = false
|
||||||
|
|
||||||
|
const uploadMesh = (mesh: Mesh) => {
|
||||||
|
indexCount = mesh.indexCount
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, mesh.positions, gl.STATIC_DRAW)
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer)
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, mesh.uvs, gl.STATIC_DRAW)
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
|
||||||
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.indices, gl.STATIC_DRAW)
|
||||||
|
}
|
||||||
|
uploadMesh(buildMesh('equirect360'))
|
||||||
|
|
||||||
|
const handleContextLost = (event: Event) => {
|
||||||
|
// 必须 preventDefault,否则上下文不会被标记为可恢复;这里直接让上层退出 VR。
|
||||||
|
event.preventDefault()
|
||||||
|
contextLost = true
|
||||||
|
options.onContextLost?.()
|
||||||
|
}
|
||||||
|
canvas.addEventListener('webglcontextlost', handleContextLost)
|
||||||
|
|
||||||
|
return {
|
||||||
|
setProjection(projection: Vr360Projection) {
|
||||||
|
if (disposed) return
|
||||||
|
uploadMesh(buildMesh(projection))
|
||||||
|
},
|
||||||
|
setFrameUv(scale: [number, number], offset: [number, number]) {
|
||||||
|
uvScale = scale
|
||||||
|
uvOffset = offset
|
||||||
|
},
|
||||||
|
setView(next: Mat3) {
|
||||||
|
view = next
|
||||||
|
},
|
||||||
|
setFov(next: number) {
|
||||||
|
fov = Math.min(VR360_MAX_FOV, Math.max(VR360_MIN_FOV, next))
|
||||||
|
},
|
||||||
|
resize(nextWidth: number, nextHeight: number) {
|
||||||
|
if (disposed) return
|
||||||
|
// 画布位置与尺寸完全交给 CSS(inset-0 + 100%),绘制缓冲区只跟随它。
|
||||||
|
// 否则「观测到画布变大 → 设置更大的缓冲区 → 布局再次变大」会指数放大。
|
||||||
|
canvas.style.width = '100%'
|
||||||
|
canvas.style.height = '100%'
|
||||||
|
// 移动端按 DPR 渲染 4K 视频纹理代价很高,上限锁 2 倍。
|
||||||
|
const dpr = Math.min(2, Math.max(1, window.devicePixelRatio || 1))
|
||||||
|
displayWidth = Math.max(1, nextWidth)
|
||||||
|
displayHeight = Math.max(1, nextHeight)
|
||||||
|
const pixelWidth = Math.max(1, Math.round(displayWidth * dpr))
|
||||||
|
const pixelHeight = Math.max(1, Math.round(displayHeight * dpr))
|
||||||
|
if (canvas.width !== pixelWidth) canvas.width = pixelWidth
|
||||||
|
if (canvas.height !== pixelHeight) canvas.height = pixelHeight
|
||||||
|
},
|
||||||
|
draw() {
|
||||||
|
if (disposed || contextLost) return
|
||||||
|
if (canvas.width < 1 || canvas.height < 1) return
|
||||||
|
|
||||||
|
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT)
|
||||||
|
|
||||||
|
if (video.readyState >= 2 && video.videoWidth > 0) {
|
||||||
|
try {
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||||
|
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0)
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video)
|
||||||
|
} catch (error) {
|
||||||
|
options.onTextureError?.(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.useProgram(program)
|
||||||
|
gl.uniformMatrix3fv(viewLocation, false, view)
|
||||||
|
gl.uniformMatrix4fv(
|
||||||
|
projectionLocation,
|
||||||
|
false,
|
||||||
|
perspectiveMatrix(
|
||||||
|
(fov * Math.PI) / 180,
|
||||||
|
displayWidth / displayHeight,
|
||||||
|
NEAR_PLANE,
|
||||||
|
FAR_PLANE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
gl.uniform2f(uvScaleLocation, uvScale[0], uvScale[1])
|
||||||
|
gl.uniform2f(uvOffsetLocation, uvOffset[0], uvOffset[1])
|
||||||
|
gl.uniform1i(textureLocation, 0)
|
||||||
|
|
||||||
|
gl.activeTexture(gl.TEXTURE0)
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
|
||||||
|
gl.enableVertexAttribArray(positionLocation)
|
||||||
|
gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0)
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer)
|
||||||
|
gl.enableVertexAttribArray(uvLocation)
|
||||||
|
gl.vertexAttribPointer(uvLocation, 2, gl.FLOAT, false, 0, 0)
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
|
||||||
|
gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0)
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (disposed) return
|
||||||
|
disposed = true
|
||||||
|
canvas.removeEventListener('webglcontextlost', handleContextLost)
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, null)
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null)
|
||||||
|
gl.deleteBuffer(positionBuffer)
|
||||||
|
gl.deleteBuffer(uvBuffer)
|
||||||
|
gl.deleteBuffer(indexBuffer)
|
||||||
|
gl.deleteTexture(texture)
|
||||||
|
gl.deleteProgram(program)
|
||||||
|
// 主动释放上下文:移动端同时存活多个 WebGL 上下文会被浏览器强制回收。
|
||||||
|
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user