mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8017e994f4 | |||
| d56fb53a08 | |||
| 30ca94c79b | |||
| 9c1f9a6e7e | |||
| ec7be38266 | |||
| ebf791c436 | |||
| 83c0b65a0b |
@@ -174,6 +174,39 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
// 默认未看过:配置接口必须先如实告知前端,否则首次操作说明会被跳过。
|
||||
initial := svc.Danmaku.ConfigForUser(t.Context(), "user-1")
|
||||
if initial.Vr360GuideSeen {
|
||||
t.Fatal("vr360 guide should be unread for a fresh user")
|
||||
}
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"vr360_guide_seen":true}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var cfg service.DanmakuRenderConfig
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !cfg.Vr360GuideSeen {
|
||||
t.Fatalf("config should reflect the persisted guide flag: %+v", cfg)
|
||||
}
|
||||
|
||||
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read persisted user: %v", err)
|
||||
}
|
||||
if !user.PlayerVr360GuideSeen {
|
||||
t.Fatalf("guide flag not persisted on the user: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
|
||||
@@ -722,7 +722,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if mount.ProxyPlay {
|
||||
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
|
||||
// 与挂载账号的 proxy_play 开关等价。
|
||||
if mount.ProxyPlay || wantSameOriginProxy(c) {
|
||||
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
@@ -758,6 +760,15 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
// ?proxy=1:把网盘/STRM 直链改为服务端同源转发(网页端读帧、VR 全景用),
|
||||
// 画质与原文件一致,不触发转码。
|
||||
if wantSameOriginProxy(c) {
|
||||
handled, proxyErr := proxySTRMStream(c, svc, m)
|
||||
if handled {
|
||||
writeProxyError(c, proxyErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
||||
if errors.Is(err, service.ErrMediaNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// 直连流的同源转发:网页端需要读取视频帧的场景(VR 全景渲染走 WebGL 纹理)
|
||||
// 不能使用会跳到网盘 CDN 的跨域直链,这里把直链改为服务端转发。
|
||||
//
|
||||
// 只在客户端显式带上 ?proxy=1 时生效,普通播放仍走原来的 302 直连,
|
||||
// 避免把网盘流量无谓地压到服务器上。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// wantSameOriginProxy 客户端是否要求把直连流改为服务端同源转发。
|
||||
func wantSameOriginProxy(c *gin.Context) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(c.Query("proxy"))) {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// proxySTRMStream 处理 STRM/网盘媒体的同源转发请求。返回 handled=false 表示
|
||||
// 该媒体不需要(或无法)代理,调用方继续按原有逻辑处理。
|
||||
func proxySTRMStream(c *gin.Context, svc *service.Container, m *model.Media) (bool, error) {
|
||||
if c == nil || svc == nil || svc.Strm == nil || m == nil {
|
||||
return false, nil
|
||||
}
|
||||
if !service.IsStrmMediaRow(m) {
|
||||
return false, nil
|
||||
}
|
||||
if err := svc.Strm.ProxyMediaDirect(c.Request.Context(), c.Writer, c.Request, m); err != nil {
|
||||
if errors.Is(err, service.ErrStrmProxyNotApplicable) {
|
||||
return false, nil
|
||||
}
|
||||
return true, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// writeProxyError 在尚未写入任何响应内容时回一个明确的网关错误。
|
||||
func writeProxyError(c *gin.Context, err error) {
|
||||
if c == nil || err == nil || c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
+12
-9
@@ -37,15 +37,18 @@ type User struct {
|
||||
// original / simplified / traditional。
|
||||
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
|
||||
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
||||
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
||||
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
||||
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
|
||||
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
|
||||
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
|
||||
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
|
||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
|
||||
// PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明,
|
||||
// 按用户保存:看过一次之后不再弹出。
|
||||
PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"`
|
||||
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
||||
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
||||
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
||||
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
|
||||
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
|
||||
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
|
||||
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
|
||||
// DanmakuAppKey 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
|
||||
DanmakuAppKey string `gorm:"size:256" json:"-"`
|
||||
// ExpiredAt is the account expiry time. Nil means the account never
|
||||
|
||||
@@ -71,6 +71,8 @@ type DanmakuRenderConfig struct {
|
||||
Volume float64 `json:"volume"`
|
||||
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
|
||||
PlaybackRate float64 `json:"playback_rate"`
|
||||
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
|
||||
Vr360GuideSeen bool `json:"vr360_guide_seen"`
|
||||
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
|
||||
@@ -98,6 +100,8 @@ type DanmakuSettingsPatch struct {
|
||||
MergeSources *bool `json:"merge_sources"`
|
||||
Volume *float64 `json:"volume"`
|
||||
PlaybackRate *float64 `json:"playback_rate"`
|
||||
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
|
||||
Vr360GuideSeen *bool `json:"vr360_guide_seen"`
|
||||
}
|
||||
|
||||
type danmakuUserContextKey struct{}
|
||||
@@ -447,6 +451,7 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||
Area: strconv.FormatFloat(area, 'f', -1, 64),
|
||||
Volume: volume,
|
||||
PlaybackRate: playbackRate,
|
||||
Vr360GuideSeen: user.PlayerVr360GuideSeen,
|
||||
MergeSources: user.DanmakuMergeSources,
|
||||
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
|
||||
}
|
||||
@@ -528,6 +533,9 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
|
||||
}
|
||||
updates["player_playback_rate"] = *patch.PlaybackRate
|
||||
}
|
||||
if patch.Vr360GuideSeen != nil {
|
||||
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
|
||||
}
|
||||
|
||||
@@ -182,10 +182,13 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
|
||||
// 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部
|
||||
// ID 预先收窄候选集,同番号的其它分片会被 SQL 直接排除,表现就是库里折叠
|
||||
// 出了 N 个版本、详情页却只列出带 ID 的那几个。因此成人条目不预先收窄,
|
||||
// 直接在该库范围内比对版本键。
|
||||
// 直接在该库范围内比对版本键。未刮削但文件名带番号的分片同理:同一部片的
|
||||
// 各分片元数据来源不一致(刮削 vs pending),收窄后标题也对不上。
|
||||
var candidates []model.Media
|
||||
narrowed := false
|
||||
if mediaAdultGroupCode(*media) == "" {
|
||||
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
|
||||
@@ -198,9 +201,15 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@ func groupMediaVersions(items []model.Media) []MediaItem {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
// 同一番号只要有一个分片被刮削标记为成人内容,就认定整部片都是成人条目:
|
||||
// 未刮削的分片(nsfw=false)也按番号折叠。否则「一个分片刮削成功、其余仍
|
||||
// pending」时,已刮削的走番号分组、其余落到标题分组,整部片被拆成两张卡。
|
||||
adultCodes := adultCodesVouchedByNSFW(items)
|
||||
type group struct {
|
||||
key string
|
||||
rows []model.Media
|
||||
@@ -84,7 +88,7 @@ func groupMediaVersions(items []model.Media) []MediaItem {
|
||||
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{rows: []model.Media{item}})
|
||||
continue
|
||||
@@ -135,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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 同源转发必须原样保留 Range 语义:把 206 改写成 200 会让浏览器误判响应长度,
|
||||
// 拖动进度条时反复重新拉流。
|
||||
func TestProxyMediaDirectForwardsRangeAndStatus(t *testing.T) {
|
||||
var gotRange string
|
||||
var gotUA string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotRange = r.Header.Get("Range")
|
||||
gotUA = r.Header.Get("User-Agent")
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.Header().Set("Content-Range", "bytes 10-19/100")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write([]byte("0123456789"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
svc := testStrmService(t)
|
||||
media := &model.Media{STRMURL: upstream.URL + "/video.mp4"}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||
req.Header.Set("Range", "bytes=10-19")
|
||||
req.Header.Set("User-Agent", "MeBoxTest/1.0")
|
||||
|
||||
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
|
||||
t.Fatalf("ProxyMediaDirect: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, want 206", rec.Code)
|
||||
}
|
||||
if gotRange != "bytes=10-19" {
|
||||
t.Fatalf("upstream Range = %q, want bytes=10-19", gotRange)
|
||||
}
|
||||
if gotUA != "MeBoxTest/1.0" {
|
||||
t.Fatalf("upstream User-Agent = %q, want MeBoxTest/1.0", gotUA)
|
||||
}
|
||||
if rec.Header().Get("Content-Range") != "bytes 10-19/100" {
|
||||
t.Fatalf("Content-Range = %q", rec.Header().Get("Content-Range"))
|
||||
}
|
||||
if rec.Body.String() != "0123456789" {
|
||||
t.Fatalf("body = %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 本地文件本身就是同源资源,不应该被代理(调用方按原静态文件逻辑处理)。
|
||||
func TestProxyMediaDirectSkipsLocalFile(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "movie.mkv")
|
||||
writeFile(t, path, "not-a-video")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||
err := svc.ProxyMediaDirect(context.Background(), rec, req, &model.Media{Path: path})
|
||||
if !errors.Is(err, ErrStrmProxyNotApplicable) {
|
||||
t.Fatalf("err = %v, want ErrStrmProxyNotApplicable", err)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Fatalf("local file must not be proxied, body = %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -746,7 +746,10 @@ func TestHandleMetaSha1Identity(t *testing.T) {
|
||||
|
||||
// fakeRemoteProvider 是 walkRemote 并发遍历的假提供方:返回一棵固定目录树,
|
||||
// 并记录每个目录被 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -141,8 +141,13 @@ const profileQuery = () => {
|
||||
// streamURL returns a direct-play URL for <video src>. The JWT is added as
|
||||
// a query parameter because <video> elements cannot send Authorization
|
||||
// headers.
|
||||
export function streamURL(mediaId: string): string {
|
||||
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}`
|
||||
//
|
||||
// proxy=true 时由服务端把网盘/STRM 直链转发为同源数据(画质与原文件一致、
|
||||
// 不转码)。VR 全景渲染需要把视频帧读进 WebGL 纹理,跨域直链会被浏览器
|
||||
// 判定为污染资源而禁止读取,因此只有这种场景才需要开启。
|
||||
export function streamURL(mediaId: string, options: { proxy?: boolean } = {}): string {
|
||||
const proxy = options.proxy ? '&proxy=1' : ''
|
||||
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}${proxy}`
|
||||
}
|
||||
|
||||
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface DanmakuConfig {
|
||||
area: string
|
||||
volume: number
|
||||
playback_rate: number
|
||||
/** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */
|
||||
vr360_guide_seen: boolean
|
||||
/** Per-user preference: merge the same episode's multiple sources. */
|
||||
merge_sources: boolean
|
||||
}
|
||||
@@ -89,6 +91,8 @@ export interface DanmakuSettingsPatch {
|
||||
merge_sources?: boolean
|
||||
volume?: number
|
||||
playback_rate?: number
|
||||
/** 看过 VR 操作说明后置为 true,之后不再弹出。 */
|
||||
vr360_guide_seen?: boolean
|
||||
}
|
||||
|
||||
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
|
||||
|
||||
@@ -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
@@ -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 } from './playerTheme'
|
||||
|
||||
// PlayerDanmakuPanel — the on-player danmaku control panel. It displays
|
||||
// the matched danmaku details (anime title, episode title, comment count,
|
||||
@@ -194,27 +195,30 @@ 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={`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={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="关闭"
|
||||
>
|
||||
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 是否加载弹幕 */}
|
||||
<div className="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 +577,7 @@ export function PlayerDanmakuPanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,40 @@ type PlayerPlaylistPanelProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
currentMediaId: string
|
||||
/** 当前正在播放媒体的全部版本(含自身)。多版本时面板顶部展示版本切换。 */
|
||||
currentVersions?: Media[]
|
||||
episodes: Media[]
|
||||
onSelectEpisode: (media: Media) => void
|
||||
/** 切换到同一集/同一条目的另一个版本。 */
|
||||
onSelectVersion?: (media: Media) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 选集面板 —— 右侧整条抽屉,内容参考 B 站的番剧选集。
|
||||
*
|
||||
* 以前这里是「悬浮卡片 + 每集一行缩略图」,两三百集的库翻起来很累,卡片本身
|
||||
* 也和画面上别的浮层长得不一样。现在改成:整条贴住播放区右边缘的抽屉,剧集用
|
||||
* 编号宫格排(一屏能看到几十集),标题/时长放在悬停提示里;当前播放的那一格用
|
||||
* 强调色填充。
|
||||
*
|
||||
* 多版本(同一部片的 4K / 1080p / 云端直链等)依旧不丢:宫格右上角的小角标表示
|
||||
* 这一集有多个版本,点角标会在宫格上方展开版本列表;当前正在播放的条目的版本
|
||||
* 则常驻在抽屉顶部,随时可换。单条媒体(电影)没有剧集列表时,抽屉本身就是
|
||||
* 版本选择器。
|
||||
*/
|
||||
export function PlayerPlaylistPanel({
|
||||
open,
|
||||
onClose,
|
||||
currentMediaId,
|
||||
currentVersions = [],
|
||||
episodes,
|
||||
onSelectEpisode,
|
||||
onSelectVersion,
|
||||
}: 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 +73,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 +89,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 +117,99 @@ 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={`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} 集)
|
||||
<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="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||
title="关闭"
|
||||
>
|
||||
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 当前条目的版本切换:多版本时置顶,随时可换 */}
|
||||
{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="no-scrollbar flex shrink-0 items-center gap-1.5 overflow-x-auto border-b border-white/10 px-3 py-2">
|
||||
{seasonGroups.map(({ season, episodes: sesEps }) => {
|
||||
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 +235,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 +255,146 @@ 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="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="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 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 h-3.5 w-3.5 items-center justify-center rounded-bl-md rounded-tr-md text-[9px] font-semibold leading-none transition ${
|
||||
versionsForEpisodeId === ep.id
|
||||
? 'bg-amber-300 text-black'
|
||||
: 'bg-black/45 text-amber-200 hover:bg-black/70'
|
||||
}`}
|
||||
title={`${versions.length} 个版本,点这里选择`}
|
||||
>
|
||||
{versions.length}
|
||||
</button>
|
||||
)}
|
||||
{durationText && <span>{durationText}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
versionsToSwitch.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center px-4 text-center text-xs text-white/40">
|
||||
暂无可切换的内容
|
||||
</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)) {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react'
|
||||
import { Check, Compass, Minus, Plus, RotateCcw, Settings2, Smartphone } from 'lucide-react'
|
||||
import { 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,
|
||||
@@ -66,10 +74,21 @@ type Vr360StageProps = {
|
||||
uiVisible: boolean
|
||||
/** 轻触画面(未发生拖拽)时回调,与普通播放一致:唤出控制栏或切换播放。 */
|
||||
onSurfaceTap: () => void
|
||||
/**
|
||||
* 第一帧真正画出来时回调,每次挂载只上报一次(改投影方式/画幅布局不会重新上报),
|
||||
* 调用方据此结束「正在启动」提示。
|
||||
*/
|
||||
onReady: () => void
|
||||
onError: (message: string) => void
|
||||
/** 用户调整投影方式/立体布局时回调,由播放页保存为下次进入的默认值。 */
|
||||
onProfileChange?: (profile: Vr360Profile) => void
|
||||
/** 退出 VR 全景播放(回到普通播放)。不提供时工具条上不显示退出入口。 */
|
||||
onExitVr?: () => void
|
||||
/**
|
||||
* 鼠标是否停留在 VR 工具条/设置面板上。由播放页转给控制栏:悬停这些浮层时
|
||||
* 控制栏不应该自动隐藏(否则设置面板会在点击前消失)。
|
||||
*/
|
||||
onUiHoldChange?: (hold: boolean) => void
|
||||
}
|
||||
|
||||
/** 每像素拖拽对应的转角(弧度)。视场角越小时画面越「拉近」,拖拽也应更细腻。 */
|
||||
@@ -92,6 +111,20 @@ function screenOrientationAngle(): number {
|
||||
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'>
|
||||
}
|
||||
@@ -113,6 +146,8 @@ export function Vr360Stage({
|
||||
onReady,
|
||||
onError,
|
||||
onProfileChange,
|
||||
onExitVr,
|
||||
onUiHoldChange,
|
||||
}: Vr360StageProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const rendererRef = useRef<Vr360Renderer | null>(null)
|
||||
@@ -368,6 +403,15 @@ export function Vr360Stage({
|
||||
|
||||
useEffect(() => () => stopGyro(), [stopGyro])
|
||||
|
||||
// 控制栏/浮层隐藏时收起「画面」设置面板:它属于同一套浮层,下次唤出控制栏时
|
||||
// 不应该自动弹回来挡住画面。
|
||||
useEffect(() => {
|
||||
if (uiVisible) return
|
||||
setSettingsOpen(false)
|
||||
// 浮层不可见时鼠标离开事件不会再触发,这里主动解除「悬停保持显示」。
|
||||
onUiHoldChange?.(false)
|
||||
}, [uiVisible, onUiHoldChange])
|
||||
|
||||
const zoomBy = useCallback(
|
||||
(factor: number) => {
|
||||
const view = viewRef.current
|
||||
@@ -396,6 +440,10 @@ export function Vr360Stage({
|
||||
}, [])
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
// 轻触画面把视角拖动之外的浮层悬停状态清掉:触摸设备不会再发出 mouseleave,
|
||||
// 上一步点击工具条/设置面板留下的「悬停保持显示」会一直挂着,控制栏就永远
|
||||
// 不隐藏了。用户已经在点画面,说明指针早已离开浮层。
|
||||
if (event.pointerType === 'touch') onUiHoldChange?.(false)
|
||||
try {
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
} catch {
|
||||
@@ -483,7 +531,10 @@ export function Vr360Stage({
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
data-vr360-surface
|
||||
className="absolute inset-0 h-full w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
||||
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}
|
||||
@@ -493,29 +544,43 @@ export function Vr360Stage({
|
||||
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="pointer-events-auto flex flex-wrap items-center justify-center gap-1.5 rounded-2xl border border-white/15 bg-black/65 px-2 py-1.5 text-white shadow-2xl backdrop-blur">
|
||||
<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={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition ${
|
||||
gyroState === 'active'
|
||||
? 'bg-rose-500/90 text-white hover:bg-rose-500'
|
||||
: 'bg-white/10 text-white/85 hover:bg-white/20'
|
||||
className={`${PLAYER_TEXT_BUTTON} ${
|
||||
gyroState === 'active' ? 'bg-rose-500 text-white hover:bg-rose-500' : ''
|
||||
}`}
|
||||
title="用手机陀螺仪转动视角"
|
||||
>
|
||||
<Smartphone size={14} />
|
||||
陀螺仪
|
||||
{gyroState === 'active' && <Compass size={13} className="text-white/85" />}
|
||||
{gyroState === 'active' && <Compass size={12} className="text-white/85" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetView}
|
||||
className="flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-xs font-medium text-white/85 transition hover:bg-white/20"
|
||||
className={PLAYER_TEXT_BUTTON}
|
||||
title="恢复默认视角与视场角"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
@@ -524,83 +589,95 @@ export function Vr360Stage({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen((open) => !open)}
|
||||
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition ${
|
||||
settingsOpen
|
||||
? 'bg-rose-500/90 text-white hover:bg-rose-500'
|
||||
: 'bg-white/10 text-white/85 hover:bg-white/20'
|
||||
}`}
|
||||
className={`${PLAYER_TEXT_BUTTON} ${settingsOpen ? 'bg-white/15 text-white' : ''}`}
|
||||
title="投影方式与画幅布局"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
画面
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomBy(1.15)}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title="视野拉远"
|
||||
>
|
||||
<Minus size={15} />
|
||||
</button>
|
||||
<span className="w-9 text-center text-[10px] tabular-nums text-white/75">
|
||||
{(VR360_DEFAULT_FOV / fovHint).toFixed(1)}×
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomBy(1 / 1.15)}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title="视野拉近"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div 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-full bg-black/45 px-3 py-1 text-[10px] text-white/70 backdrop-blur">
|
||||
<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="pointer-events-auto w-[min(92vw,420px)] rounded-2xl border border-white/15 bg-black/75 px-3 py-2.5 text-white shadow-2xl backdrop-blur">
|
||||
<p className="mb-1.5 text-[10px] uppercase tracking-wide text-white/45">投影方式</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<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={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[11px] transition ${
|
||||
profile.projection === value
|
||||
? 'bg-rose-500/25 text-rose-200'
|
||||
: 'bg-white/5 text-white/75 hover:bg-white/15'
|
||||
className={`${PLAYER_SEGMENT} ${
|
||||
profile.projection === value ? PLAYER_SEGMENT_ON : PLAYER_SEGMENT_OFF
|
||||
}`}
|
||||
>
|
||||
{profile.projection === value && <Check size={11} />}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mb-1.5 mt-2.5 text-[10px] uppercase tracking-wide text-white/45">
|
||||
<p className="mb-1.5 mt-3 text-[10px] uppercase tracking-wide text-white/40">
|
||||
画幅布局
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className={PLAYER_SEGMENT_GROUP}>
|
||||
{VR360_STEREO_OPTIONS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onProfileChange?.({ ...profile, stereo: value })}
|
||||
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[11px] transition ${
|
||||
profile.stereo === value
|
||||
? 'bg-rose-500/25 text-rose-200'
|
||||
: 'bg-white/5 text-white/75 hover:bg-white/15'
|
||||
className={`${PLAYER_SEGMENT} ${
|
||||
profile.stereo === value ? PLAYER_SEGMENT_ON : PLAYER_SEGMENT_OFF
|
||||
}`}
|
||||
>
|
||||
{profile.stereo === value && <Check size={11} />}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{profile.stereo !== 'mono' && (
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-white/45">
|
||||
<p className="mt-2.5 text-[10px] leading-relaxed text-white/45">
|
||||
左右/上下并排素材在当前屏幕上只显示左眼画面;真正的双眼立体画面需要 VR
|
||||
头显(WebXR)。
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 播放器浮层的统一视觉语言。
|
||||
*
|
||||
* 播放器里现在有十几种浮层:底部操作栏、清晰度/倍速/字幕小弹层、设置面板、
|
||||
* 选集抽屉、弹幕面板、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`
|
||||
|
||||
/** 操作栏里的图标按钮。所有图标按钮共用同一尺寸与悬停反馈,排在一起才整齐。 */
|
||||
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'
|
||||
@@ -164,7 +164,7 @@ export function MediaDetailMainContent({
|
||||
onToggleFavourite={onToggleFavourite}
|
||||
playTargetId={playTargetId}
|
||||
/>
|
||||
<MediaVersionSwitcher media={media} mode="detail" />
|
||||
<MediaVersionSwitcher media={media} />
|
||||
{isAdmin && (
|
||||
<MediaDetailAdminPanel
|
||||
media={media}
|
||||
|
||||
+195
-46
@@ -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,18 +34,17 @@ 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 { 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,
|
||||
@@ -108,6 +108,17 @@ export function PlayerPage() {
|
||||
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)
|
||||
@@ -126,6 +137,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)
|
||||
@@ -272,6 +286,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
|
||||
@@ -471,6 +486,8 @@ export function PlayerPage() {
|
||||
setMedia(null)
|
||||
setLoadError('')
|
||||
vr360TouchedRef.current = false
|
||||
vr360TextureRetryUsedRef.current = false
|
||||
vr360TextureRetryUntilRef.current = 0
|
||||
setVr360(null)
|
||||
setVr360Detected(false)
|
||||
setHlsStartSec(0)
|
||||
@@ -578,6 +595,35 @@ export function PlayerPage() {
|
||||
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) {
|
||||
@@ -607,11 +653,11 @@ export function PlayerPage() {
|
||||
saveVr360Preference({ ...loadVr360Preference(), profile })
|
||||
}, [])
|
||||
|
||||
const handleVr360Error = useCallback((message: string) => {
|
||||
vr360TouchedRef.current = true
|
||||
setVr360(null)
|
||||
setPlayerError(message)
|
||||
toast.error(message)
|
||||
// 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.
|
||||
@@ -685,6 +731,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
|
||||
@@ -869,13 +942,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)
|
||||
}
|
||||
@@ -904,6 +994,7 @@ export function PlayerPage() {
|
||||
setPlaybackMode,
|
||||
switchToLocalHLS,
|
||||
teardownHls,
|
||||
vr360DirectProxy,
|
||||
])
|
||||
|
||||
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
|
||||
@@ -1092,9 +1183,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(() => {
|
||||
@@ -1132,8 +1226,8 @@ export function PlayerPage() {
|
||||
[navigate, location.search, location.state],
|
||||
)
|
||||
|
||||
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本入口。
|
||||
const versionList = useMemo(
|
||||
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本信息。
|
||||
const currentVersions = useMemo(
|
||||
() => (media?.id === id ? mediaVersionsOf(media) : []),
|
||||
[id, media],
|
||||
)
|
||||
@@ -1232,7 +1326,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(() => {
|
||||
@@ -1263,38 +1397,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
|
||||
@@ -1420,7 +1565,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,
|
||||
@@ -1511,20 +1658,14 @@ export function PlayerPage() {
|
||||
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
|
||||
media={media}
|
||||
loadError={loadError}
|
||||
@@ -1554,7 +1695,7 @@ export function PlayerPage() {
|
||||
danmakuEpisodeId={danmakuEpisodeId}
|
||||
danmakuSearchTrigger={danmakuSearchTrigger}
|
||||
danmakuOpen={danmakuOpen}
|
||||
onToggleDanmaku={toggleDanmakuOpen}
|
||||
onOpenDanmaku={toggleDanmakuOpen}
|
||||
onDanmakuLoaded={danmakuLoaded}
|
||||
onDanmakuCandidates={danmakuGotCandidates}
|
||||
onDanmakuAlternatives={danmakuGotAlternatives}
|
||||
@@ -1565,15 +1706,19 @@ export function PlayerPage() {
|
||||
prevEpisodeTitle={prevEpisodeTitle}
|
||||
nextEpisodeTitle={nextEpisodeTitle}
|
||||
playlistOpen={playlistOpen}
|
||||
hasPlaylist={playlistEpisodes.length > 0}
|
||||
hasPlaylist={playlistEpisodes.length > 1 || currentVersions.length > 1}
|
||||
hasVersions={currentVersions.length > 1}
|
||||
onTogglePlaylist={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}
|
||||
@@ -1581,13 +1726,17 @@ export function PlayerPage() {
|
||||
onToggleVr360={toggleVr360}
|
||||
onVr360ProfileChange={changeVr360Profile}
|
||||
onVr360Error={handleVr360Error}
|
||||
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
|
||||
onDismissVr360Guide={dismissVr360Guide}
|
||||
playlistPanel={
|
||||
<PlayerPlaylistPanel
|
||||
open={playlistOpen}
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
currentMediaId={media?.id ?? ''}
|
||||
currentVersions={currentVersions}
|
||||
episodes={playlistEpisodes}
|
||||
onSelectEpisode={playEpisode}
|
||||
onSelectVersion={switchVersion}
|
||||
/>
|
||||
}
|
||||
danmakuPanel={
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -19,6 +29,7 @@ import {
|
||||
type SubtitlePosition,
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
import { isPointerInLockZone } from '../utils/playerLockZone'
|
||||
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
|
||||
|
||||
type SubtitleRenderGroup = {
|
||||
@@ -27,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[] = []
|
||||
@@ -164,7 +185,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
|
||||
@@ -180,14 +202,22 @@ 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 素材(用于在菜单里提示)。 */
|
||||
@@ -195,6 +225,9 @@ type PlayerVideoStageProps = {
|
||||
onToggleVr360?: () => void
|
||||
onVr360ProfileChange?: (profile: Vr360Profile) => void
|
||||
onVr360Error?: (message: string) => void
|
||||
/** 是否展示 VR 全景播放的首次操作说明(按用户只弹一次)。 */
|
||||
showVr360Guide?: boolean
|
||||
onDismissVr360Guide?: () => void
|
||||
waiting?: boolean
|
||||
waitingMessage?: string
|
||||
}
|
||||
@@ -228,7 +261,7 @@ export function PlayerVideoStage({
|
||||
danmakuEpisodeId,
|
||||
danmakuSearchTrigger = 0,
|
||||
danmakuOpen,
|
||||
onToggleDanmaku,
|
||||
onOpenDanmaku,
|
||||
onDanmakuLoaded,
|
||||
onDanmakuCandidates,
|
||||
onDanmakuAlternatives,
|
||||
@@ -242,19 +275,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) {
|
||||
@@ -262,8 +301,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 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
|
||||
const [vrReady, setVrReady] = useState(false)
|
||||
// 只按「媒体 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[]>([])
|
||||
@@ -286,11 +344,6 @@ export function PlayerVideoStage({
|
||||
setAssFallbackPath(null)
|
||||
}, [media?.id, activeSubtitleTrack?.path, subs])
|
||||
|
||||
// 切换媒体或 VR 配置后重新等待渲染器的第一帧。
|
||||
useEffect(() => {
|
||||
setVrReady(false)
|
||||
}, [media?.id, vr360?.projection, vr360?.stereo])
|
||||
|
||||
useEffect(() => {
|
||||
const selectedTrack = subs[subtitleIndex]
|
||||
let cancelled = false
|
||||
@@ -410,20 +463,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 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
|
||||
@@ -599,6 +749,8 @@ export function PlayerVideoStage({
|
||||
data-player-stage
|
||||
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
||||
onPointerDown={handleStagePointerDown}
|
||||
onPointerMove={handleStagePointerMove}
|
||||
onMouseLeave={() => setLockHovered(false)}
|
||||
onClick={handleSurfaceActivate}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
>
|
||||
@@ -639,9 +791,11 @@ export function PlayerVideoStage({
|
||||
profile={vr360}
|
||||
uiVisible={controlsVisible}
|
||||
onSurfaceTap={handleSurfaceActivate}
|
||||
onReady={() => setVrReady(true)}
|
||||
onReady={() => setVrReadyMediaId(media.id)}
|
||||
onError={(message) => onVr360Error?.(message)}
|
||||
onProfileChange={onVr360ProfileChange}
|
||||
onExitVr={onToggleVr360}
|
||||
onUiHoldChange={setVrUiHovered}
|
||||
/>
|
||||
) : null}
|
||||
<DanmakuStage
|
||||
@@ -704,8 +858,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}
|
||||
@@ -717,7 +873,7 @@ export function PlayerVideoStage({
|
||||
onSubtitleStyleChange={onSubtitleStyleChange}
|
||||
danmakuOpen={danmakuOpen}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
onToggleDanmaku={onToggleDanmaku}
|
||||
onOpenDanmaku={onOpenDanmaku}
|
||||
hasPrevEpisode={hasPrevEpisode}
|
||||
hasNextEpisode={hasNextEpisode}
|
||||
onPrevEpisode={onPrevEpisode}
|
||||
@@ -726,14 +882,18 @@ 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}
|
||||
@@ -748,23 +908,103 @@ export function PlayerVideoStage({
|
||||
)}
|
||||
{vr360 && media && !vrReady ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-black/80 px-5 py-3 text-sm text-white shadow-2xl backdrop-blur">
|
||||
<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}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { mediaVersionLabel } from './mediaVersion.ts'
|
||||
import {
|
||||
isStrmMedia,
|
||||
mediaVersionFileName,
|
||||
mediaVersionLabel,
|
||||
mediaVersionMatches,
|
||||
mediaVersionSourceLabel,
|
||||
} from './mediaVersion.ts'
|
||||
import type { Media } from '../types/index.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
@@ -41,4 +47,21 @@ const dottedPart = mediaVersionLabel(media({
|
||||
}))
|
||||
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')
|
||||
|
||||
@@ -176,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, '')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
LOCK_ZONE_HEIGHT,
|
||||
LOCK_ZONE_WIDTH,
|
||||
isPointerInLockZone,
|
||||
} from './playerLockZone.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`playerLockZone: ${name}`)
|
||||
}
|
||||
|
||||
const H = 800
|
||||
const MID = H / 2
|
||||
|
||||
// 命中:紧贴左边缘、垂直正中。
|
||||
check('left edge at vertical center hits', isPointerInLockZone(0, MID, H))
|
||||
check('inside the zone near the left hits', isPointerInLockZone(LOCK_ZONE_WIDTH - 1, MID, H))
|
||||
check('top of the zone still hits', isPointerInLockZone(10, MID - LOCK_ZONE_HEIGHT / 2, H))
|
||||
check('bottom of the zone still hits', isPointerInLockZone(10, MID + LOCK_ZONE_HEIGHT / 2, H))
|
||||
|
||||
// 未命中:右侧超出宽度、上下超出高度、中心之外。
|
||||
check('beyond the width misses', !isPointerInLockZone(LOCK_ZONE_WIDTH + 1, MID, H))
|
||||
check('just past the width edge misses', !isPointerInLockZone(LOCK_ZONE_WIDTH + 0.5, MID, H))
|
||||
check('above the zone misses', !isPointerInLockZone(10, MID - LOCK_ZONE_HEIGHT / 2 - 1, H))
|
||||
check('below the zone misses', !isPointerInLockZone(10, MID + LOCK_ZONE_HEIGHT / 2 + 1, H))
|
||||
check('negative x misses', !isPointerInLockZone(-1, MID, H))
|
||||
check('top-left corner misses', !isPointerInLockZone(0, 0, H))
|
||||
check('right edge at center misses', !isPointerInLockZone(1200, MID, H))
|
||||
|
||||
// 不同画面高度下感应区跟着中心移动。
|
||||
check('tracks center on a short stage', isPointerInLockZone(10, 300, 600))
|
||||
check('short stage: off-center misses', !isPointerInLockZone(10, MID, 600))
|
||||
|
||||
// 脏数据一律不命中,避免锁在奇怪的位置冒出来。
|
||||
check('NaN x misses', !isPointerInLockZone(Number.NaN, MID, H))
|
||||
check('NaN y misses', !isPointerInLockZone(10, Number.NaN, H))
|
||||
check('zero stage height misses', !isPointerInLockZone(10, 0, 0))
|
||||
check('negative stage height misses', !isPointerInLockZone(10, 0, -100))
|
||||
check('NaN stage height misses', !isPointerInLockZone(10, 0, Number.NaN))
|
||||
|
||||
console.log('playerLockZone.test.ts ok')
|
||||
@@ -0,0 +1,27 @@
|
||||
// 播放器「锁」按钮的几何判定。
|
||||
//
|
||||
// 锁按钮默认隐身:桌面端靠鼠标移到画面左侧中间把它唤出来,移动端靠点击画面。
|
||||
// 这里用坐标判断鼠标是否落在感应区里,而不是在画面上摆一个透明的热区元素——
|
||||
// 隐形热区会吃掉全景拖动的手势,也会挡住画面本身的单击。
|
||||
|
||||
/** 感应区宽度(像素),自画面左边缘向右展开。 */
|
||||
export const LOCK_ZONE_WIDTH = 72
|
||||
/** 感应区高度(像素),以画面垂直中心为基准上下各展开一半。 */
|
||||
export const LOCK_ZONE_HEIGHT = 88
|
||||
|
||||
/**
|
||||
* 判断鼠标位置(相对画面左上角)是否落在锁按钮的感应区内。
|
||||
*
|
||||
* 参数异常(NaN、画面高度为 0 等)一律返回 false:宁可锁不出现,也不要因为
|
||||
* 拿到脏数据让它在莫名其妙的位置冒出来。
|
||||
*/
|
||||
export function isPointerInLockZone(
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
stageHeight: number,
|
||||
): boolean {
|
||||
if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) return false
|
||||
if (!Number.isFinite(stageHeight) || stageHeight <= 0) return false
|
||||
if (offsetX < 0 || offsetX > LOCK_ZONE_WIDTH) return false
|
||||
return Math.abs(offsetY - stageHeight / 2) <= LOCK_ZONE_HEIGHT / 2
|
||||
}
|
||||
@@ -78,6 +78,23 @@ 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]))
|
||||
|
||||
+13
-5
@@ -93,16 +93,22 @@ export type Vr360Detection = {
|
||||
}
|
||||
|
||||
// 关键词分两类:明确的关键词按子串匹配("vr360" 里也含 "360"),
|
||||
// 容易误伤的两字母缩写按独立词元匹配(避免 "1ou" / "sivr" 之类误判)。
|
||||
const PANORAMA_SUBSTRINGS = ['360', 'equirect', 'insta360', 'pano', 'vuze', 'theta', '全景']
|
||||
// 容易误伤的两字母缩写按作品编号形态匹配。
|
||||
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 VR_TOKENS = ['vr']
|
||||
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))
|
||||
@@ -152,8 +158,10 @@ export function detectVr360Profile(input: Vr360DetectInput): Vr360Detection {
|
||||
|
||||
const fisheye = hasSubstring(text, FISHEYE_SUBSTRINGS)
|
||||
const vr180 = hasSubstring(text, VR180_SUBSTRINGS)
|
||||
const panorama = hasSubstring(text, PANORAMA_SUBSTRINGS)
|
||||
const vrToken = hasToken(tokens, VR_TOKENS) || nameText.includes('vr')
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user