Compare commits

...

13 Commits

Author SHA1 Message Date
truewhile f4f57759ce 优化移动端播放 2026-09-18 11:28:33 +08:00
truewhile 6ec9adc976 优化移动端播放 2026-09-18 10:09:08 +08:00
truewhile f4abdf78e2 优化在线字幕 2026-09-17 23:53:22 +08:00
truewhile ae8db26c1e bug处理 2026-09-17 23:20:58 +08:00
truewhile 8017e994f4 优化 2026-09-17 22:57:41 +08:00
truewhile d56fb53a08 优化界面,优化多版本切换逻辑 2026-09-17 22:33:53 +08:00
truewhile 30ca94c79b 优化问题 2026-09-17 14:17:09 +08:00
truewhile 9c1f9a6e7e bug处理 2026-09-17 11:19:38 +08:00
truewhile ec7be38266 优化vr播放逻辑 2026-09-17 09:22:00 +08:00
truewhile ebf791c436 优化 2026-09-17 01:29:10 +08:00
truewhile 83c0b65a0b 优化360播放 2026-09-17 01:08:28 +08:00
truewhile 1153fe4191 添加VR360播放功能 2026-09-17 00:31:45 +08:00
truewhile 93866d169b 优化 2026-09-16 23:59:38 +08:00
42 changed files with 5564 additions and 840 deletions
+33
View File
@@ -174,6 +174,39 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
}
}
func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) {
svc := newDanmakuSettingsService(t)
// 默认未看过:配置接口必须先如实告知前端,否则首次操作说明会被跳过。
initial := svc.Danmaku.ConfigForUser(t.Context(), "user-1")
if initial.Vr360GuideSeen {
t.Fatal("vr360 guide should be unread for a fresh user")
}
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"vr360_guide_seen":true}`, "user-1")
updateDanmakuSettingsHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
var cfg service.DanmakuRenderConfig
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err)
}
if !cfg.Vr360GuideSeen {
t.Fatalf("config should reflect the persisted guide flag: %+v", cfg)
}
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("read persisted user: %v", err)
}
if !user.PlayerVr360GuideSeen {
t.Fatalf("guide flag not persisted on the user: %+v", user)
}
}
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
svc := newDanmakuSettingsService(t)
+12 -1
View File
@@ -722,7 +722,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if mount.ProxyPlay {
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
// 与挂载账号的 proxy_play 开关等价。
if mount.ProxyPlay || wantSameOriginProxy(c) {
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
@@ -758,6 +760,15 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
// ?proxy=1:把网盘/STRM 直链改为服务端同源转发(网页端读帧、VR 全景用),
// 画质与原文件一致,不触发转码。
if wantSameOriginProxy(c) {
handled, proxyErr := proxySTRMStream(c, svc, m)
if handled {
writeProxyError(c, proxyErr)
return
}
}
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+56
View File
@@ -0,0 +1,56 @@
// 直连流的同源转发:网页端需要读取视频帧的场景(VR 全景渲染走 WebGL 纹理)
// 不能使用会跳到网盘 CDN 的跨域直链,这里把直链改为服务端转发。
//
// 只在客户端显式带上 ?proxy=1 时生效,普通播放仍走原来的 302 直连,
// 避免把网盘流量无谓地压到服务器上。
package handler
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
// wantSameOriginProxy 客户端是否要求把直连流改为服务端同源转发。
func wantSameOriginProxy(c *gin.Context) bool {
if c == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(c.Query("proxy"))) {
case "1", "true", "yes":
return true
default:
return false
}
}
// proxySTRMStream 处理 STRM/网盘媒体的同源转发请求。返回 handled=false 表示
// 该媒体不需要(或无法)代理,调用方继续按原有逻辑处理。
func proxySTRMStream(c *gin.Context, svc *service.Container, m *model.Media) (bool, error) {
if c == nil || svc == nil || svc.Strm == nil || m == nil {
return false, nil
}
if !service.IsStrmMediaRow(m) {
return false, nil
}
if err := svc.Strm.ProxyMediaDirect(c.Request.Context(), c.Writer, c.Request, m); err != nil {
if errors.Is(err, service.ErrStrmProxyNotApplicable) {
return false, nil
}
return true, err
}
return true, nil
}
// writeProxyError 在尚未写入任何响应内容时回一个明确的网关错误。
func writeProxyError(c *gin.Context, err error) {
if c == nil || err == nil || c.Writer.Written() {
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
+12 -9
View File
@@ -37,15 +37,18 @@ type User struct {
// original / simplified / traditional。
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
// PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明,
// 按用户保存:看过一次之后不再弹出。
PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"`
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
// DanmakuAppKey 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
DanmakuAppKey string `gorm:"size:256" json:"-"`
// ExpiredAt is the account expiry time. Nil means the account never
+8
View File
@@ -71,6 +71,8 @@ type DanmakuRenderConfig struct {
Volume float64 `json:"volume"`
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
PlaybackRate float64 `json:"playback_rate"`
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
Vr360GuideSeen bool `json:"vr360_guide_seen"`
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
MergeSources bool `json:"merge_sources"`
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
@@ -98,6 +100,8 @@ type DanmakuSettingsPatch struct {
MergeSources *bool `json:"merge_sources"`
Volume *float64 `json:"volume"`
PlaybackRate *float64 `json:"playback_rate"`
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
Vr360GuideSeen *bool `json:"vr360_guide_seen"`
}
type danmakuUserContextKey struct{}
@@ -447,6 +451,7 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
Area: strconv.FormatFloat(area, 'f', -1, 64),
Volume: volume,
PlaybackRate: playbackRate,
Vr360GuideSeen: user.PlayerVr360GuideSeen,
MergeSources: user.DanmakuMergeSources,
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
}
@@ -528,6 +533,9 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
}
updates["player_playback_rate"] = *patch.PlaybackRate
}
if patch.Vr360GuideSeen != nil {
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
}
if len(updates) == 0 {
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
}
+23 -14
View File
@@ -2,7 +2,6 @@ package service
import (
"context"
"sort"
"strings"
"time"
@@ -179,9 +178,21 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
libraryIDs = []string{media.LibraryID}
}
filter := repository.MediaQueryFilter{IncludeNSFW: true}
candidates, narrowed, err := s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
if err != nil {
return nil, err
// 成人条目按番号分组,而番号常常只存在于路径/标题里:本地 NFO 来源的分片
// 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部
// ID 预先收窄候选集,同番号的其它分片会被 SQL 直接排除,表现就是库里折叠
// 出了 N 个版本、详情页却只列出带 ID 的那几个。因此成人条目不预先收窄,
// 直接在该库范围内比对版本键。未刮削但文件名带番号的分片同理:同一部片的
// 各分片元数据来源不一致(刮削 vs pending),收窄后标题也对不上。
var candidates []model.Media
narrowed := false
hasAdultCode := mediaAdultGroupCode(*media) != "" ||
canonicalAdultGroupCode(AdultCodeFromMediaPath(media.Path)) != ""
if !hasAdultCode {
candidates, narrowed, err = s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
if err != nil {
return nil, err
}
}
if !narrowed {
candidates, err = s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
@@ -190,23 +201,21 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
}
}
s.attachLibraryMetadata(ctx, candidates)
// 同番号只要有一个分片被确认为成人内容,尚未刮削的分片也按番号折叠,
// 与列表页的分组结果保持一致。
vouched := adultCodesVouchedByNSFW(candidates)
if vouchKey := adultVouchedGroupKey(*media, vouched); vouchKey != "" {
key = vouchKey
}
matched := make([]model.Media, 0, 4)
for _, row := range candidates {
if mediaVersionGroupKey(row) == key {
if mediaVersionGroupKeyWithAdultVouch(row, vouched) == key {
matched = append(matched, row)
}
}
if len(matched) == 0 {
return []model.Media{*media}, nil
}
sort.SliceStable(matched, func(i, j int) bool {
if matched[i].ID == media.ID {
return true
}
if matched[j].ID == media.ID {
return false
}
return betterMediaVersion(matched[i], matched[j])
})
sortMediaVersionsForDisplay(matched)
return matched, nil
}
+150 -25
View File
@@ -3,6 +3,7 @@ package service
import (
"fmt"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
@@ -76,35 +77,33 @@ func groupMediaVersions(items []model.Media) []MediaItem {
if len(items) == 0 {
return nil
}
// 同一番号只要有一个分片被刮削标记为成人内容,就认定整部片都是成人条目:
// 未刮削的分片(nsfw=false)也按番号折叠。否则「一个分片刮削成功、其余仍
// pending」时,已刮削的走番号分组、其余落到标题分组,整部片被拆成两张卡。
adultCodes := adultCodesVouchedByNSFW(items)
type group struct {
key string
primary model.Media
rows []model.Media
key string
rows []model.Media
}
groups := make([]group, 0, len(items))
byKey := make(map[string]int, len(items))
for _, item := range items {
key := mediaVersionGroupKey(item)
key := mediaVersionGroupKeyWithAdultVouch(item, adultCodes)
if key == "" {
groups = append(groups, group{primary: item, rows: []model.Media{item}})
groups = append(groups, group{rows: []model.Media{item}})
continue
}
if idx, ok := byKey[key]; ok {
groups[idx].rows = append(groups[idx].rows, item)
if betterMediaVersion(item, groups[idx].primary) {
groups[idx].primary = item
}
continue
}
byKey[key] = len(groups)
groups = append(groups, group{key: key, primary: item, rows: []model.Media{item}})
groups = append(groups, group{key: key, rows: []model.Media{item}})
}
out := make([]MediaItem, 0, len(groups))
for _, g := range groups {
sort.SliceStable(g.rows, func(i, j int) bool {
return betterMediaVersion(g.rows[i], g.rows[j])
})
item := MediaItem{Media: g.primary}
sortMediaVersionsForDisplay(g.rows)
item := MediaItem{Media: g.rows[0]}
if len(g.rows) > 1 {
item.Versions = g.rows
}
@@ -140,16 +139,65 @@ func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem {
return grouped
}
func mediaVersionGroupKey(m model.Media) string {
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
if IsEmbyRemoteID(m.ID) {
return fmt.Sprintf("embyremote:%s", m.ID)
}
// mediaVersionLibraryKey 返回版本身份里使用的库标识。
func mediaVersionLibraryKey(m model.Media) string {
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
if libKey == "" {
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
}
return libKey
}
// adultCodesVouchedByNSFW 收集「已被刮削确认为成人内容」的番号。
func adultCodesVouchedByNSFW(items []model.Media) map[string]bool {
codes := map[string]bool{}
for _, item := range items {
if !item.NSFW {
continue
}
if code := canonicalAdultGroupCode(AdultCodeFromMediaPath(item.Path)); code != "" {
codes[code] = true
continue
}
if code := mediaAdultGroupCode(item); code != "" {
codes[code] = true
}
}
return codes
}
// mediaVersionGroupKeyWithAdultVouch 是版本身份的统一入口:番号已被同组分片
// 确认时优先按番号折叠,否则沿用 mediaVersionGroupKey 的原判定。
func mediaVersionGroupKeyWithAdultVouch(m model.Media, vouched map[string]bool) string {
if key := adultVouchedGroupKey(m, vouched); key != "" {
return key
}
return mediaVersionGroupKey(m)
}
// adultVouchedGroupKey 在番号已被同组分片确认时,返回番号分组键;
// 其余情况返回空串,表示沿用 mediaVersionGroupKey 的原判定。
func adultVouchedGroupKey(m model.Media, vouched map[string]bool) string {
if len(vouched) == 0 || m.NSFW {
return ""
}
code := canonicalAdultGroupCode(AdultCodeFromMediaPath(m.Path))
if code == "" || !vouched[code] {
return ""
}
libKey := mediaVersionLibraryKey(m)
if libKey == "" {
libKey = "_"
}
return fmt.Sprintf("adult:%s:%s", libKey, code)
}
func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
if IsEmbyRemoteID(m.ID) {
return fmt.Sprintf("embyremote:%s", m.ID)
}
libKey := mediaVersionLibraryKey(m)
specialKind := mediaSpecialKind(m.Path)
season, episode := m.SeasonNum, m.EpisodeNum
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
@@ -351,8 +399,11 @@ func mediaVersionFileStem(name string) string {
}
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
// 当只有容器等无法区分多版本的信息时,回退到清理后的文件名。
func MediaVersionLabel(m model.Media) string {
parts := make([]string, 0, 4)
isStrm := strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") ||
strings.TrimSpace(m.STRMURL) != ""
if m.Height > 0 {
parts = append(parts, fmt.Sprintf("%dp", m.Height))
} else if m.Width > 0 {
@@ -368,17 +419,91 @@ func MediaVersionLabel(m model.Media) string {
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
parts = append(parts, strings.ToUpper(codec))
}
if m.SizeBytes > 0 {
// STRM 占位体积(通常几十到几百字节)不能区分分片,跳过。
placeholderSize := isStrm && m.SizeBytes > 0 && m.SizeBytes < 1024*1024
if m.SizeBytes > 0 && !placeholderSize {
parts = append(parts, formatMediaSize(m.SizeBytes))
}
if len(parts) > 0 {
if len(parts) > 0 && !mediaVersionLabelIndistinct(parts) {
return strings.Join(parts, " · ")
}
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/"))
if base == "" || base == "." {
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
if cleaned := cleanMediaVersionFilename(m.Path); cleaned != "" {
return cleaned
}
return base
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
}
func mediaVersionLabelIndistinct(parts []string) bool {
if len(parts) == 0 {
return true
}
for _, part := range parts {
upper := strings.ToUpper(strings.TrimSpace(part))
if upper == "" || upper == "云端直链" || upper == "STRM" {
continue
}
if _, ok := videoExtensions["."+strings.ToLower(upper)]; ok {
continue
}
return false
}
return true
}
func cleanMediaVersionFilename(path string) string {
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
if base == "" || base == "." {
return ""
}
stem := mediaFileStem(base)
stem = strings.ReplaceAll(stem, "_", " ")
stem = strings.ReplaceAll(stem, ".", " ")
return strings.Join(strings.Fields(stem), " ")
}
// mediaPartNumber 从路径提取分片序号(part1 / cd2 / -3)。
// 裸后缀只认 1–2 位数字,避免把年份 2024 当成 part。
func mediaPartNumber(path string) (int, bool) {
stem := mediaFileStem(filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")))
if stem == "" {
return 0, false
}
lower := strings.ToLower(stem)
for _, re := range mediaPartNumberPatterns {
if m := re.FindStringSubmatch(lower); len(m) == 2 {
n, err := strconv.Atoi(m[1])
if err == nil && n > 0 {
return n, true
}
}
}
return 0, false
}
var mediaPartNumberPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?:^|[.\-_])part[\s._-]*(\d{1,3})$`),
regexp.MustCompile(`(?:^|[.\-_])(?:cd|disc|disk)[\s._-]*(\d{1,3})$`),
regexp.MustCompile(`[-_](\d{1,2})$`),
}
func sortMediaVersionsForDisplay(rows []model.Media) {
sort.SliceStable(rows, func(i, j int) bool {
return compareMediaVersionsForDisplay(rows[i], rows[j])
})
}
// compareMediaVersionsForDisplay:有分片号时按 1→2→3… 升序;否则按画质/体积择优。
func compareMediaVersionsForDisplay(a, b model.Media) bool {
pa, oka := mediaPartNumber(a.Path)
pb, okb := mediaPartNumber(b.Path)
if oka && okb {
if pa != pb {
return pa < pb
}
} else if oka != okb {
return oka
}
return betterMediaVersion(a, b)
}
func mediaVersionContainerFromPath(path, strmURL string) string {
@@ -0,0 +1,66 @@
package service
import (
"strconv"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 真实场景:同一部片的分片里只有第 4 个被刮削成功(nsfw=true),其余仍是 pending。
// 修复前:已刮削的分片走「番号」分组、其余落到「标题」分组,7 个分片会裂成两张卡
// (一张只有第 4 个,另一张是其余 6 个)。
func TestGroupMediaVersionsKeepsUnscrapedPartsWithScrapedSibling(t *testing.T) {
const libID = "lib-1"
const dir = "/media/云下载/sivr-270/"
parts := func(nums ...int) []model.Media {
out := make([]model.Media, 0, len(nums))
for _, n := range nums {
out = append(out, model.Media{
LibraryID: libID,
Title: "sivr 270",
Path: dir + "sivr-270-" + strconv.Itoa(n) + ".strm",
Container: "strm",
})
}
return out
}
items := parts(1, 2, 3)
items = append(items, model.Media{
LibraryID: libID,
Title: "SIVR-270-【VR】河北彩花×ご奉仕ナース",
OriginalName: "SIVR-270",
Path: dir + "sivr-270-4.strm",
NSFW: true,
ScrapeStatus: "matched",
Container: "strm",
})
items = append(items, parts(5, 6, 7)...)
grouped := GroupMediaVersions(items)
if len(grouped) != 1 {
paths := make([]string, 0, len(grouped))
for _, g := range grouped {
paths = append(paths, g.Path)
}
t.Fatalf("分成 %d 张卡 %v,期望折叠成 1 张", len(grouped), paths)
}
if len(grouped[0].Versions) != 7 {
t.Fatalf("版本数 = %d,期望 7", len(grouped[0].Versions))
}
}
// 反向保护:没有任何分片被确认为成人内容时,不能仅凭文件名里的番号形态折叠,
// 否则普通媒体也可能被误并进同一张卡。
func TestGroupMediaVersionsIgnoresUnvouchedAdultCode(t *testing.T) {
items := []model.Media{
{LibraryID: "lib-2", Title: "Alpha Movie", Path: "/media/movies/Alpha Movie/ABC-123-1080p.mkv"},
{LibraryID: "lib-2", Title: "Beta Movie", Path: "/media/movies/Beta Movie/ABC-123-2160p.mkv"},
}
grouped := GroupMediaVersions(items)
if len(grouped) != 2 {
t.Fatalf("分成 %d 张卡,期望 2 张(未被确认成人内容的番号不参与折叠)", len(grouped))
}
}
+134
View File
@@ -443,6 +443,88 @@ func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) {
}
}
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 分组会把它们裂成两张标题完全相同的卡,必须按番号折成一张。
@@ -566,6 +648,58 @@ func TestGroupMediaVersionsMergesAdultPartsWithBorrowedMetaTubeIDs(t *testing.T)
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) {
+3 -2
View File
@@ -33,8 +33,9 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re
if !cloudPlaybackModeEnabled(r.Context(), s.repo, cloudMode) {
return ErrCloudPlaybackDisabled
}
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host。
target := normalizeCloudPlayTarget(strmURL)
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host;
// 指向别的 MeBox 实例的地址保持原样,按第三方直链透传。
target := normalizeCloudPlayTarget(r.Context(), s.repo, s.cfg, r, strmURL)
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
setCloudRedirectNoStore(w)
http.Redirect(w, r, absoluteInternalRedirect(target, r), http.StatusFound)
+85 -9
View File
@@ -1,19 +1,28 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// TestNormalizeCloudPlayTarget 验证存库的云盘播放 URL(可能携带扫描时的
// 旧 host)被规范化为相对路径,使 302 始终基于当前请求地址构造。
// TestNormalizeCloudPlayTarget 验证存库的云盘播放 URL(可能携带扫描时的旧 host)
// 被规范化为相对路径,使 302 始终基于当前请求地址构造。
func TestNormalizeCloudPlayTarget(t *testing.T) {
ref := "/电影/某部影片 (2024)/movie.mkv"
stale := "http://192.168.1.4:9011/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
got := normalizeCloudPlayTarget(stale)
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/media-1", nil)
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)
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)
if err != nil {
@@ -26,16 +35,83 @@ func TestNormalizeCloudPlayTarget(t *testing.T) {
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"
gotStrm := normalizeCloudPlayTarget(strmStale)
// 绝对地址但 host 就是当前请求 host:宿主切换过(开发机扫描 → 部署)
// 之后仍要能当作本机地址处理。
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"
if gotStrm != wantStrm {
t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm)
t.Fatalf("normalizeCloudPlayTarget(same host) = %q, want %q", gotStrm, wantStrm)
}
// 非云盘播放 URL 保持原样(WebDAV/直链等)。
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)
}
}
// 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)
}
}
+184 -11
View File
@@ -1,9 +1,13 @@
package service
import (
"context"
"net/http"
"net/url"
"strings"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/repository"
)
// normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。
@@ -11,28 +15,197 @@ import (
// STRMURL 是扫描时根据当时的 server_url/请求地址生成并固化进数据库的。
// 在 Windows 开发机上扫描、再部署到 Docker(或更换了内网 IP/域名)后,
// 这些绝对 URL 会指向已失效的旧地址,第三方播放器跟随 302 就会拿到
// 连接失败/404。这里只要能从 URL 中解析出 provider+ref,就重建为相对
// /api/cloud/play 路径,由 absoluteInternalRedirect 基于「当前请求」补全
// host,从而对历史脏数据免疫。
func normalizeCloudPlayTarget(raw string) string {
// 连接失败/404。所以只要确认这个地址「是本机自己生成的」,就从 URL 中解析出
// provider+ref,重建为相对 /api/cloud/play 或 /api/strm/play 路径,由
// absoluteInternalRedirect 基于「当前请求」补全 host,从而对历史脏数据免疫。
//
// 反过来,指向**别的 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)
if raw == "" {
return raw
}
if !isInternalPlaybackTarget(ctx, repo, cfg, r, raw) {
return raw
}
if typ, ref, ok := parseCloudMediaPlaybackURL(raw); ok {
return BuildRelativeCloudPlayURL(typ, ref)
}
if u, err := url.Parse(raw); err == nil {
path := strings.ToLower(u.Path)
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()
}
u, err := url.Parse(raw)
if err != nil {
return raw
}
path := strings.ToLower(u.Path)
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
}
// 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 路径。
func BuildRelativeCloudPlayURL(typ, ref string) string {
return "/api/cloud/play/" + url.PathEscape(strings.TrimSpace(typ)) + "?" + url.Values{"ref": []string{ref}}.Encode()
+72
View File
@@ -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) {
repos := newStreamTestRepo(t)
if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeRedirectProxy {
+106 -12
View File
@@ -121,7 +121,7 @@ func (s *StrmService) resolveLocalPlay(ctx context.Context, rawPath string) (*St
// 支持:
// - /api/strm/play/{provider}/video{ext}?acct=..&pickcode=.. (常规格式,含账号)
// - /api/cloud/play/{type}?ref=.. (旧格式,无账号 → 取该类型第一个启用账号)
// - 绝对 http(s) 链接(直接透传)
// - 绝对 http(s) 链接(直接透传,包含别的 MeBox / MediaStationGo 实例的播放端点)
// - 其余协议(webdav:// 等)返回错误,由调用方决定是否静默跳过
func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmPlayResult, error) {
raw = strings.TrimSpace(raw)
@@ -132,6 +132,17 @@ func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmP
if err != nil {
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)
switch {
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 返回指定提供方第一个凭据可用的启用账号。
func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string) (*model.StrmAccount, error) {
accounts, err := s.repo.StrmAccount.List(ctx)
@@ -174,38 +195,111 @@ func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string
return nil, nil
}
// ErrStrmProxyNotApplicable 表示该媒体没有需要服务端转发的直链(本地文件本身同源)。
var ErrStrmProxyNotApplicable = errors.New("strm proxy not applicable")
// ProxyMediaDirect 把媒体行的网盘/STRM 直链解析为真实地址后由服务端反向代理给
// 客户端,让浏览器拿到「同源」数据。画质与原文件完全一致,不触发任何转码。
//
// 用途:VR 全景播放要把视频帧读进 WebGL 纹理,而跨域直链(网盘 302 跳到 CDN)
// 在浏览器里属于被污染的资源,WebGL 读取会抛 SecurityError;把流量经服务端转发
// 是「原画 + VR」唯一可行的办法。
func (s *StrmService) ProxyMediaDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, m *model.Media) error {
if s == nil || m == nil {
return ErrStrmProxyNotApplicable
}
raw := strings.TrimSpace(m.STRMURL)
if raw == "" {
path := strings.TrimSpace(m.Path)
if !strings.HasSuffix(strings.ToLower(path), ".strm") {
return ErrStrmProxyNotApplicable
}
target, err := readLocalSTRMTarget(path)
if err != nil {
return err
}
raw = strings.TrimSpace(target)
}
if raw == "" {
return ErrStrmProxyNotApplicable
}
result, err := s.ResolvePlayTarget(ctx, raw)
if err != nil {
return err
}
switch {
case result.Link != nil && result.Link.URL != "":
return s.ProxyDirect(ctx, w, r, result.Link)
case result.RedirectURL != "":
return s.ProxyDirect(ctx, w, r, &cloud.DirectLink{URL: result.RedirectURL})
default:
// 本地文件(LocalPath)由静态文件处理器提供,本身就是同源。
return ErrStrmProxyNotApplicable
}
}
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
if link == nil || link.URL == "" {
return errors.New("空直链")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil)
method := http.MethodGet
if r != nil && r.Method == http.MethodHead {
method = http.MethodHead
}
req, err := http.NewRequestWithContext(ctx, method, link.URL, nil)
if err != nil {
return err
}
for k, v := range link.Headers {
req.Header.Set(k, v)
}
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
if r != nil {
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
// 部分网盘直链按 UA 防盗链;解析时未绑定 UA 的直链沿用浏览器 UA 更稳。
if ua := strings.TrimSpace(r.Header.Get("User-Agent")); ua != "" && req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", ua)
}
}
resp, err := s.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag"} {
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
if value := resp.Header.Get(header); value != "" {
w.Header().Set(header, value)
}
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(resp.StatusCode)
// 原样透传上游状态码:Range 请求必须回 206,改写成 200 会让浏览器误判
// 响应长度,拖动进度条时反复重新拉流。
w.WriteHeader(resp.StatusCode)
if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK {
return nil
}
if resp.StatusCode == http.StatusPartialContent || resp.StatusCode == http.StatusOK {
_, _ = io.Copy(w, resp.Body)
if method == http.MethodHead {
return nil
}
return nil
// 边转发边 flush,避免大体积视频被 net/http 的写缓冲切成一段段卡顿。
writer := io.Writer(w)
if flusher, ok := w.(http.Flusher); ok {
writer = &flushWriter{writer: w, flusher: flusher}
}
_, err = io.Copy(writer, resp.Body)
return err
}
type flushWriter struct {
writer io.Writer
flusher http.Flusher
}
func (f *flushWriter) Write(p []byte) (int, error) {
n, err := f.writer.Write(p)
if f.flusher != nil {
f.flusher.Flush()
}
return n, err
}
+74
View File
@@ -0,0 +1,74 @@
package service
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 同源转发必须原样保留 Range 语义:把 206 改写成 200 会让浏览器误判响应长度,
// 拖动进度条时反复重新拉流。
func TestProxyMediaDirectForwardsRangeAndStatus(t *testing.T) {
var gotRange string
var gotUA string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRange = r.Header.Get("Range")
gotUA = r.Header.Get("User-Agent")
w.Header().Set("Content-Type", "video/mp4")
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Content-Range", "bytes 10-19/100")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("0123456789"))
}))
defer upstream.Close()
svc := testStrmService(t)
media := &model.Media{STRMURL: upstream.URL + "/video.mp4"}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
req.Header.Set("Range", "bytes=10-19")
req.Header.Set("User-Agent", "MeBoxTest/1.0")
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
t.Fatalf("ProxyMediaDirect: %v", err)
}
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", rec.Code)
}
if gotRange != "bytes=10-19" {
t.Fatalf("upstream Range = %q, want bytes=10-19", gotRange)
}
if gotUA != "MeBoxTest/1.0" {
t.Fatalf("upstream User-Agent = %q, want MeBoxTest/1.0", gotUA)
}
if rec.Header().Get("Content-Range") != "bytes 10-19/100" {
t.Fatalf("Content-Range = %q", rec.Header().Get("Content-Range"))
}
if rec.Body.String() != "0123456789" {
t.Fatalf("body = %q", rec.Body.String())
}
}
// 本地文件本身就是同源资源,不应该被代理(调用方按原静态文件逻辑处理)。
func TestProxyMediaDirectSkipsLocalFile(t *testing.T) {
svc := testStrmService(t)
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
writeFile(t, path, "not-a-video")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
err := svc.ProxyMediaDirect(context.Background(), rec, req, &model.Media{Path: path})
if !errors.Is(err, ErrStrmProxyNotApplicable) {
t.Fatalf("err = %v, want ErrStrmProxyNotApplicable", err)
}
if rec.Body.Len() != 0 {
t.Fatalf("local file must not be proxied, body = %q", rec.Body.String())
}
}
+14 -2
View File
@@ -746,7 +746,10 @@ func TestHandleMetaSha1Identity(t *testing.T) {
// fakeRemoteProvider 是 walkRemote 并发遍历的假提供方:返回一棵固定目录树,
// 并记录每个目录被 List 的次数,用于验证并发遍历无漏目录、无重复目录。
// walkRemote 会并发调用 List,因此计数必须加锁:此前直接写 map 会偶发
// "fatal error: concurrent map writes",把整个测试进程带走。
type fakeRemoteProvider struct {
mu sync.Mutex
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
}
// 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) {
f.mu.Lock()
if f.listed == nil {
f.listed = map[string]int{}
}
f.listed[dirID]++
f.mu.Unlock()
switch dirID {
case "root":
return []cloud.FileEntry{
@@ -828,8 +840,8 @@ func TestWalkRemoteConcurrent(t *testing.T) {
st.flushPreferredVideos()
for _, dir := range []string{"root", "a", "a1", "b"} {
if provider.listed[dir] != 1 {
t.Errorf("目录 %s 被列出 %d 次,期望 1 次", dir, provider.listed[dir])
if count := provider.listedCount(dir); count != 1 {
t.Errorf("目录 %s 被列出 %d 次,期望 1 次", dir, count)
}
}
+38 -9
View File
@@ -410,14 +410,10 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
}
return s.serveEmbedded(ctx, m, index, w)
}
abs, err := filepath.Abs(sub)
abs, err := readExternalSubtitlePath(m, sub)
if err != nil {
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.
if err != nil {
@@ -428,14 +424,17 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
if err != nil {
return err
}
// 非 UTF-8 的外挂字幕(UTF-16、GBK/Big5 等)必须先归一化:浏览器只能按
// UTF-8 解析 <track> 内容,否则整篇都会变成替换字符。
text := decodeSubtitleText(body)
switch strings.ToLower(filepath.Ext(abs)) {
case ".vtt":
_, err = w.Write(body)
_, err = io.WriteString(w, text)
case ".srt":
_, err = w.Write([]byte(srtToVTT(string(body))))
_, err = io.WriteString(w, srtToVTT(text))
case ".ass", ".ssa":
_, err = w.Write([]byte(assToVTT(string(body))))
_, err = io.WriteString(w, assToVTT(text))
default:
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)) {
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:
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 {
input, err := s.resolveInput(ctx, media)
if err != nil {
+167
View File
@@ -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
}
+169
View File
@@ -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)
}
}
+7 -2
View File
@@ -141,8 +141,13 @@ const profileQuery = () => {
// streamURL returns a direct-play URL for <video src>. The JWT is added as
// a query parameter because <video> elements cannot send Authorization
// headers.
export function streamURL(mediaId: string): string {
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}`
//
// proxy=true 时由服务端把网盘/STRM 直链转发为同源数据(画质与原文件一致、
// 不转码)。VR 全景渲染需要把视频帧读进 WebGL 纹理,跨域直链会被浏览器
// 判定为污染资源而禁止读取,因此只有这种场景才需要开启。
export function streamURL(mediaId: string, options: { proxy?: boolean } = {}): string {
const proxy = options.proxy ? '&proxy=1' : ''
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}${proxy}`
}
// hlsURL returns the m3u8 playlist URL fed into hls.js.
+4
View File
@@ -72,6 +72,8 @@ export interface DanmakuConfig {
area: string
volume: number
playback_rate: number
/** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */
vr360_guide_seen: boolean
/** Per-user preference: merge the same episode's multiple sources. */
merge_sources: boolean
}
@@ -89,6 +91,8 @@ export interface DanmakuSettingsPatch {
merge_sources?: boolean
volume?: number
playback_rate?: number
/** 看过 VR 操作说明后置为 true,之后不再弹出。 */
vr360_guide_seen?: boolean
}
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
+23 -48
View File
@@ -6,26 +6,23 @@ import { isStrmMedia, mediaVersionLabel, mediaVersionsOf } from '../utils/mediaV
type MediaVersionSwitcherProps = {
media: Media
/** 详情页:用 Link 跳转播放;播放页:回调切换 */
mode?: 'detail' | 'player'
onSelect?: (version: Media) => void
className?: string
}
export function MediaVersionSwitcher({
media,
mode = 'detail',
onSelect,
className = '',
}: MediaVersionSwitcherProps) {
/**
* 详情页的版本切换。
*
* 播放器内不再单独浮出一块版本条:多版本切换统一收在「选集」面板里
* (见 PlayerPlaylistPanel),这里只负责详情页的同片多版本跳转。
*/
export function MediaVersionSwitcher({ media, className = '' }: MediaVersionSwitcherProps) {
const versions = mediaVersionsOf(media)
if (versions.length <= 1) return null
const dark = mode === 'player'
return (
<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'}`}>
<Layers size={14} className={dark ? 'text-white/60' : 'text-sand-500'} />
<div className="flex items-center gap-2 text-sm font-semibold text-ink-600">
<Layers size={14} className="text-sand-500" />
<span>版本({versions.length})</span>
</div>
<div className="flex flex-wrap gap-2">
@@ -33,42 +30,10 @@ export function MediaVersionSwitcher({
const active = version.id === media.id
const isStrm = isStrmMedia(version)
const label = mediaVersionLabel(version)
const btnClass = dark
? active
? 'border-white/50 bg-white/20 text-white shadow-sm ring-1 ring-white/20'
: '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 btnClass = 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 (
<Link
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}`}
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>
)
})}
File diff suppressed because it is too large Load Diff
+27 -10
View File
@@ -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 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
// the matched danmaku details (anime title, episode title, comment count,
@@ -55,6 +56,11 @@ type PlayerDanmakuPanelProps = {
danmakuInfo?: DanmakuLoadedInfo | null
onSelectEpisode: (episodeId: number, animeTitle: string, episodeTitle: string) => void
onResetAuto: () => void
/**
* 竖屏剧场模式:弹幕面板改为视频区底部的动作面板,而不是右侧抽屉,
* 窄屏下搜索框与滑杆不再被压扁。
*/
theater?: boolean
}
export function PlayerDanmakuPanel({
@@ -89,6 +95,7 @@ export function PlayerDanmakuPanel({
danmakuInfo,
onSelectEpisode,
onResetAuto,
theater = false,
}: PlayerDanmakuPanelProps) {
const [draft, setDraft] = useState(search)
const [advancedOpen, setAdvancedOpen] = useState(false)
@@ -194,27 +201,36 @@ export function PlayerDanmakuPanel({
return (
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
// 排布和选集抽屉完全一致(整条贴住右边缘、标题栏常驻),两个面板互斥打开时
// 位置不会跳,用户也不用重新找入口。
<div
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 ? PLAYER_SHEET : `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
}
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-semibold">
<MessageSquareText size={16} className="text-rose-400" /> 弹幕设置
{settingsSaving && <Loader2 size={12} className="animate-spin text-rose-300" />}
<div className={theater ? PLAYER_SHEET_HEADER : PLAYER_PANEL_HEADER}>
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
<MessageSquareText size={16} className="shrink-0 text-rose-400" />
<span className="truncate">弹幕设置</span>
{settingsSaving && <Loader2 size={12} className="shrink-0 animate-spin text-rose-300" />}
</div>
<button
onClick={onClose}
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
title="关闭"
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-10 w-10' : ''}`}
title="关闭 (Esc)"
>
<X size={16} />
<X size={theater ? 20 : 16} />
</button>
</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">
<span className="text-white/85">加载弹幕</span>
<span className="text-white/85">显示弹幕</span>
<input
type="checkbox"
checked={enabled}
@@ -573,6 +589,7 @@ export function PlayerDanmakuPanel({
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,199 @@
import { useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import { ChevronDown, 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
/** 选集列表是否展开(展开后由 playlistPanel 接管,提供季/搜索/版本)。 */
playlistOpen: boolean
onTogglePlaylist: () => void
/** 展开状态下的选集内容(复用播放器的选集面板,内嵌渲染)。 */
playlistPanel?: ReactNode
/**
* 每次从操作栏点「选集」都会 +1:即使列表已经展开,也要把这块滚回视野内。
*/
revealToken?: number
}
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,
playlistOpen,
onTogglePlaylist,
playlistPanel,
revealToken = 0,
}: PlayerMobileTheaterInfoProps) {
const episodeSectionRef = useRef<HTMLElement | null>(null)
const hasEpisodeList = episodes.length > 1
const versionsToSwitch = currentVersions.length > 1 ? currentVersions : []
const overview = media?.overview?.trim() || ''
// 从操作栏点「选集」:把选集区滚回视野内,用户不用自己往上翻。
useEffect(() => {
if (!revealToken) return
episodeSectionRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' })
}, [revealToken])
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 ref={episodeSectionRef} className="mt-4 scroll-mt-2">
<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>
<button
type="button"
onClick={onTogglePlaylist}
className="flex min-h-8 items-center gap-1 rounded-md px-2 text-[11px] text-white/60 transition hover:bg-white/10 hover:text-white"
>
{playlistOpen ? '收起' : '全部'}
<ChevronDown
size={13}
className={`transition-transform ${playlistOpen ? 'rotate-180' : ''}`}
/>
</button>
</div>
{playlistOpen && playlistPanel ? (
playlistPanel
) : (
<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 && !playlistOpen ? (
<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>
)
}
+287 -126
View File
@@ -1,9 +1,16 @@
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 { seasonLabel, seasonSortOrder, seriesTitleFromPath } from '../utils/groupSeries'
import {
isStrmMedia,
mediaVersionFileName,
mediaVersionLabel,
mediaVersionMatches,
mediaVersionSourceLabel,
} from '../utils/mediaVersion'
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER } from './playerTheme'
export type SeasonGroup = {
season: number
@@ -14,20 +21,46 @@ type PlayerPlaylistPanelProps = {
open: boolean
onClose: () => void
currentMediaId: string
/** 当前正在播放媒体的全部版本(含自身)。多版本时面板顶部展示版本切换。 */
currentVersions?: Media[]
episodes: Media[]
onSelectEpisode: (media: Media) => void
/** 切换到同一集/同一条目的另一个版本。 */
onSelectVersion?: (media: Media) => void
/**
* 竖屏剧场模式:不再以浮层形式盖住播放画面,而是内嵌在视频下方的
* 内容区里(外层负责标题与「收起」按钮),选集不再遮挡播放器。
*/
inline?: boolean
}
/**
* 选集面板 —— 右侧整条抽屉,内容参考 B 站的番剧选集。
*
* 以前这里是「悬浮卡片 + 每集一行缩略图」,两三百集的库翻起来很累,卡片本身
* 也和画面上别的浮层长得不一样。现在改成:整条贴住播放区右边缘的抽屉,剧集用
* 编号宫格排(一屏能看到几十集),标题/时长放在悬停提示里;当前播放的那一格用
* 强调色填充。
*
* 多版本(同一部片的 4K / 1080p / 云端直链等)依旧不丢:宫格右上角的小角标表示
* 这一集有多个版本,点角标会在宫格上方展开版本列表;当前正在播放的条目的版本
* 则常驻在抽屉顶部,随时可换。单条媒体(电影)没有剧集列表时,抽屉本身就是
* 版本选择器。
*/
export function PlayerPlaylistPanel({
open,
onClose,
currentMediaId,
currentVersions = [],
episodes,
onSelectEpisode,
onSelectVersion,
inline = false,
}: PlayerPlaylistPanelProps) {
const [filterText, setFilterText] = useState('')
const activeItemRef = useRef<HTMLDivElement | null>(null)
const listContainerRef = useRef<HTMLDivElement | null>(null)
/** 正在挑选版本的剧集 id;null = 宫格直接播放。 */
const [versionsForEpisodeId, setVersionsForEpisodeId] = useState<string | null>(null)
const activeItemRef = useRef<HTMLButtonElement | null>(null)
// 按季分组
const seasonGroups = useMemo<SeasonGroup[]>(() => {
@@ -46,9 +79,9 @@ export function PlayerPlaylistPanel({
.map(([season, list]) => ({ season, episodes: list }))
}, [episodes])
// 当前播放所在季
// 当前播放所在季(播放中的可能是某个剧集行的非首个版本,需按版本组比对)
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
return found.episode_num > 0 ? (found.season_num ?? 0) : (found.season_num || 1)
}, [episodes, currentMediaId, seasonGroups])
@@ -62,15 +95,19 @@ export function PlayerPlaylistPanel({
}
}, [open, currentSeason])
// 换集/换季时收起版本列表:那张列表是针对上一集的,留着会让人误点。
useEffect(() => {
setVersionsForEpisodeId(null)
}, [open, selectedSeason, currentMediaId])
// 当面板打开时,自动平滑滚动到当前播放集的位置
useEffect(() => {
if (open && activeItemRef.current) {
const timer = setTimeout(() => {
activeItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}, 100)
return () => clearTimeout(timer)
}
}, [open, selectedSeason, currentMediaId])
if (!open || versionsForEpisodeId) return
const timer = setTimeout(() => {
activeItemRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' })
}, 100)
return () => clearTimeout(timer)
}, [open, selectedSeason, currentMediaId, versionsForEpisodeId])
if (!open) return null
@@ -86,35 +123,109 @@ export function PlayerPlaylistPanel({
})
: 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 (
<div
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={
inline
? 'flex flex-col'
: `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
}
>
{/* 头部 */}
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 shrink-0">
<div className="flex items-center gap-2 text-sm font-semibold">
<ListVideo size={17} className="text-rose-400" />
<span>选集列表</span>
<span className="font-mono text-xs font-normal text-white/50">
({episodes.length} 集)
</span>
{/* 内嵌在竖屏剧场布局的视频下方时不自带标题栏:外层已经提供了
「选集 / 收起」标题,重复一层只会挤掉内容。 */}
{inline ? null : (
<div className={PLAYER_PANEL_HEADER}>
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
<span className="truncate">{hasEpisodeList ? '选集' : '版本'}</span>
<span className="shrink-0 font-mono text-[11px] font-normal text-white/45">
{hasEpisodeList ? `${episodes.length} 集` : `${versionsToSwitch.length} 个版本`}
</span>
</div>
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
<X size={16} />
</button>
</div>
<button
onClick={onClose}
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
title="关闭"
>
<X size={16} />
</button>
</div>
)}
<div className={inline ? undefined : '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(若有多季) */}
{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">
{hasEpisodeList && seasonGroups.length > 1 && (
<div className="scrollbar-hide 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 }) => {
const isSelected = selectedSeason === season
const isPlayingThisSeason = sesEps.some((e) => e.id === currentMediaId)
const isPlayingThisSeason = sesEps.some((e) => mediaVersionMatches(e, currentMediaId))
return (
<button
key={season}
@@ -140,22 +251,19 @@ export function PlayerPlaylistPanel({
)}
{/* 搜索/过滤单集(当单集数量较多时) */}
{listToDisplay.length > 10 && (
<div className="px-3 pt-2.5 pb-1.5 shrink-0">
<div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs">
<Search size={13} className="text-white/40 shrink-0" />
{hasEpisodeList && listToDisplay.length > 10 && (
<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.5 text-xs">
<Search size={13} className="shrink-0 text-white/40" />
<input
type="text"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
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 && (
<button
onClick={() => setFilterText('')}
className="text-white/40 hover:text-white"
>
<button onClick={() => setFilterText('')} className="text-white/40 hover:text-white">
<X size={12} />
</button>
)}
@@ -163,100 +271,153 @@ export function PlayerPlaylistPanel({
</div>
)}
{/* 集数列表 */}
<div
ref={listContainerRef}
className="flex-1 overflow-y-auto p-2.5 space-y-1.5 pr-2 select-none"
>
{filteredEpisodes.length === 0 ? (
<div className="py-8 text-center text-xs text-white/40">
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
</div>
) : (
filteredEpisodes.map((ep) => {
const isPlaying = ep.id === currentMediaId
const displayTitle = getEpisodeTitle(ep, listToDisplay)
const durationText =
ep.duration_sec > 0 ? `${Math.floor(ep.duration_sec / 60)} 分钟` : ''
{/* 集数宫格:内嵌模式整块跟着外层一起滚动,不再自己滚 */}
{hasEpisodeList ? (
<div className={inline ? 'p-3 select-none' : 'min-h-0 flex-1 overflow-y-auto p-3 select-none'}>
{filteredEpisodes.length === 0 ? (
<div className="py-10 text-center text-xs text-white/40">
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
</div>
) : (
<div
className={
inline
? 'grid grid-cols-6 gap-1.5'
: 'grid grid-cols-5 gap-1.5 sm:grid-cols-6'
}
>
{filteredEpisodes.map((ep) => {
const isPlaying = mediaVersionMatches(ep, currentMediaId)
const versions = ep.versions && ep.versions.length > 1 ? ep.versions : []
const displayTitle = getEpisodeTitle(ep, listToDisplay)
const durationText =
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 (
<div
key={ep.id}
ref={isPlaying ? activeItemRef : null}
onClick={() => onSelectEpisode(ep)}
className={`group flex cursor-pointer items-center gap-2.5 rounded-xl p-2 transition border ${
isPlaying
? 'border-rose-500/60 bg-rose-500/20 text-white'
: 'border-white/5 bg-white/5 hover:border-white/20 hover:bg-white/10 text-white/85'
}`}
>
{/* 封面/集号 */}
<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'
return (
<div key={ep.id} className="relative">
<button
ref={isPlaying ? activeItemRef : null}
onClick={() => onSelectEpisode(ep)}
title={cellTitle}
className={`flex ${inline ? 'h-10' : 'h-9'} w-full items-center justify-center rounded-md text-xs font-medium tabular-nums transition ${
isPlaying
? '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'
}`}
>
{displayTitle}
</p>
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-white/50">
{ep.episode_num > 0 && (
<span className="font-mono">第 {ep.episode_num} 集</span>
{isPlaying ? (
<Play size={12} className="fill-current" />
) : ep.episode_num > 0 ? (
ep.episode_num
) : (
<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 ${inline ? 'h-5 w-5 text-[10px]' : 'h-3.5 w-3.5 text-[9px]'} items-center justify-center rounded-bl-md rounded-tr-md 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>
{isPlaying && (
<div className="shrink-0 flex items-center gap-1 text-[11px] font-medium text-rose-400 px-1">
<Check size={13} />
</div>
)}
</div>
)
})
)}
)
})}
</div>
)}
</div>
) : (
versionsToSwitch.length === 0 && (
<div className="flex items-center justify-center px-4 py-8 text-center text-xs text-white/40">
暂无可切换的内容
</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 {
const title = ep.episode_title?.trim()
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
+690
View File
@@ -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>
</>
)
}
+102
View File
@@ -0,0 +1,102 @@
/**
* 播放器浮层的统一视觉语言。
*
* 播放器里现在有十几种浮层:底部操作栏、清晰度/倍速/字幕小弹层、设置面板、
* 选集抽屉、弹幕面板、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`
/**
* 底部动作面板(竖屏剧场模式):贴着整个屏幕底部向上弹出,高度按视口
* 计算(最高 86dvh),因此手机上能容纳的选项远多于贴视频区弹出的版本。
*
* 用 fixed 而不是 absolute:播放区在竖屏剧场模式里只有 16:9 一小块,
* 若以它为定位基准,面板最高只能到视频高度的 72%,内容会被压得很窄。
*/
export const PLAYER_SHEET =
`player-sheet-height fixed inset-x-0 bottom-0 z-40 flex min-h-[10rem] flex-col overflow-hidden rounded-t-2xl border border-b-0 border-white/10 ${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'
/**
* 底部动作面板的可滚动内容区:底部垫上安全区高度避免 iPhone 小横条遮挡,
* 并把面板内所有按钮抬到 40px,手机上手指点得准。
*/
export const PLAYER_SHEET_BODY =
'min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-2 [&_button]:min-h-10'
/** 操作栏里的图标按钮。所有图标按钮共用同一尺寸与悬停反馈,排在一起才整齐。 */
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'
+32
View File
@@ -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
}
+8
View File
@@ -272,6 +272,14 @@ body {
}
.scrollbar-hide::-webkit-scrollbar { display: none; }
/* ── 播放器底部动作面板的高度上限 ──
手机上按视口高度算(86%),老浏览器不认识 dvh 时退回 vh,避免面板
在 flex 容器里被内容撑出屏幕。 */
.player-sheet-height {
max-height: 86vh;
max-height: 86dvh;
}
/* ─── Reduced Motion ─── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
+1 -1
View File
@@ -164,7 +164,7 @@ export function MediaDetailMainContent({
onToggleFavourite={onToggleFavourite}
playTargetId={playTargetId}
/>
<MediaVersionSwitcher media={media} mode="detail" />
<MediaVersionSwitcher media={media} />
{isAdmin && (
<MediaDetailAdminPanel
media={media}
+332 -49
View File
@@ -19,6 +19,7 @@ import { profileAPI } from '../api/profile'
import { useAuthStore } from '../stores/auth'
import type { Media, PlaybackInfo, PlaybackQuality } from '../types'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
import { normalizePlaybackRate } from '../utils/playbackRate'
import { isRemoteEmbyID } from '../utils/remoteEmby'
import {
@@ -33,15 +34,23 @@ import {
type SubtitlePosition,
type SubtitleStylePreset,
} from '../utils/subtitleDisplay'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel'
import { classifyDirectPlayError } from './directPlayError'
import { apiErrorMessage } from './StrmManagePage'
import { PlayerTopBar } from './PlayerTopBar'
import { PlayerVideoStage } from './PlayerVideoStage'
import { PlayerMobileTheaterInfo } from '../components/PlayerMobileTheaterInfo'
import { useIsMobileTheater } from '../hooks/useIsMobileTheater'
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
import { mediaVersionsOf } from '../utils/mediaVersion'
import {
detectVr360Profile,
loadVr360Preference,
sameVr360Profile,
saveVr360Preference,
type Vr360Detection,
type Vr360Profile,
} from '../utils/vr360'
// Fullscreen, dark-themed video page.
//
@@ -96,6 +105,22 @@ export function PlayerPage() {
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [media, setMedia] = useState<Media | null>(null)
// VR 全景播放:null = 普通播放。识别结果与用户开关只存本机,
// 不改动数据库,换设备/换浏览器不会互相影响。
const [vr360, setVr360] = useState<Vr360Profile | null>(null)
const [vr360Detected, setVr360Detected] = useState(false)
const vr360TouchedRef = useRef(false)
// VR 取帧失败后的自愈:只尝试一次「切到 HLS」,并在切换窗口内忽略重复报错。
const vr360TextureRetryUsedRef = useRef(false)
const vr360TextureRetryUntilRef = useRef(0)
// VR 全景下 STRM/网盘直连必须由服务端同源转发,浏览器才允许 WebGL 读取画面;
// 普通播放仍然 302 直连,避免把网盘流量压到服务器。放在 ref 里是为了让
// handleVideoError 里的 src 比对始终拿到最新值。
const vr360DirectProxy = Boolean(vr360) && isStrmMedia(media)
const vr360DirectProxyRef = useRef(vr360DirectProxy)
useEffect(() => {
vr360DirectProxyRef.current = vr360DirectProxy
}, [vr360DirectProxy])
const [mode, setMode] = useState<PlayerMode>('direct')
const [subs, setSubs] = useState<SubtitleTrack[]>([])
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
@@ -114,6 +139,9 @@ export function PlayerPage() {
normalizePlaybackRate(authUser?.player_playback_rate),
)
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
// VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来,
// 这时不弹说明,避免给老用户闪一下。
const [vr360GuideSeen, setVr360GuideSeen] = useState<boolean | null>(null)
const playerVolumeTouchedRef = useRef(false)
const playerPlaybackRateTouchedRef = useRef(false)
const playerPlaybackRateSaveSeqRef = useRef(0)
@@ -171,6 +199,8 @@ export function PlayerPage() {
// 选集 / 播放列表状态
const [playlistEpisodes, setPlaylistEpisodes] = useState<Media[]>([])
const [playlistOpen, setPlaylistOpen] = useState(false)
// 竖屏剧场模式:每次从操作栏点「选集」都 +1,把视频下方的选集区滚回视野。
const [episodeRevealToken, setEpisodeRevealToken] = useState(0)
const teardownHls = useCallback(() => {
if (hlsRef.current) {
@@ -260,6 +290,7 @@ export function PlayerPage() {
setDanmakuFontSize(Number(cfg.font_size) || 24)
setDanmakuArea(Number(cfg.area) || 1)
setDanmakuMergeSources(Boolean(cfg.merge_sources))
setVr360GuideSeen(Boolean(cfg.vr360_guide_seen))
applyDanmakuConnectionConfig(cfg)
if (!playerVolumeTouchedRef.current) {
const current = useAuthStore.getState().user
@@ -458,6 +489,11 @@ export function PlayerPage() {
useEffect(() => {
setMedia(null)
setLoadError('')
vr360TouchedRef.current = false
vr360TextureRetryUsedRef.current = false
vr360TextureRetryUntilRef.current = 0
setVr360(null)
setVr360Detected(false)
setHlsStartSec(0)
setPlaybackInfo(null)
setHlsSource('local')
@@ -537,6 +573,97 @@ export function PlayerPage() {
}
}, [id, modeParam, directOnly])
// VR 全景素材识别:只在用户没手动改过开关时自动进入 VR 模式。
// 识别完全基于文件名/路径关键词与画幅比例,误判时点一下 VR 按钮即可退出。
useEffect(() => {
if (!media || media.id !== id) return
const detection = detectVr360Profile({
title: media.title,
originalName: media.original_name,
path: media.path,
relativePath: media.relative_path,
width: media.width,
height: media.height,
})
setVr360Detected(detection.confident)
if (vr360TouchedRef.current) return
const preference = loadVr360Preference()
if (!preference.autoDetect || !detection.confident) {
setVr360(null)
return
}
if (detection.profile.projection !== preference.profile.projection) {
// 投影方式由文件名决定(360 / 180 / 鱼眼),记住它作为下次的默认值。
saveVr360Preference({ ...preference, profile: detection.profile })
}
setVr360(detection.profile)
}, [id, media])
// 远端 Emby 挂载的媒体常常没有宽高元数据(width/height 为 0),等播放器拿到
// 真实画面尺寸后再补一次识别。HLS 转码是等比缩放,所以比例依然可信。
useEffect(() => {
if (!media || media.id !== id) return
if (media.width > 0 && media.height > 0) return
const video = ref.current
if (!video) return
const refine = () => {
if (vr360TouchedRef.current || video.videoWidth <= 0 || video.videoHeight <= 0) return
const detection = detectVr360Profile({
title: media.title,
originalName: media.original_name,
path: media.path,
relativePath: media.relative_path,
width: video.videoWidth,
height: video.videoHeight,
})
setVr360Detected(detection.confident)
const preference = loadVr360Preference()
if (!preference.autoDetect || !detection.confident) return
setVr360((current) =>
current && sameVr360Profile(current, detection.profile) ? current : detection.profile,
)
}
video.addEventListener('loadedmetadata', refine)
refine()
return () => video.removeEventListener('loadedmetadata', refine)
}, [id, media])
const toggleVr360 = useCallback(() => {
vr360TouchedRef.current = true
if (vr360) {
setVr360(null)
return
}
const current = mediaRef.current
const detection: Vr360Detection | null = current
? detectVr360Profile({
title: current.title,
originalName: current.original_name,
path: current.path,
relativePath: current.relative_path,
width: current.width,
height: current.height,
})
: null
const preference = loadVr360Preference()
const profile = detection?.profile ?? preference.profile
saveVr360Preference({ ...preference, profile })
setVr360(profile)
}, [vr360])
const changeVr360Profile = useCallback((profile: Vr360Profile) => {
vr360TouchedRef.current = true
setVr360(profile)
saveVr360Preference({ ...loadVr360Preference(), profile })
}, [])
// VR 操作说明是「按用户只弹一次」:用户关掉说明时就落库,之后进入 VR 不再显示。
// 保存失败只影响下次是否再弹一次,不打断播放,所以本地状态不回滚。
const dismissVr360Guide = useCallback(() => {
setVr360GuideSeen(true)
void danmakuAPI.updateSettings({ vr360_guide_seen: true }).catch(() => undefined)
}, [])
// Wire up the actual <video> element when we know the mode.
// Depend on media.id (not the media object): refreshing duration after
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
@@ -608,6 +735,33 @@ export function PlayerPage() {
[mediaId, setPlaybackMode, switchToLocalHLS],
)
// 切到 HLS 播放(优先 115 云端,其次本地转码)。toggleMode 与「VR 需要同源帧」
// 的自愈逻辑共用,避免两处各写一遍云端/本地选择。
const enterHlsPlayback = useCallback(
(startSec: number) => {
setHlsStartSec(startSec)
const preferred =
playbackInfo?.provider === 'cloud115'
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
: undefined
if (preferred?.source === 'cloud') {
setHlsSource('cloud')
if (preferred.available) {
setCloudWaiting(false)
} else {
setCloudWaiting(true)
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
void startCloudTranscode(Number(preferred.id) || 4)
}
} else {
switchToLocalHLS(startSec)
}
setPlaybackMode('hls')
},
[playbackInfo, selectedQuality, setPlaybackMode, startCloudTranscode, switchToLocalHLS],
)
// 云端转码等待:按后端建议间隔轮询,完成后自动切到云 HLS。
useEffect(() => {
if (!cloudWaiting || !mediaId) return
@@ -792,13 +946,30 @@ export function PlayerPage() {
setPlaybackMode('direct')
})
} else {
const url = streamURL(mediaId)
// VR 全景需要浏览器能读帧:STRM/网盘直链会被 302 到跨域 CDN,此时改为
// 服务端同源转发(原画,不转码);普通播放仍走 302 直连,省服务器流量。
const url = streamURL(mediaId, { proxy: vr360DirectProxyRef.current })
const absoluteURL = new URL(url, window.location.href).href
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
if (video.src !== absoluteURL) {
directRetryRef.current = false
clearFallbackTimer()
// 进出 VR 会切换播放源地址,这里保住当前播放位置。
const resumeAt = video.currentTime > 2 ? video.currentTime : 0
if (resumeAt > 0) {
video.addEventListener(
'loadedmetadata',
() => {
try {
video.currentTime = resumeAt
} catch {
// ignore
}
},
{ once: true },
)
}
video.src = url
void video.play().catch(() => undefined)
}
@@ -827,6 +998,7 @@ export function PlayerPage() {
setPlaybackMode,
switchToLocalHLS,
teardownHls,
vr360DirectProxy,
])
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
@@ -1015,9 +1187,12 @@ export function PlayerPage() {
}
}, [id, media])
// 选集列表按版本组折叠,行的 id 是组内最优版本;用户切到同一条目的其它
// 版本后 media.id 与行 id 不再相等,这里必须按版本组比对,否则上一集/下一集
// 与自动连播都会失效。
const currentEpisodeIndex = useMemo(() => {
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])
const prevEpisode = useMemo(() => {
@@ -1055,8 +1230,8 @@ export function PlayerPage() {
[navigate, location.search, location.state],
)
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本入口。
const versionList = useMemo(
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本信息。
const currentVersions = useMemo(
() => (media?.id === id ? mediaVersionsOf(media) : []),
[id, media],
)
@@ -1086,6 +1261,16 @@ export function PlayerPage() {
if (next) setDanmakuOpen(false)
return next
})
setEpisodeRevealToken((token) => token + 1)
}, [])
// 操作栏 / 设置面板里的「选集」入口。竖屏剧场模式下选集区就在视频下方,
// 这里只负责展开并滚过去,不把已经展开的列表收起来(收起由选集区自己的
// 「收起」按钮负责),这样播放画面永远不会被浮层挡住。
const openPlaylistEntry = useCallback(() => {
setPlaylistOpen(true)
setDanmakuOpen(false)
setEpisodeRevealToken((token) => token + 1)
}, [])
const toggleDanmakuOpen = useCallback(() => {
@@ -1155,7 +1340,47 @@ export function PlayerPage() {
playbackInfo?.provider === 'cloud115'
? [...(playbackInfo.cloud_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 烧录。
useEffect(() => {
@@ -1186,38 +1411,49 @@ export function PlayerPage() {
}
const next = mode === 'hls' ? 'direct' : 'hls'
if (next === 'hls') {
setHlsStartSec(0)
const preferred =
playbackInfo?.provider === 'cloud115'
? findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
: undefined
if (preferred?.source === 'cloud') {
setHlsSource('cloud')
if (preferred.available) {
setCloudWaiting(false)
} else {
setCloudWaiting(true)
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
void startCloudTranscode(Number(preferred.id) || 4)
}
} else {
switchToLocalHLS()
}
enterHlsPlayback(0)
} else {
setCloudWaiting(false)
}
setPlaybackMode(next)
}, [
enterHlsPlayback,
isDirectStream,
mode,
playbackInfo,
selectedQuality,
setPlaybackMode,
startCloudTranscode,
switchToLocalHLS,
])
// VR 渲染取不到帧时:网盘/STRM 的直连会 302 跳到 CDN,视频就变成跨域资源,
// 浏览器禁止 WebGL 读取(texImage2D 抛 SecurityError)。此时自动切到同源的
// HLS(云端优先)再继续 VR,只有确实做不到时才如实报错并退出 VR。
const handleVr360Error = useCallback(
(message: string) => {
const now = Date.now()
// 切换 HLS 的过程中旧的直连帧还会继续上报失败,先忽略这一窗口内的重复错误。
if (now < vr360TextureRetryUntilRef.current) return
const canRetryWithHls =
!vr360TextureRetryUsedRef.current &&
modeRef.current === 'direct' &&
!directOnly &&
!isRemoteEmbyID(mediaRef.current?.id)
if (canRetryWithHls) {
vr360TextureRetryUsedRef.current = true
vr360TextureRetryUntilRef.current = now + 15_000
setPlayerError('')
toast('直连源跨域,浏览器无法取帧;正在切到 HLS 转码后继续 VR 全景')
enterHlsPlayback(ref.current?.currentTime || 0)
return
}
vr360TouchedRef.current = true
setVr360(null)
setPlayerError(
`${message}。若当前是网盘/STRM 直连(会 302 跳转到 CDN),浏览器不允许 WebGL 读取跨域画面,请改用 HLS 播放后再开启 VR。`,
)
toast.error(message)
},
[directOnly, enterHlsPlayback],
)
const handleSeekAbsolute = useCallback(
(absoluteSec: number) => {
if (mode !== 'hls') return false
@@ -1343,7 +1579,9 @@ export function PlayerPage() {
if (retryingDirectRef.current) return
const expectedSrc = mediaId ? new URL(streamURL(mediaId), window.location.href).href : ''
const expectedSrc = mediaId
? new URL(streamURL(mediaId, { proxy: vr360DirectProxyRef.current }), window.location.href).href
: ''
const action = classifyDirectPlayError({
errorCode: video?.error?.code,
readyState: video?.readyState ?? 0,
@@ -1431,24 +1669,23 @@ export function PlayerPage() {
media?.title?.trim() ||
''
// 竖屏手机走剧场布局:视频贴顶按 16:9 自适应,下方是标题/选集/简介。
// 桌面端与横屏手机保持原来的全屏居中沉浸式布局。
const isMobileTheater = useIsMobileTheater()
return (
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
<PlayerTopBar
title={topBarTitle}
subtitle={topBarSubtitle}
directOnly={directOnly}
isDirectStream={isDirectStream}
directStreamLabel={isRemoteEmbyID(media?.id) ? 'Emby 直连播放' : undefined}
mode={mode}
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
theater={isMobileTheater}
media={media}
loadError={loadError}
playerError={playerError}
@@ -1477,7 +1714,7 @@ export function PlayerPage() {
danmakuEpisodeId={danmakuEpisodeId}
danmakuSearchTrigger={danmakuSearchTrigger}
danmakuOpen={danmakuOpen}
onToggleDanmaku={toggleDanmakuOpen}
onOpenDanmaku={toggleDanmakuOpen}
onDanmakuLoaded={danmakuLoaded}
onDanmakuCandidates={danmakuGotCandidates}
onDanmakuAlternatives={danmakuGotAlternatives}
@@ -1488,28 +1725,44 @@ export function PlayerPage() {
prevEpisodeTitle={prevEpisodeTitle}
nextEpisodeTitle={nextEpisodeTitle}
playlistOpen={playlistOpen}
hasPlaylist={playlistEpisodes.length > 0}
onTogglePlaylist={togglePlaylistOpen}
hasPlaylist={playlistEpisodes.length > 1 || currentVersions.length > 1}
hasVersions={currentVersions.length > 1}
onTogglePlaylist={isMobileTheater ? openPlaylistEntry : togglePlaylistOpen}
knownDuration={media?.duration_sec || 0}
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
qualities={qualityOptions}
qualityLabel={qualityLabel}
selectedQuality={selectedQuality}
onSelectQuality={selectPlaybackQuality}
showQuality={showQuality}
playbackModeLabel={playbackModeLabel}
onTogglePlaybackMode={canTogglePlaybackMode ? toggleMode : undefined}
waiting={cloudWaiting}
waitingMessage={cloudWaitMessage}
vr360={vr360}
vr360Detected={vr360Detected}
onToggleVr360={toggleVr360}
onVr360ProfileChange={changeVr360Profile}
onVr360Error={handleVr360Error}
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
onDismissVr360Guide={dismissVr360Guide}
playlistPanel={
<PlayerPlaylistPanel
open={playlistOpen}
onClose={() => setPlaylistOpen(false)}
currentMediaId={media?.id ?? ''}
episodes={playlistEpisodes}
onSelectEpisode={playEpisode}
/>
isMobileTheater
? undefined
: <PlayerPlaylistPanel
open={playlistOpen}
onClose={() => setPlaylistOpen(false)}
currentMediaId={media?.id ?? ''}
currentVersions={currentVersions}
episodes={playlistEpisodes}
onSelectEpisode={playEpisode}
onSelectVersion={switchVersion}
/>
}
danmakuPanel={
<PlayerDanmakuPanel
theater={isMobileTheater}
open={danmakuOpen}
onClose={() => setDanmakuOpen(false)}
enabled={danmakuEnabled}
@@ -1544,6 +1797,36 @@ 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}
playlistOpen={playlistOpen}
onTogglePlaylist={togglePlaylistOpen}
revealToken={episodeRevealToken}
playlistPanel={
<PlayerPlaylistPanel
inline
open={playlistOpen}
onClose={() => setPlaylistOpen(false)}
currentMediaId={media?.id ?? ''}
currentVersions={currentVersions}
episodes={playlistEpisodes}
onSelectEpisode={playEpisode}
onSelectVersion={switchVersion}
/>
}
/>
) : null}
</div>
)
}
+71 -44
View File
@@ -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 { PLAYER_ICON_BUTTON } from '../components/playerTheme'
// PlayerTopBar — 播放器顶部的极简浮层。
//
// 以前这里只有「返回」和一个播放方式药丸按钮,用户看不到自己在看什么;而播放方式
// 又要和控制栏里的画质/倍速混在一起点。现在改成 B 站那样:左边是返回图标 + 当前
// 媒体标题,右边只留一个只读的播放方式状态标签(真正的切换入口收进了控制栏的
// 「设置」面板),顶栏因此不再参与「一堆药丸按钮」的视觉噪音。
type PlayerTopBarProps = {
/** 当前播放的媒体标题。 */
title?: string
/** 标题下的次要信息(集数、版本等)。 */
subtitle?: string
directOnly: boolean
isDirectStream?: boolean
directStreamLabel?: string
mode: PlayerMode
onBack: () => void
onToggleMode: () => void
}
export function PlayerTopBar({
title,
subtitle,
directOnly,
isDirectStream,
directStreamLabel,
mode,
onBack,
onToggleMode,
}: PlayerTopBarProps) {
return (
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between p-4 sm:p-6">
<button
onClick={onBack}
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"
>
<ArrowLeft size={16} /> 返回
</button>
const status = isDirectStream
? {
icon: <Sparkles size={12} />,
label: directStreamLabel || '直连播放',
title: directStreamLabel
? `${directStreamLabel},默认直连播放,不进行转码`
: '远程直连播放,不进行转码',
}
: 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">
{isDirectStream ? (
<span
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={directStreamLabel ? `${directStreamLabel},默认直连播放,不进行转码` : '远程直连播放,不进行转码'}
>
<Sparkles size={14} /> {directStreamLabel || '直连播放'}
</span>
) : directOnly ? (
<span
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="宿主机不转码,由客户端本地解码直连"
>
<Sparkles size={14} /> 客户端直连解码
</span>
) : (
<button
onClick={onToggleMode}
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"
title="切换播放模式"
>
{mode === 'hls' ? (
<>
<RefreshCw size={14} /> HLS 转码中
</>
) : (
<>
<Sparkles size={14} /> 直接播放
</>
)}
</button>
)}
return (
<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">
<div className="flex min-w-0 items-center gap-2">
<button
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`}
title="返回 (Esc)"
>
<ArrowLeft size={18} />
</button>
{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)]">
{title}
</p>
{subtitle ? (
<p className="truncate text-[11px] text-white/65 drop-shadow-[0_1px_4px_rgba(0,0,0,0.8)]">
{subtitle}
</p>
) : null}
</div>
) : null}
</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>
)
}
}
+337 -23
View File
@@ -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 { 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 { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
@@ -12,11 +22,14 @@ import {
import { AssSubtitleStage } from '../components/AssSubtitleStage'
import { DanmakuStage } from '../components/DanmakuStage'
import { PlayerControls } from '../components/PlayerControls'
import { Vr360Stage } from '../components/Vr360Stage'
import type { Vr360Profile } from '../utils/vr360'
import {
subtitleTextStyle,
type SubtitlePosition,
type SubtitleStylePreset,
} from '../utils/subtitleDisplay'
import { isPointerInLockZone } from '../utils/playerLockZone'
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
type SubtitleRenderGroup = {
@@ -25,6 +38,16 @@ type SubtitleRenderGroup = {
style: CSSProperties
}
/**
* 锁按钮每次被唤出后停留的时间(毫秒)。
*
* 锁本身默认隐身,只在「鼠标悬停感应区」或「点击画面」之后露一会儿。解锁的瞬间
* 操作栏会立刻消失,如果锁也一起消失,用户就不知道锁在哪、也没法再锁回去。
*/
const LOCK_REVEAL_MS = 2600
/** 首次操作说明的最长停留时间:到这里仍未点「知道了」也自动收起并记录为已看过。 */
const VR_GUIDE_AUTO_DISMISS_MS = 20_000
function uniqueSubtitleCues(cues: SubtitleCue[]): SubtitleCue[] {
const seen = new Set<string>()
const unique: SubtitleCue[] = []
@@ -133,6 +156,11 @@ function buildSubtitleRenderGroups(
type PlayerVideoStageProps = {
media: Media | null
/**
* 竖屏剧场模式:舞台不再全屏居中,而是贴顶按 16:9 自适应高度;
* 弹层改为底部动作面板,避免右侧抽屉与悬浮小弹层在窄屏下被裁剪。
*/
theater?: boolean
/** 媒体元数据加载失败提示(非空时替代「加载中」展示)。 */
loadError?: string
playerError: string
@@ -162,7 +190,8 @@ type PlayerVideoStageProps = {
danmakuEpisodeId: number | string | null
danmakuSearchTrigger?: number
danmakuOpen: boolean
onToggleDanmaku: () => void
/** 打开弹幕设置面板(搜索弹幕库、调整渲染参数)。 */
onOpenDanmaku: () => void
onDanmakuLoaded: (info: DanmakuLoadedInfo | null) => void
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
onDanmakuAlternatives: (alternatives: DanmakuAnime[]) => void
@@ -178,20 +207,39 @@ type PlayerVideoStageProps = {
nextEpisodeTitle?: string
playlistOpen?: boolean
hasPlaylist?: boolean
/** 当前条目存在多个版本(选集面板里提供版本切换,按钮提示随之变化)。 */
hasVersions?: boolean
onTogglePlaylist?: () => void
knownDuration?: number
streamOffset?: number
onSeekAbsolute?: (seconds: number) => boolean
qualities?: PlaybackQuality[]
/** 清晰度按钮上显示的文字(播放页按当前播放方式算好)。 */
qualityLabel?: string
selectedQuality?: string
onSelectQuality?: (quality: PlaybackQuality) => void
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
waitingMessage?: string
}
export function PlayerVideoStage({
media,
theater = false,
loadError,
playerError,
subs,
@@ -219,7 +267,7 @@ export function PlayerVideoStage({
danmakuEpisodeId,
danmakuSearchTrigger = 0,
danmakuOpen,
onToggleDanmaku,
onOpenDanmaku,
onDanmakuLoaded,
onDanmakuCandidates,
onDanmakuAlternatives,
@@ -233,14 +281,25 @@ export function PlayerVideoStage({
nextEpisodeTitle,
playlistOpen,
hasPlaylist,
hasVersions = false,
onTogglePlaylist,
knownDuration,
streamOffset,
onSeekAbsolute,
qualities = [],
qualityLabel = '',
selectedQuality = '',
onSelectQuality,
showQuality = false,
playbackModeLabel = '',
onTogglePlaybackMode,
vr360 = null,
vr360Detected = false,
onToggleVr360,
onVr360ProfileChange,
onVr360Error,
showVr360Guide = false,
onDismissVr360Guide,
waiting = false,
waitingMessage = '',
}: PlayerVideoStageProps) {
@@ -248,6 +307,27 @@ export function PlayerVideoStage({
const [videoRatio, setVideoRatio] = useState<number | null>(null)
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
const [controlsVisible, setControlsVisible] = useState(true)
// 鼠标是否停在 VR 工具条/设置面板上:停住时控制栏不自动隐藏,否则设置面板会在点击前消失。
const [vrUiHovered, setVrUiHovered] = useState(false)
// 画面是否被「锁住」:锁上之后操作栏、VR 工具条、进度框全部隐藏,只剩干净的
// 画面,轻触画面也不会把它们唤回来,也不会误触发播放/暂停;想看操作栏必须先
// 点左边的锁解锁。VR 和普通播放共用这一套状态。
const [locked, setLocked] = useState(false)
// 锁按钮本身默认隐身(见下方的 lockVisible),这三个状态决定它什么时候露面:
// lockHovered —— 桌面鼠标停在感应区里(移开就收);
// lockShown —— 点了画面之后的临时显示窗口;
// lockHint —— 刚解锁,顺带显示「已解锁」提示。
const [lockHovered, setLockHovered] = useState(false)
const [lockShown, setLockShown] = useState(false)
const [lockHint, setLockHint] = useState(false)
const lockRevealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lockVisible = lockHovered || lockShown
const vrActive = Boolean(vr360)
// VR 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
// 只按「媒体 ID」记录:切换投影方式/画幅布局不会重建画布,也就不能清掉这个
// 状态——渲染器只在挂载后上报一次就绪,清掉之后提示会一直停在「正在启动」。
const [vrReadyMediaId, setVrReadyMediaId] = useState<string | null>(null)
const vrReady = Boolean(media && vrReadyMediaId === media.id)
const revealControlsOnlyRef = useRef(false)
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
const [activeCues, setActiveCues] = useState<SubtitleCue[]>([])
@@ -389,18 +469,117 @@ export function PlayerVideoStage({
if (video.paused) void video.play()?.catch(() => undefined)
else video.pause()
}
/**
* 临时把锁按钮唤出来(移动端点击画面、以及刚解锁时)。
*
* 每次都重新计时:连续点击时不会因为第一次的计时器到点而提前隐身。
*/
const revealLock = useCallback((withUnlockHint: boolean) => {
setLockShown(true)
setLockHint(withUnlockHint)
if (lockRevealTimerRef.current) clearTimeout(lockRevealTimerRef.current)
lockRevealTimerRef.current = setTimeout(() => {
lockRevealTimerRef.current = null
setLockShown(false)
setLockHint(false)
}, LOCK_REVEAL_MS)
}, [])
useEffect(
() => () => {
if (lockRevealTimerRef.current) clearTimeout(lockRevealTimerRef.current)
},
[],
)
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
revealControlsOnlyRef.current = event.pointerType === 'touch' && !controlsVisible
// 触摸端没有悬停,靠点击画面把锁唤出来(顺带给刚上锁的用户一个解锁入口)。
if (event.pointerType === 'touch') revealLock(false)
// 锁住时轻触画面不做任何事;VR 模式下也不负责唤出控制栏(画面默认是干净的)。
if (locked) {
revealControlsOnlyRef.current = false
return
}
// 未锁的移动端:控制栏隐藏时首次轻触只唤出控制栏,不切换播放。
revealControlsOnlyRef.current =
!vrActive && event.pointerType === 'touch' && !controlsVisible
}
const handleStageClick = () => {
// 桌面端:鼠标移到画面左侧中间就把锁露出来,移开就收回去。
const handleStagePointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'mouse') return
const rect = event.currentTarget.getBoundingClientRect()
setLockHovered(
isPointerInLockZone(event.clientX - rect.left, event.clientY - rect.top, rect.height),
)
}
// 舞台任意位置的「激活」(轻触/单击):
// 普通播放:桌面端直接切换播放/暂停;移动端控制栏隐藏时首次轻触只唤出控制栏,
// 控制栏已显示时再次轻触才切换播放/暂停。
// VR 模式:单击只负责显示/收起操作栏,不切换播放。
// 两种情况都在锁住时完全无反应——只能点锁解锁(锁由上面的 pointerdown 唤出)。
const handleSurfaceActivate = () => {
if (locked) return
if (revealControlsOnlyRef.current) {
revealControlsOnlyRef.current = false
setControlsVisible(true)
return
}
revealControlsOnlyRef.current = false
if (vrActive) {
setControlsVisible((visible) => !visible)
return
}
togglePlay()
}
const toggleLock = () => {
const next = !locked
setLocked(next)
// 锁上时立刻收起操作栏;解锁时也保持收起,这样画面在任何一侧都不会突然
// 弹出一整条浮层。解锁那一下顺带显示「已解锁」提示。
setControlsVisible(false)
revealLock(!next)
}
/** 复位锁定状态:切模式/换片时调用,避免上一次的锁挡住新的内容。 */
const resetLock = useCallback(() => {
setLocked(false)
setLockShown(false)
setLockHint(false)
setLockHovered(false)
if (lockRevealTimerRef.current) {
clearTimeout(lockRevealTimerRef.current)
lockRevealTimerRef.current = null
}
}, [])
// 进入 VR 全景播放时先亮一次操作栏与 VR 工具条,让用户知道入口在哪;
// 退出 VR 时清掉 VR 浮层的悬停状态,否则控制栏会以为还有浮层被悬停而不隐藏。
// 两种情况都要解除锁定:切模式是个明确的操作意图,不该被上一次的锁挡住。
useEffect(() => {
resetLock()
if (vrActive) {
setControlsVisible(true)
return
}
setVrUiHovered(false)
}, [resetLock, vrActive])
// 换集/换片时同样复位:新内容应该从干净的、可操作的状态开始。
useEffect(() => {
resetLock()
}, [media?.id, resetLock])
// 首次操作说明只出现一次:读完了点「知道了」会立即落库;放着不管也会在
// VR_GUIDE_AUTO_DISMISS_MS 之后自动收起并同样记录为已看过,避免每次进 VR 都弹。
const vrGuideVisible = vrActive && vrReady && showVr360Guide
useEffect(() => {
if (!vrGuideVisible) return
const timer = setTimeout(() => onDismissVr360Guide?.(), VR_GUIDE_AUTO_DISMISS_MS)
return () => clearTimeout(timer)
}, [vrGuideVisible, onDismissVr360Guide])
const toggleFullscreen = () => {
const stage = stageRef.current
if (!stage) return
@@ -550,26 +729,49 @@ export function PlayerVideoStage({
: true
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%',
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 (
<div
ref={stageRef}
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}
onClick={handleStageClick}
onPointerMove={handleStagePointerMove}
onMouseLeave={() => setLockHovered(false)}
onClick={handleSurfaceActivate}
onDoubleClick={toggleFullscreen}
>
{media ? (
@@ -602,6 +804,20 @@ export function PlayerVideoStage({
)
})}
</video>
{vr360 ? (
<Vr360Stage
key={`${media.id}:vr360`}
videoRef={videoRef}
profile={vr360}
uiVisible={controlsVisible}
onSurfaceTap={handleSurfaceActivate}
onReady={() => setVrReadyMediaId(media.id)}
onError={(message) => onVr360Error?.(message)}
onProfileChange={onVr360ProfileChange}
onExitVr={onToggleVr360}
onUiHoldChange={setVrUiHovered}
/>
) : null}
<DanmakuStage
key={media.id}
media={media}
@@ -655,6 +871,7 @@ export function PlayerVideoStage({
) : null}
</div>
<PlayerControls
theater={theater}
videoRef={videoRef}
volume={playerVolume}
onVolumeChange={onPlayerVolumeChange}
@@ -662,8 +879,10 @@ export function PlayerVideoStage({
playbackRate={playerPlaybackRate}
onPlaybackRateChange={onPlayerPlaybackRateChange}
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
uiVisible={controlsVisible}
uiVisible={controlsVisible && !locked}
onUiVisibleChange={setControlsVisible}
uiHold={vrUiHovered}
uiLocked={locked}
subs={subs}
subtitleIndex={subtitleIndex}
onSelectSubtitle={onSelectSubtitle}
@@ -675,7 +894,7 @@ export function PlayerVideoStage({
onSubtitleStyleChange={onSubtitleStyleChange}
danmakuOpen={danmakuOpen}
danmakuEnabled={danmakuEnabled}
onToggleDanmaku={onToggleDanmaku}
onOpenDanmaku={onOpenDanmaku}
hasPrevEpisode={hasPrevEpisode}
hasNextEpisode={hasNextEpisode}
onPrevEpisode={onPrevEpisode}
@@ -684,14 +903,21 @@ export function PlayerVideoStage({
nextEpisodeTitle={nextEpisodeTitle}
playlistOpen={playlistOpen}
hasPlaylist={hasPlaylist}
hasVersions={hasVersions}
onTogglePlaylist={onTogglePlaylist}
knownDuration={knownDuration}
streamOffset={streamOffset}
onSeekAbsolute={onSeekAbsolute}
qualities={qualities}
qualityLabel={qualityLabel}
selectedQuality={selectedQuality}
onSelectQuality={onSelectQuality}
showQuality={showQuality}
playbackModeLabel={playbackModeLabel}
onTogglePlaybackMode={onTogglePlaybackMode}
vr360={vr360}
vr360Detected={vr360Detected}
onToggleVr360={onToggleVr360}
/>
{danmakuPanel}
{playlistPanel}
@@ -701,17 +927,105 @@ export function PlayerVideoStage({
) : (
<p className="text-sand-500">加载中…</p>
)}
{vr360 && media && !vrReady ? (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/60">
<div className="flex items-center gap-2 rounded-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 ? (
<div className="absolute bottom-20 left-1/2 w-[min(92vw,720px)] -translate-x-1/2 rounded-2xl border border-white/15 bg-black/75 px-5 py-4 text-sm text-white shadow-2xl backdrop-blur">
{playerError}
<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">
<CircleAlert size={15} className="mt-0.5 shrink-0 text-rose-400" />
<span className="min-w-0 flex-1">{playerError}</span>
</div>
) : null}
{waiting ? (
<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">
<Loader2 className="mx-auto mb-3 animate-spin text-rose-400" size={28} />
<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={26} />
<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>
) : null}
+67
View File
@@ -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')
+38 -2
View File
@@ -107,6 +107,15 @@ function cleanFallbackFilename(raw: string): string {
return name
}
function isIndistinctVersionLabel(parts: string[]): boolean {
if (parts.length === 0) return true
return parts.every((part) => {
const upper = part.toUpperCase()
if (upper === '云端直链' || upper === 'STRM') return true
return VIDEO_EXTENSIONS.has(part.toLowerCase())
})
}
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积 / 规格 / 来源标签。 */
export function mediaVersionLabel(media: Media): string {
const isStrm = isStrmMedia(media)
@@ -153,11 +162,11 @@ export function mediaVersionLabel(media: Media): string {
parts.push('云端直链')
}
if (parts.length > 0) {
// 只有容器 /「云端直链」时无法区分多分片,回退文件名(sivr-270-1 / …part1)
if (parts.length > 0 && !isIndistinctVersionLabel(parts)) {
return parts.join(' · ')
}
// 兜底:使用清理后的文件名
const cleaned = cleanFallbackFilename(media.path || '')
return cleaned || media.title || media.id
}
@@ -167,3 +176,30 @@ export function mediaVersionsOf(media: Media | null | undefined): Media[] {
if (media.versions && media.versions.length > 1) return media.versions
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, '')
}
+40
View File
@@ -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')
+27
View File
@@ -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
}
+179
View File
@@ -0,0 +1,179 @@
import {
detectVr360Profile,
normalizeVr360Profile,
sameVr360Profile,
vr360FrameUv,
type Vr360Profile,
} from './vr360.ts'
import {
applyDirection,
applyViewOffset,
cameraForward,
cameraFromDeviceOrientation,
cameraFromYawPitch,
forwardPitch,
forwardYaw,
multiply3,
perspectiveMatrix,
rotationY,
rotationZ,
transpose3,
viewMatrixFromCamera,
} from './vr360Math.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`vr360: ${name}`)
}
function close(a: number, b: number, tolerance = 1e-6): boolean {
return Math.abs(a - b) <= tolerance
}
function closeAll(a: number[], b: number[], tolerance = 1e-6): boolean {
return a.length === b.length && a.every((value, index) => close(value, b[index], tolerance))
}
function profileOf(profile: Vr360Profile): string {
return `${profile.projection}/${profile.stereo}`
}
// ---------- 命名/画幅识别 ----------
const plain = detectVr360Profile({ title: '普通电影', path: '/media/电影/movie.mkv', width: 1920, height: 1080 })
check('普通素材不自动进入 VR', !plain.confident)
check('普通素材默认 360 单眼', profileOf(plain.profile) === 'equirect360/mono')
const pano360 = detectVr360Profile({
title: '全景演示',
path: '/media/360/equirect_demo.mp4',
width: 3840,
height: 1920,
})
check('360 关键词可识别', pano360.confident && pano360.profile.projection === 'equirect360')
check('2:1 的 360 保持单眼', pano360.profile.stereo === 'mono')
const vr360Sbs = detectVr360Profile({ path: '/media/vr/vr360-sbs-8k.mp4', width: 7680, height: 1920 })
check('4:1 画幅识别为左右并排', vr360Sbs.profile.stereo === 'sbs')
const ou360 = detectVr360Profile({ path: '/media/vr/pano360-ou.mp4', width: 3840, height: 3840 })
check('1:1 的 360 识别为上下并排', ou360.profile.stereo === 'ou')
const fisheye = detectVr360Profile({ path: '/media/vr/abc-fisheye-180.mp4', width: 1920, height: 1920 })
check('鱼眼关键词优先于其它信号', fisheye.profile.projection === 'fisheye180')
const vr180Sbs = detectVr360Profile({
path: '/media/云下载/sivr-270/sivr-270-1.mp4',
width: 3840,
height: 1920,
})
check('编号里的 vr 可识别为 VR180', vr180Sbs.confident && vr180Sbs.profile.projection === 'equirect180')
check('VR180 的 2:1 画幅为左右并排', vr180Sbs.profile.stereo === 'sbs')
const vr180Mono = detectVr360Profile({ path: '/media/vr/vr180-mono.mp4', width: 1920, height: 1920 })
check('VR180 的 1:1 画幅为单眼', vr180Mono.profile.stereo === 'mono')
const vr180Ou = detectVr360Profile({ path: '/media/vr/vr180-tb.mp4', width: 1920, height: 3840 })
check('VR180 的 1:2 画幅为上下并排', vr180Ou.profile.stereo === 'ou')
const topBottom = detectVr360Profile({ path: '/media/vr/vr360-top-bottom.mp4' })
check('top-bottom 关键词为上下并排', topBottom.profile.stereo === 'ou')
// 回归:真实媒体库里出现过的误判(单词里含 vr、分辨率写法含 360)。
const valvrave = detectVr360Profile({
title: '革命机Valvrave',
path: '/media/anime/2013/[革命机] Valvrave the Liberator (2013)/01.mkv',
})
check('单词里的 vr 不误判(Valvrave)', !valvrave.confident)
check('误判时仍给出可用的默认配置', profileOf(valvrave.profile) === 'equirect360/mono')
const resolution360p = detectVr360Profile({ path: '/media/movie/movie-360p.mp4', width: 640, height: 360 })
check('360p 分辨率写法不误判', !resolution360p.confident)
const titleWithVrWord = detectVr360Profile({ title: '某动画 VR 特别篇', path: '/media/anime/01.mkv' })
check('标题里的独立 VR 词元可识别', titleWithVrWord.confident)
const ipvr = detectVr360Profile({ path: '/media/云下载/ipvr00192pl/ipvr00192.part2.mp4' })
check('编号形态 ipvr00192 可识别', ipvr.confident && ipvr.profile.projection === 'equirect180')
// ---------- 立体裁切与偏好归一化 ----------
check('单眼不裁切', closeAll(vr360FrameUv('mono').scale, [1, 1]))
check('左右并排取左半张', closeAll(vr360FrameUv('sbs').scale, [0.5, 1]))
check('上下并排取上半张', closeAll(vr360FrameUv('ou').scale, [1, 0.5]))
check('并排画面不做偏移', closeAll(vr360FrameUv('sbs').offset, [0, 0]))
check('未知投影回退到 360 全景', normalizeVr360Profile({ projection: 'nope', stereo: 'nope' }).projection === 'equirect360')
check('未知布局回退到单眼', normalizeVr360Profile({}).stereo === 'mono')
check(
'相同配置判定相等',
sameVr360Profile({ projection: 'equirect180', stereo: 'sbs' }, { projection: 'equirect180', stereo: 'sbs' }),
)
check(
'不同配置判定不等',
!sameVr360Profile({ projection: 'equirect180', stereo: 'sbs' }, { projection: 'equirect180', stereo: 'ou' }),
)
// ---------- 相机姿态与设备陀螺仪 ----------
const identityForward = cameraForward(new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]))
check('单位姿态朝向地面(设备平放)', closeAll([...identityForward], [0, 0, -1]))
const level = cameraFromYawPitch(0, 0)
check('默认视角水平看向正前方', closeAll([...cameraForward(level)], [0, 1, 0]))
const east = cameraFromYawPitch(Math.PI / 2, 0)
check('方位角 90° 转向正东', closeAll([...cameraForward(east)], [1, 0, 0], 1e-6))
const up = cameraFromYawPitch(0, Math.PI / 2)
check('俯仰角 90° 抬头看天', closeAll([...cameraForward(up)], [0, 0, 1], 1e-6))
const northEast = cameraFromYawPitch(Math.PI / 4, 0)
check('方位角 45° 落在东北方向', close(forwardYaw(cameraForward(northEast)), Math.PI / 4))
const tilted = cameraFromYawPitch(0.3, -0.4)
check('朝向角可反解出方位角', close(forwardYaw(cameraForward(tilted)), 0.3, 1e-6))
check('朝向角可反解出俯仰角', close(forwardPitch(cameraForward(tilted)), -0.4, 1e-6))
check(
'旋转矩阵转置等于反向旋转',
closeAll([...transpose3(rotationY(0.7))], [...rotationY(-0.7)], 1e-6),
)
check(
'正反旋转相乘为单位矩阵',
closeAll(
[...multiply3(rotationZ(1.1), rotationZ(-1.1))],
[1, 0, 0, 0, 1, 0, 0, 0, 1],
1e-6,
),
)
// 视图矩阵 = 相机姿态的逆(纯旋转)
const view = viewMatrixFromCamera(east)
check('视图矩阵为相机矩阵的转置', closeAll([...view], [...transpose3(east)], 1e-6))
// 设备姿态约定(与 W3C 规范一致):相机看向设备背面,
// 因此设备平放时(alpha 任意、beta = 0)视线朝下。
const flat = cameraFromDeviceOrientation(Math.PI / 3, 0, 0, 0)
check('设备平放时看向地面', closeAll([...cameraForward(flat)], [0, 0, -1], 1e-6))
// 竖持手机(beta = 90°)时视线水平
const portrait = cameraFromDeviceOrientation(0, Math.PI / 2, 0, 0)
check('竖持手机时视线水平', close(cameraForward(portrait)[2], 0, 1e-6))
check('竖持手机时视线朝北', close(forwardYaw(cameraForward(portrait)), 0, 1e-6))
// 横屏(屏幕旋转 90°)时相机「上方向」跟随屏幕
const landscape = cameraFromDeviceOrientation(0, Math.PI / 2, 0, Math.PI / 2)
check('横屏时画面随屏幕转向', closeAll([...applyDirection(landscape, 0, 1, 0)], [1, 0, 0], 1e-6))
// 陀螺仪开启瞬间的视角对齐:叠加偏移后朝向应等于对齐前记录的拖拽视角
const aligned = applyViewOffset(cameraFromDeviceOrientation(0, Math.PI / 2, 0, 0), 0.5, 0.25)
check('叠加偏移后方位角对齐', close(forwardYaw(cameraForward(aligned)), 0.5, 1e-6))
check('叠加偏移后俯仰角对齐', close(forwardPitch(cameraForward(aligned)), 0.25, 1e-6))
// ---------- 透视投影 ----------
const projection = perspectiveMatrix(Math.PI / 2, 2, 0.1, 10)
check('透视矩阵缩放项随视场角变化', close(projection[0], 0.5))
check('透视矩阵写入 w = -1(右手系)', projection[11] === -1)
console.log('vr360.test.ts: ok')
+237
View File
@@ -0,0 +1,237 @@
// VR360(全景)播放的配置模型、命名启发式识别与本地偏好。
//
// 网页端只能从文件名/路径和画幅比例推断「这段视频是不是全景/VR」,任何信号
// 都不绝对可靠,所以这里只负责给出「大概率正确」的初始配置:识别结果不写入
// 数据库,用户随时可以在播放器菜单里改投影方式与立体布局,改动只影响本机。
export type Vr360Projection = 'equirect360' | 'equirect180' | 'fisheye180'
export type Vr360Stereo = 'mono' | 'sbs' | 'ou'
export type Vr360Profile = {
/** 画面投影方式:360 等距柱状、180 等距柱状、180 鱼眼。 */
projection: Vr360Projection
/** 立体布局:单眼、左右并排、上下并排。 */
stereo: Vr360Stereo
}
export const DEFAULT_VR360_PROFILE: Vr360Profile = {
projection: 'equirect360',
stereo: 'mono',
}
/** 视场角可调范围(度):数值越小画面越「拉近」。 */
export const VR360_MIN_FOV = 35
export const VR360_MAX_FOV = 110
export const VR360_DEFAULT_FOV = 75
export const VR360_PROJECTION_OPTIONS: Array<[Vr360Projection, string]> = [
['equirect360', '360° 全景'],
['equirect180', '180° 半景'],
['fisheye180', '180° 鱼眼'],
]
export const VR360_STEREO_OPTIONS: Array<[Vr360Stereo, string]> = [
['mono', '单眼'],
['sbs', '左右并排'],
['ou', '上下并排'],
]
export function vr360ProjectionLabel(projection: Vr360Projection): string {
return VR360_PROJECTION_OPTIONS.find(([value]) => value === projection)?.[1] ?? '360° 全景'
}
export function vr360StereoLabel(stereo: Vr360Stereo): string {
return VR360_STEREO_OPTIONS.find(([value]) => value === stereo)?.[1] ?? '单眼'
}
export function normalizeVr360Projection(value: unknown): Vr360Projection {
return value === 'equirect180' || value === 'fisheye180' ? value : 'equirect360'
}
export function normalizeVr360Stereo(value: unknown): Vr360Stereo {
return value === 'sbs' || value === 'ou' ? value : 'mono'
}
export function normalizeVr360Profile(value: unknown): Vr360Profile {
const profile = (value ?? {}) as Partial<Vr360Profile>
return {
projection: normalizeVr360Projection(profile.projection),
stereo: normalizeVr360Stereo(profile.stereo),
}
}
export function sameVr360Profile(a: Vr360Profile, b: Vr360Profile): boolean {
return a.projection === b.projection && a.stereo === b.stereo
}
// 立体素材(左右/上下并排)在普通屏幕上只取左眼画面:真正的双眼渲染需要
// WebXR 头显,网页里把两半都画出来只会得到重复画面。
export function vr360FrameUv(stereo: Vr360Stereo): {
scale: [number, number]
offset: [number, number]
} {
if (stereo === 'sbs') return { scale: [0.5, 1], offset: [0, 0] }
if (stereo === 'ou') return { scale: [1, 0.5], offset: [0, 0] }
return { scale: [1, 1], offset: [0, 0] }
}
export type Vr360DetectInput = {
title?: string
originalName?: string
path?: string
relativePath?: string
/** 视频画面宽度(媒体元数据或 ffprobe 结果)。 */
width?: number
/** 视频画面高度。 */
height?: number
}
export type Vr360Detection = {
profile: Vr360Profile
/** 文件名/路径里出现了明确的 VR 或全景关键词,可以放心自动进入 VR 模式。 */
confident: boolean
}
// 关键词分两类:明确的关键词按子串匹配("vr360" 里也含 "360"),
// 容易误伤的两字母缩写按作品编号形态匹配。
const PANORAMA_SUBSTRINGS = ['equirect', 'insta360', 'pano', 'vuze', 'theta', '全景']
const FISHEYE_SUBSTRINGS = ['fisheye', '鱼眼']
const VR180_SUBSTRINGS = ['vr180', '180vr', '180°', '180度', '半景']
const SBS_SUBSTRINGS = ['side-by-side', 'sidebyside', 'side_by_side', '左右']
const OU_SUBSTRINGS = ['over-under', 'top-bottom', 'topbottom', '上下']
const SBS_TOKENS = ['sbs', 'hsbs', 'lr']
const OU_TOKENS = ['ou', 'tb', 'vou', 'tou']
// 「VR」必须落在作品编号形态上,不能只是单词里的字母组合:
// vr180 / vr00192(后面跟数字)、sivr-270 / kmvr.mkv(跟在字母后并以非字母数字收尾)。
// 这样 "Valvrave"、"VRoid" 这类单词不会被误判成 VR 素材。
const VR_NAME_PATTERN = /vr[0-9]|[a-z]vr(?![a-z0-9])/
// 「360」需要排除 "360p" 之类的分辨率写法和 "1360" 之类的数字片段。
const PANORAMA_360_PATTERN = /(?<![0-9])360(?![0-9p])/
function detectText(input: Vr360DetectInput): string {
return [input.path, input.relativePath, input.originalName, input.title]
.filter((value): value is string => Boolean(value))
.join(' ')
.toLowerCase()
}
// 文件名比标题可靠:标题里可能出现「VR」之类的普通单词,文件名里的 "vr" 通常
// 是作品编号的一部分(如 sivr-270、ipvr00192)。
function detectNameText(input: Vr360DetectInput): string {
return [input.path, input.relativePath, input.originalName]
.filter((value): value is string => Boolean(value))
.join(' ')
.toLowerCase()
}
function detectTokens(text: string): Set<string> {
return new Set(text.split(/[^a-z0-9]+/).filter(Boolean))
}
function hasSubstring(text: string, needles: string[]): boolean {
return needles.some((needle) => text.includes(needle))
}
function hasToken(tokens: Set<string>, needles: string[]): boolean {
return needles.some((needle) => tokens.has(needle))
}
function aspectRatio(input: Vr360DetectInput): number | null {
const width = input.width ?? 0
const height = input.height ?? 0
if (width <= 0 || height <= 0) return null
const ratio = width / height
return Number.isFinite(ratio) && ratio > 0 ? ratio : null
}
/**
* 依据文件名/路径关键词与画幅比例推断 VR 配置。
*
* 关键词比画幅可靠:2:1 既可能是 360 单眼,也可能是 180 左右并排,所以比例只
* 在没有明确关键词时补默认值(4:1 ⇒ 左右并排;约 1:1 的 360 ⇒ 上下并排)。
*/
export function detectVr360Profile(input: Vr360DetectInput): Vr360Detection {
const text = detectText(input)
const nameText = detectNameText(input)
const tokens = detectTokens(text)
const fisheye = hasSubstring(text, FISHEYE_SUBSTRINGS)
const vr180 = hasSubstring(text, VR180_SUBSTRINGS)
const panorama =
hasSubstring(text, PANORAMA_SUBSTRINGS) || PANORAMA_360_PATTERN.test(text)
// 标题里只认独立的 "vr" 词元;文件名/路径里允许作品编号形态。
const vrToken = tokens.has('vr') || VR_NAME_PATTERN.test(nameText)
const confident = fisheye || vr180 || panorama || vrToken
let projection: Vr360Projection = DEFAULT_VR360_PROFILE.projection
if (fisheye) projection = 'fisheye180'
else if (vr180) projection = 'equirect180'
// 只有 "VR" 一个信号、没有 360/全景关键词时,按更常见的 VR180 处理。
else if (vrToken && !panorama) projection = 'equirect180'
const sbs = hasSubstring(text, SBS_SUBSTRINGS) || hasToken(tokens, SBS_TOKENS)
const ou = hasSubstring(text, OU_SUBSTRINGS) || hasToken(tokens, OU_TOKENS)
let stereo: Vr360Stereo = 'mono'
if (sbs !== ou) stereo = sbs ? 'sbs' : 'ou'
const ratio = aspectRatio(input)
if (ratio !== null && stereo === 'mono') {
if (projection === 'equirect360') {
// 4:1 基本只可能是 360 左右并排(如 7680x1920);
// 约 1:1 的 360 素材多为上下并排(如 3840x3840)。
if (ratio >= 3.4) stereo = 'sbs'
else if (ratio <= 1.05) stereo = 'ou'
} else {
// VR180 单眼是 1:1,左右并排是 2:1,上下并排是 1:2。
if (ratio >= 1.8 && ratio <= 2.2) stereo = 'sbs'
else if (ratio <= 0.6) stereo = 'ou'
}
}
return { profile: { projection, stereo }, confident }
}
const VR360_PREF_KEY = 'mebox.player.vr360'
export type Vr360Preference = {
/** 是否按文件名/画幅自动进入 VR 模式;用户可以关掉,避免误判干扰正常播放。 */
autoDetect: boolean
/** 用户上次选定的投影与布局,作为下次手动进入 VR 模式时的默认值。 */
profile: Vr360Profile
}
export const DEFAULT_VR360_PREFERENCE: Vr360Preference = {
autoDetect: true,
profile: DEFAULT_VR360_PROFILE,
}
export function loadVr360Preference(): Vr360Preference {
if (typeof window === 'undefined') return DEFAULT_VR360_PREFERENCE
try {
const raw = window.localStorage.getItem(VR360_PREF_KEY)
if (!raw) return DEFAULT_VR360_PREFERENCE
const parsed = JSON.parse(raw) as Partial<Vr360Preference>
return {
autoDetect: parsed.autoDetect !== false,
profile: normalizeVr360Profile(parsed.profile),
}
} catch {
return DEFAULT_VR360_PREFERENCE
}
}
export function saveVr360Preference(preference: Vr360Preference): void {
try {
window.localStorage.setItem(
VR360_PREF_KEY,
JSON.stringify({
autoDetect: preference.autoDetect !== false,
profile: normalizeVr360Profile(preference.profile),
}),
)
} catch {
// 隐私模式下 localStorage 不可用;播放器仍可正常使用 VR 模式。
}
}
+154
View File
@@ -0,0 +1,154 @@
// VR360 播放用到的纯数学:3x3 旋转矩阵、设备姿态到相机姿态、透视投影。
//
// 坐标约定(与 W3C DeviceOrientation 规范一致,右手系):
// x = 东(设备右侧),y = 北(设备顶部),z = 天空(设备屏幕朝外)
// 相机看向相机局部坐标的 -z,设备平放时 -z 指向地面,因此设备平放时视角朝下,
// 竖持手机(beta=90°)时视角水平向前 —— 与手机陀螺仪的实际手感一致。
//
// 所有 3x3 矩阵都是 WebGL 默认的列主序(column-major)Float32Array(9),
// 可以直接交给 gl.uniformMatrix3fv,不需要转置。
export type Mat3 = Float32Array
export function degToRad(degrees: number): number {
return (degrees * Math.PI) / 180
}
export function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min
return Math.min(max, Math.max(min, value))
}
export function createMat3(
m00: number, m10: number, m20: number,
m01: number, m11: number, m21: number,
m02: number, m12: number, m22: number,
): Mat3 {
// WebGL 列主序内存布局:[col0, col1, col2]
return new Float32Array([m00, m10, m20, m01, m11, m21, m02, m12, m22])
}
export function identity3(): Mat3 {
return createMat3(1, 0, 0, 0, 1, 0, 0, 0, 1)
}
export function rotationX(radians: number): Mat3 {
const c = Math.cos(radians)
const s = Math.sin(radians)
return createMat3(1, 0, 0, 0, c, s, 0, -s, c)
}
export function rotationY(radians: number): Mat3 {
const c = Math.cos(radians)
const s = Math.sin(radians)
return createMat3(c, 0, -s, 0, 1, 0, s, 0, c)
}
export function rotationZ(radians: number): Mat3 {
const c = Math.cos(radians)
const s = Math.sin(radians)
return createMat3(c, s, 0, -s, c, 0, 0, 0, 1)
}
/** 先施加 b,再施加 a(即矩阵乘积 a·b)。 */
export function multiply3(a: Mat3, b: Mat3): Mat3 {
const out = new Float32Array(9)
for (let col = 0; col < 3; col++) {
for (let row = 0; row < 3; row++) {
let sum = 0
for (let k = 0; k < 3; k++) {
sum += a[k * 3 + row] * b[col * 3 + k]
}
out[col * 3 + row] = sum
}
}
return out
}
/** 旋转矩阵的逆等于转置。 */
export function transpose3(m: Mat3): Mat3 {
const out = new Float32Array(9)
for (let col = 0; col < 3; col++) {
for (let row = 0; row < 3; row++) {
out[col * 3 + row] = m[row * 3 + col]
}
}
return out
}
export function applyDirection(m: Mat3, x: number, y: number, z: number): [number, number, number] {
return [
m[0] * x + m[3] * y + m[6] * z,
m[1] * x + m[4] * y + m[7] * z,
m[2] * x + m[5] * y + m[8] * z,
]
}
/** 相机在世界坐标中的朝向(相机局部 -z)。 */
export function cameraForward(camera: Mat3): [number, number, number] {
const forward = applyDirection(camera, 0, 0, -1)
const length = Math.hypot(forward[0], forward[1], forward[2]) || 1
return [forward[0] / length, forward[1] / length, forward[2] / length]
}
/** 相机朝向的水平方位角(0 = +y 即正北,顺时针转向 +x 即正东)。 */
export function forwardYaw(forward: [number, number, number]): number {
return Math.atan2(forward[0], forward[1])
}
/** 相机朝向的俯仰角(+ 抬头,- 低头)。 */
export function forwardPitch(forward: [number, number, number]): number {
return Math.asin(clampNumber(forward[2], -1, 1))
}
/**
* 由「用户拖拽/默认视角」的方位角与俯仰角构造相机姿态。
* 默认(yaw = 0, pitch = 0)时相机水平看向 +y,画面中的地平线保持水平。
*/
export function cameraFromYawPitch(yaw: number, pitch: number): Mat3 {
return multiply3(rotationZ(-yaw), rotationX(Math.PI / 2 + pitch))
}
/**
* 由设备姿态构造相机姿态(W3C 规范:R = Rz(α)·Rx(β)·Ry(γ)),
* 再按屏幕旋转角做一次横滚校正,使画面正向与屏幕正向一致。
*/
export function cameraFromDeviceOrientation(
alpha: number,
beta: number,
gamma: number,
screenAngle: number,
): Mat3 {
const device = multiply3(multiply3(rotationZ(alpha), rotationX(beta)), rotationY(gamma))
return multiply3(device, rotationZ(-screenAngle))
}
/**
* 在设备姿态之上叠加用户拖拽产生的视角偏移:水平方向围绕世界 z 轴旋转,
* 垂直方向围绕相机自身的右轴倾斜(小角度下等效于抬头/低头)。
*/
export function applyViewOffset(camera: Mat3, yawOffset: number, pitchOffset: number): Mat3 {
return multiply3(rotationZ(-yawOffset), multiply3(camera, rotationX(pitchOffset)))
}
/** 世界坐标 → 相机坐标的视图矩阵(纯旋转,所以就是转置)。 */
export function viewMatrixFromCamera(camera: Mat3): Mat3 {
return transpose3(camera)
}
/** 标准透视投影矩阵(列主序 4x4)。 */
export function perspectiveMatrix(
fovY: number,
aspect: number,
near: number,
far: number,
): Float32Array {
const f = 1 / Math.tan(fovY / 2)
const rangeInv = 1 / (near - far)
return new Float32Array([
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (far + near) * rangeInv, -1,
0, 0, 2 * far * near * rangeInv, 0,
])
}
+365
View File
@@ -0,0 +1,365 @@
// 用一段 WebGL 把 <video> 贴到球面/半球面上,实现 360°/180° 全景播放。
//
// 不引入 three.js:整个渲染器只是一段着色器 + 一个网格 + 一张视频纹理,依赖
// 极小,同时也不会把播放页首屏体积撑大(该模块由播放器按需懒加载)。
//
// 视频元素始终是同一个正在播放的 <video>,这里只把它当前的帧采样到球面上,
// 因此 HLS、直连、115 云转码等所有播放方式都能直接复用。
import type { Mat3 } from './vr360Math'
import { perspectiveMatrix } from './vr360Math'
import {
VR360_DEFAULT_FOV,
VR360_MAX_FOV,
VR360_MIN_FOV,
type Vr360Projection,
} from './vr360'
export type Vr360Renderer = {
setProjection(projection: Vr360Projection): void
/** 立体素材裁切:只取左眼所在的半张画面。 */
setFrameUv(scale: [number, number], offset: [number, number]): void
setView(view: Mat3): void
setFov(fovDeg: number): void
resize(width: number, height: number): void
draw(): void
dispose(): void
}
export type Vr360RendererOptions = {
/** WebGL 上下文丢失(移动端切后台、系统回收 GPU 资源)时回调,由调用方退出 VR 模式。 */
onContextLost?: () => void
/** 视频帧无法写入纹理(跨域污染等)时回调。 */
onTextureError?: (error: unknown) => void
}
const VERTEX_SHADER = `
attribute vec3 aPosition;
attribute vec2 aUv;
uniform mat3 uView;
uniform mat4 uProjection;
uniform vec2 uUvScale;
uniform vec2 uUvOffset;
varying vec2 vUv;
void main() {
vUv = aUv * uUvScale + uUvOffset;
gl_Position = uProjection * vec4(uView * aPosition, 1.0);
}
`
const FRAGMENT_SHADER = `
precision mediump float;
uniform sampler2D uTexture;
varying vec2 vUv;
void main() {
gl_FragColor = texture2D(uTexture, vUv);
}
`
// 球面半径为 1 个单位,相机固定在球心,远近裁剪面据此选取。
const NEAR_PLANE = 0.1
const FAR_PLANE = 10
type Mesh = {
positions: Float32Array
uvs: Float32Array
indices: Uint16Array
indexCount: number
}
type GridPoint = {
position: [number, number, number]
uv: [number, number]
}
function buildGrid(
columns: number,
rows: number,
point: (col: number, row: number) => GridPoint,
): Mesh {
const positions = new Float32Array((columns + 1) * (rows + 1) * 3)
const uvs = new Float32Array((columns + 1) * (rows + 1) * 2)
for (let row = 0; row <= rows; row++) {
for (let col = 0; col <= columns; col++) {
const index = row * (columns + 1) + col
const { position, uv } = point(col, row)
positions[index * 3] = position[0]
positions[index * 3 + 1] = position[1]
positions[index * 3 + 2] = position[2]
uvs[index * 2] = uv[0]
uvs[index * 2 + 1] = uv[1]
}
}
const indices = new Uint16Array(columns * rows * 6)
let cursor = 0
for (let row = 0; row < rows; row++) {
for (let col = 0; col < columns; col++) {
const topLeft = row * (columns + 1) + col
const topRight = topLeft + 1
const bottomLeft = topLeft + columns + 1
const bottomRight = bottomLeft + 1
indices[cursor++] = topLeft
indices[cursor++] = bottomLeft
indices[cursor++] = topRight
indices[cursor++] = topRight
indices[cursor++] = bottomLeft
indices[cursor++] = bottomRight
}
}
return { positions, uvs, indices, indexCount: indices.length }
}
/**
* 等距柱状投影:水平覆盖 ±span,垂直覆盖 -90°~+90°。
* u 从 0(左端)到 1(右端),v 从 0(画面顶部)到 1(画面底部)。
*/
function buildEquirectMesh(span: number): Mesh {
const columns = 96
const rows = 48
return buildGrid(columns, rows, (col, row) => {
const lambda = -span + (2 * span * col) / columns
const phi = -Math.PI / 2 + (Math.PI * row) / rows
const cosPhi = Math.cos(phi)
return {
position: [Math.sin(lambda) * cosPhi, Math.cos(lambda) * cosPhi, Math.sin(phi)],
uv: [(lambda + span) / (2 * span), 0.5 - phi / Math.PI],
}
})
}
/**
* 180° 鱼眼(等距投影):画面中心是正前方,纹理半径 r 与偏角 θ 成正比。
* 方画幅内切圆之外的区域不参与采样,标准 VR180 鱼眼素材即如此。
*/
function buildFisheyeMesh(): Mesh {
const radial = 64
const azimuth = 96
const thetaMax = Math.PI / 2
return buildGrid(azimuth, radial, (col, row) => {
const psi = (2 * Math.PI * col) / azimuth
const theta = (thetaMax * row) / radial
const sinTheta = Math.sin(theta)
const radius = theta / thetaMax
return {
position: [sinTheta * Math.cos(psi), Math.cos(theta), sinTheta * Math.sin(psi)],
uv: [0.5 + 0.5 * radius * Math.cos(psi), 0.5 - 0.5 * radius * Math.sin(psi)],
}
})
}
function buildMesh(projection: Vr360Projection): Mesh {
if (projection === 'fisheye180') return buildFisheyeMesh()
if (projection === 'equirect180') return buildEquirectMesh(Math.PI / 2)
return buildEquirectMesh(Math.PI)
}
function compileShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
gl.deleteShader(shader)
return null
}
return shader
}
/**
* 在给定画布上创建全景渲染器;浏览器不支持 WebGL 时返回 null,调用方据此
* 退回普通播放,而不是给用户一个黑屏。
*/
export function createVr360Renderer(
canvas: HTMLCanvasElement,
video: HTMLVideoElement,
options: Vr360RendererOptions = {},
): Vr360Renderer | null {
const attributes: WebGLContextAttributes = {
alpha: false,
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: false,
preserveDrawingBuffer: false,
powerPreference: 'high-performance',
}
const gl = (canvas.getContext('webgl2', attributes) ??
canvas.getContext('webgl', attributes)) as WebGLRenderingContext | null
if (!gl) return null
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER)
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER)
if (!vertexShader || !fragmentShader) return null
const program = gl.createProgram()
if (!program) return null
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)
gl.deleteShader(vertexShader)
gl.deleteShader(fragmentShader)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
gl.deleteProgram(program)
return null
}
const positionLocation = gl.getAttribLocation(program, 'aPosition')
const uvLocation = gl.getAttribLocation(program, 'aUv')
const viewLocation = gl.getUniformLocation(program, 'uView')
const projectionLocation = gl.getUniformLocation(program, 'uProjection')
const uvScaleLocation = gl.getUniformLocation(program, 'uUvScale')
const uvOffsetLocation = gl.getUniformLocation(program, 'uUvOffset')
const textureLocation = gl.getUniformLocation(program, 'uTexture')
const positionBuffer = gl.createBuffer()
const uvBuffer = gl.createBuffer()
const indexBuffer = gl.createBuffer()
const texture = gl.createTexture()
if (!positionBuffer || !uvBuffer || !indexBuffer || !texture) return null
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
// 先放一张 1x1 黑色占位图,视频帧尚未就绪时也不会出现未定义内容。
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 255]),
)
gl.disable(gl.CULL_FACE)
gl.disable(gl.DEPTH_TEST)
gl.disable(gl.BLEND)
gl.clearColor(0, 0, 0, 1)
let indexCount = 0
let uvScale: [number, number] = [1, 1]
let uvOffset: [number, number] = [0, 0]
let view: Mat3 = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1])
let fov = VR360_DEFAULT_FOV
let displayWidth = 1
let displayHeight = 1
let disposed = false
let contextLost = false
const uploadMesh = (mesh: Mesh) => {
indexCount = mesh.indexCount
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(gl.ARRAY_BUFFER, mesh.positions, gl.STATIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer)
gl.bufferData(gl.ARRAY_BUFFER, mesh.uvs, gl.STATIC_DRAW)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.indices, gl.STATIC_DRAW)
}
uploadMesh(buildMesh('equirect360'))
const handleContextLost = (event: Event) => {
// 必须 preventDefault,否则上下文不会被标记为可恢复;这里直接让上层退出 VR。
event.preventDefault()
contextLost = true
options.onContextLost?.()
}
canvas.addEventListener('webglcontextlost', handleContextLost)
return {
setProjection(projection: Vr360Projection) {
if (disposed) return
uploadMesh(buildMesh(projection))
},
setFrameUv(scale: [number, number], offset: [number, number]) {
uvScale = scale
uvOffset = offset
},
setView(next: Mat3) {
view = next
},
setFov(next: number) {
fov = Math.min(VR360_MAX_FOV, Math.max(VR360_MIN_FOV, next))
},
resize(nextWidth: number, nextHeight: number) {
if (disposed) return
// 画布位置与尺寸完全交给 CSS(inset-0 + 100%),绘制缓冲区只跟随它。
// 否则「观测到画布变大 → 设置更大的缓冲区 → 布局再次变大」会指数放大。
canvas.style.width = '100%'
canvas.style.height = '100%'
// 移动端按 DPR 渲染 4K 视频纹理代价很高,上限锁 2 倍。
const dpr = Math.min(2, Math.max(1, window.devicePixelRatio || 1))
displayWidth = Math.max(1, nextWidth)
displayHeight = Math.max(1, nextHeight)
const pixelWidth = Math.max(1, Math.round(displayWidth * dpr))
const pixelHeight = Math.max(1, Math.round(displayHeight * dpr))
if (canvas.width !== pixelWidth) canvas.width = pixelWidth
if (canvas.height !== pixelHeight) canvas.height = pixelHeight
},
draw() {
if (disposed || contextLost) return
if (canvas.width < 1 || canvas.height < 1) return
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clear(gl.COLOR_BUFFER_BIT)
if (video.readyState >= 2 && video.videoWidth > 0) {
try {
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video)
} catch (error) {
options.onTextureError?.(error)
}
}
gl.useProgram(program)
gl.uniformMatrix3fv(viewLocation, false, view)
gl.uniformMatrix4fv(
projectionLocation,
false,
perspectiveMatrix(
(fov * Math.PI) / 180,
displayWidth / displayHeight,
NEAR_PLANE,
FAR_PLANE,
),
)
gl.uniform2f(uvScaleLocation, uvScale[0], uvScale[1])
gl.uniform2f(uvOffsetLocation, uvOffset[0], uvOffset[1])
gl.uniform1i(textureLocation, 0)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.enableVertexAttribArray(positionLocation)
gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer)
gl.enableVertexAttribArray(uvLocation)
gl.vertexAttribPointer(uvLocation, 2, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0)
},
dispose() {
if (disposed) return
disposed = true
canvas.removeEventListener('webglcontextlost', handleContextLost)
gl.bindBuffer(gl.ARRAY_BUFFER, null)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null)
gl.deleteBuffer(positionBuffer)
gl.deleteBuffer(uvBuffer)
gl.deleteBuffer(indexBuffer)
gl.deleteTexture(texture)
gl.deleteProgram(program)
// 主动释放上下文:移动端同时存活多个 WebGL 上下文会被浏览器强制回收。
gl.getExtension('WEBGL_lose_context')?.loseContext()
},
}
}