From b7f1cd85f8868ab3b9004aeeed159e5f20797956 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:01:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8C=89=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=92=AD=E6=94=BE=E5=99=A8=E4=B8=8E=E5=BC=B9?= =?UTF-8?q?=E5=B9=95=E5=81=8F=E5=A5=BD=20=E5=9C=A8=20user=20=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E4=B8=8A=E6=96=B0=E5=A2=9E=E6=92=AD=E6=94=BE=E5=99=A8?= =?UTF-8?q?=E9=9F=B3=E9=87=8F=E3=80=81=E5=BC=B9=E5=B9=95=E5=BC=80=E5=85=B3?= =?UTF-8?q?=E3=80=81=E9=80=8F=E6=98=8E=E5=BA=A6=E3=80=81=E5=AD=97=E5=8F=B7?= =?UTF-8?q?=E3=80=81=E6=98=BE=E7=A4=BA=E5=8C=BA=E5=9F=9F=E3=80=81=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E5=81=8F=E5=A5=BD=E3=80=81=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=BC=B9=E5=B9=95=E6=BA=90=E4=BB=A5=E5=8F=8A=20dandanplay=20?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=87=AD=E6=8D=AE=E5=AD=97=E6=AE=B5=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=8F=90=E4=BE=9B=20PUT=20/danmaku/settings=20?= =?UTF-8?q?=E5=B1=80=E9=83=A8=E6=9B=B4=E6=96=B0=E6=8E=A5=E5=8F=A3=EF=BC=8C?= =?UTF-8?q?=E5=B8=A6=E5=8F=96=E5=80=BC=E6=A0=A1=E9=AA=8C=E3=80=82=20?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E8=84=B1=E6=95=8F=E5=90=8E=E7=9A=84=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E6=B8=B2=E6=9F=93=E9=85=8D=E7=BD=AE=EF=BC=8C=E7=BB=9D?= =?UTF-8?q?=E4=B8=8D=E5=9B=9E=E4=BC=A0=E5=BA=94=E7=94=A8=E5=AF=86=E9=92=A5?= =?UTF-8?q?=EF=BC=8C=E4=BB=85=E5=9C=A8=E5=93=8D=E5=BA=94=E4=B8=AD=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=E5=AF=86=E9=92=A5=E6=98=AF=E5=90=A6=E5=B7=B2=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E3=80=82=20=E7=BB=93=E6=9E=9C=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E9=94=AE=E5=8A=A0=E5=85=A5=E7=94=A8=E6=88=B7=20ID=20=E4=B8=8E?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=E6=8C=87=E7=BA=B9=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E4=B8=8D=E5=90=8C=E7=94=A8=E6=88=B7=E6=88=96=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E4=B8=B2=E7=94=A8=E7=BC=93=E5=AD=98=EF=BC=9B=E6=97=A7=E7=89=88?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E7=BA=A7=E8=AE=BE=E7=BD=AE=E4=BB=85=E4=BD=9C?= =?UTF-8?q?=E4=B8=BA=E6=9C=AA=E7=99=BB=E5=BD=95=E4=B8=8E=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E6=9C=9F=E7=9A=84=E5=9B=9E=E9=80=80=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/danmaku.go | 28 +-- internal/handler/danmaku_settings_test.go | 55 +++++ internal/model/user.go | 15 +- internal/service/danmaku_service.go | 287 +++++++++++++++++++--- internal/service/danmaku_service_test.go | 40 +++ web/src/api/danmaku.ts | 52 ++-- web/src/components/AssSubtitleStage.tsx | 28 ++- web/src/components/DanmakuStage.tsx | 2 +- web/src/components/PlayerControls.tsx | 72 ++++-- web/src/components/PlayerDanmakuPanel.tsx | 163 +++++++++++- web/src/pages/PlayerPage.tsx | 189 +++++++++++--- web/src/pages/PlayerVideoStage.tsx | 9 + web/src/pages/settingsGroupDanmaku.ts | 64 ----- web/src/pages/settingsGroups.ts | 2 - web/src/types/auth.ts | 2 + web/src/utils/withoutVideoFrame.test.ts | 30 +++ web/src/utils/withoutVideoFrame.ts | 27 ++ 17 files changed, 856 insertions(+), 209 deletions(-) delete mode 100644 web/src/pages/settingsGroupDanmaku.ts create mode 100644 web/src/utils/withoutVideoFrame.test.ts create mode 100644 web/src/utils/withoutVideoFrame.ts diff --git a/internal/handler/danmaku.go b/internal/handler/danmaku.go index 193c1fe..b85b8ed 100644 --- a/internal/handler/danmaku.go +++ b/internal/handler/danmaku.go @@ -2,6 +2,7 @@ package handler import ( + "errors" "net/http" "strings" @@ -16,10 +17,8 @@ import ( func getDanmakuHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid := currentUserID(c) - // 弹幕合并偏好按用户存储:这里读取后作为本次抓取的选项传入。 - opts := service.DanmakuFetchOptions{ - MergeSources: svc.Danmaku.MergeSourcesEnabled(c.Request.Context(), uid), - } + // 按用户读取弹幕源、凭据、合并偏好和渲染参数。 + opts := service.DanmakuFetchOptions{UserID: uid} res, err := svc.Danmaku.FetchWithOptions( c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"), opts) if err != nil { @@ -30,17 +29,15 @@ func getDanmakuHandler(svc *service.Container) gin.HandlerFunc { } } -// getDanmakuConfigHandler exposes the danmaku renderer knobs (opacity, font -// size, area, enabled) so the player can initialize its control panel without -// admin privileges. +// getDanmakuConfigHandler exposes the current user's player volume and danmaku +// preferences so the player can initialize without admin privileges. func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { c.JSON(http.StatusOK, svc.Danmaku.ConfigForUser(c.Request.Context(), currentUserID(c))) } } -// updateDanmakuSettingsHandler 持久化当前用户的弹幕偏好。目前只有合并开关, -// 落在 user 表上(与字幕简繁偏好同样按用户存储)。 +// updateDanmakuSettingsHandler 持久化当前用户的播放器音量与弹幕偏好。 func updateDanmakuSettingsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid := currentUserID(c) @@ -48,21 +45,20 @@ func updateDanmakuSettingsHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) return } - var req struct { - MergeSources *bool `json:"merge_sources"` - } + var req service.DanmakuSettingsPatch if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) return } - if req.MergeSources == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "merge_sources is required"}) + cfg, err := svc.Danmaku.UpdateUserSettings(c.Request.Context(), uid, req) + if errors.Is(err, service.ErrNoDanmakuSettings) || errors.Is(err, service.ErrInvalidDanmakuSettings) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := svc.Danmaku.SetMergeSources(c.Request.Context(), uid, *req.MergeSources); err != nil { + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"merge_sources": *req.MergeSources}) + c.JSON(http.StatusOK, cfg) } } diff --git a/internal/handler/danmaku_settings_test.go b/internal/handler/danmaku_settings_test.go index 6142537..a1d7a38 100644 --- a/internal/handler/danmaku_settings_test.go +++ b/internal/handler/danmaku_settings_test.go @@ -133,3 +133,58 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) { t.Fatal("config should reflect the persisted merge preference") } } + +func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) { + svc := newDanmakuSettingsService(t) + + body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}" + c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1") + updateDanmakuSettingsHandler(svc)(c) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String()) + } + if bytes.Contains(w.Body.Bytes(), []byte("my-app-secret")) { + t.Fatal("response must never expose the application secret") + } + var cfg service.DanmakuRenderConfig + if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil { + t.Fatalf("decode: %v", err) + } + if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" { + t.Fatalf("unexpected render config: %+v", cfg) + } + if !cfg.MergeSources || cfg.Volume != 0.35 { + t.Fatalf("unexpected user preferences: %+v", cfg) + } + if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured { + t.Fatalf("unexpected service config: %+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.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" { + t.Fatalf("preferences not persisted: %+v", user) + } +} + +func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) { + svc := newDanmakuSettingsService(t) + + c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", + `{"source":"ftp://dm.example.com"}`, "user-1") + updateDanmakuSettingsHandler(svc)(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String()) + } + user, err := svc.Repo.User.FindByID(t.Context(), "user-1") + if err != nil || user == nil { + t.Fatalf("read user: %v", err) + } + if user.DanmakuSource != "" { + t.Fatalf("invalid source was persisted: %q", user.DanmakuSource) + } +} diff --git a/internal/model/user.go b/internal/model/user.go index ca97a58..e0bd65c 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -31,10 +31,17 @@ type User struct { // SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好: // original / simplified / traditional。 SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"` - // DanmakuMergeSources 是网页播放器的弹幕偏好:开启后,同一集的多个弹幕 - // 来源会被合并并按「时间 + 内容」去重后一起展示。按用户存储,避免一个 - // 用户的开关影响其他人。 - DanmakuMergeSources bool `gorm:"default:false" json:"danmaku_merge_sources"` + // 网页播放器偏好按用户存储,切换媒体对象后继续沿用。 + PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"` + 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 // expires. When set and in the past, the account is treated as expired // (login blocked) until an admin or a redemption code renews it. diff --git a/internal/service/danmaku_service.go b/internal/service/danmaku_service.go index 959502c..4dbe2e9 100644 --- a/internal/service/danmaku_service.go +++ b/internal/service/danmaku_service.go @@ -28,9 +28,9 @@ import ( "github.com/truewhile/MeBox/internal/repository" ) -// Danmaku setting keys, managed through the admin settings UI (PUT -// /admin/settings). They are stored in the Setting table so the playback -// page can pull them without admin privileges. +// Legacy instance-level danmaku settings. Playback preferences now live on +// model.User; these keys remain only as a fallback for unauthenticated/legacy +// service paths and tests. const ( DanmakuEnabledKey = "danmaku.enabled" DanmakuSourceKey = "danmaku.source" @@ -56,21 +56,55 @@ var danmakuOfficialBase = DanmakuDefaultSource // DanmakuRenderConfig carries the renderer knobs to the web player. type DanmakuRenderConfig struct { - Enabled bool `json:"enabled"` - Source string `json:"source,omitempty"` - Opacity string `json:"opacity"` - FontSize string `json:"font_size"` - Area string `json:"area"` + Enabled bool `json:"enabled"` + Source string `json:"source,omitempty"` + AppID string `json:"app_id,omitempty"` + Opacity string `json:"opacity"` + FontSize string `json:"font_size"` + Area string `json:"area"` + Volume float64 `json:"volume"` // MergeSources 是当前用户的弹幕合并偏好(按用户存储)。 MergeSources bool `json:"merge_sources"` + // AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。 + AppKeyConfigured bool `json:"app_key_configured"` } // DanmakuFetchOptions 承载单次抓取的调用方偏好。 type DanmakuFetchOptions struct { // MergeSources 为真时,同一集的多个来源会被合并去重后一起返回。 MergeSources bool + // UserID 非空时使用该用户的弹幕源、凭据与渲染偏好。 + UserID string } +// DanmakuSettingsPatch 是播放器弹幕设置的局部更新。nil 字段保持不变。 +type DanmakuSettingsPatch struct { + Enabled *bool `json:"enabled"` + Source *string `json:"source"` + AppID *string `json:"app_id"` + AppKey *string `json:"app_key"` + ClearAppKey bool `json:"clear_app_key"` + Opacity *float64 `json:"opacity"` + FontSize *int `json:"font_size"` + Area *float64 `json:"area"` + MergeSources *bool `json:"merge_sources"` + Volume *float64 `json:"volume"` +} + +type danmakuUserContextKey struct{} + +type danmakuUserContext struct { + source string + appID string + appKey string +} + +// ErrNoDanmakuSettings 表示更新请求没有包含任何可写字段。 +var ErrNoDanmakuSettings = errors.New("no settings to update") + +// ErrInvalidDanmakuSettings 表示更新请求包含非法值。 +var ErrInvalidDanmakuSettings = errors.New("invalid player settings") + // DanmakuFetchResult is what /api/danmaku/:id returns. Raw holds the upstream // comment payload; parsing happens client-side. The dandanplay protocol has // two payload shapes in the wild — the classic Bilibili-style XML and the @@ -197,9 +231,11 @@ type danmakuResultCacheEntry struct { storedAt time.Time } -func danmakuResultCacheKey(source, mediaID, keyword, episodeID string, merge bool) string { +func danmakuResultCacheKey(userID, source, credentialFingerprint, mediaID, keyword, episodeID string, merge bool) string { return strings.Join([]string{ + strings.TrimSpace(userID), strings.TrimSpace(source), + strings.TrimSpace(credentialFingerprint), mediaID, strings.TrimSpace(keyword), strings.TrimSpace(episodeID), @@ -207,6 +243,18 @@ func danmakuResultCacheKey(source, mediaID, keyword, episodeID string, merge boo }, "\x00") } +// danmakuCredentialFingerprint keeps credential changes in the result cache +// key without persisting the AppSecret in plain text. +func danmakuCredentialFingerprint(appID, appKey string) string { + appID = strings.TrimSpace(appID) + appKey = strings.TrimSpace(appKey) + if appID == "" && appKey == "" { + return "" + } + sum := md5.Sum([]byte(appID + "\x00" + appKey)) + return hex.EncodeToString(sum[:]) +} + func (s *DanmakuService) resultCacheGet(key string) (*DanmakuFetchResult, bool) { if s == nil || key == "" { return nil, false @@ -300,6 +348,7 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig { Opacity: "1", FontSize: "24", Area: "1", + Volume: 1, } if s == nil || s.repo == nil || s.repo.Setting == nil { return cfg @@ -319,39 +368,164 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig { return cfg } -// ConfigForUser 在全局渲染设置之外附加当前用户的个性化偏好。 +// ConfigForUser 返回当前用户的播放器与弹幕偏好。用户不存在时回退到旧版 +// 全局配置,便于旧客户端和迁移期平滑工作。 func (s *DanmakuService) ConfigForUser(ctx context.Context, userID string) DanmakuRenderConfig { - cfg := s.Config(ctx) - cfg.MergeSources = s.MergeSourcesEnabled(ctx, userID) - return cfg + if user, ok := s.findUser(ctx, userID); ok { + return danmakuConfigFromUser(user) + } + return s.Config(ctx) } // MergeSourcesEnabled 返回该用户的弹幕合并偏好,读取失败时回退为关闭。 func (s *DanmakuService) MergeSourcesEnabled(ctx context.Context, userID string) bool { - if s == nil || s.repo == nil || s.repo.User == nil { - return false + if user, ok := s.findUser(ctx, userID); ok { + return user.DanmakuMergeSources } - userID = strings.TrimSpace(userID) - if userID == "" { - return false - } - user, err := s.repo.User.FindByID(ctx, userID) - if err != nil || user == nil { - return false - } - return user.DanmakuMergeSources + return false } -// SetMergeSources 持久化该用户的弹幕合并偏好。 -func (s *DanmakuService) SetMergeSources(ctx context.Context, userID string, enabled bool) error { +func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.User, bool) { if s == nil || s.repo == nil || s.repo.User == nil { - return errors.New("danmaku settings unavailable") + return nil, false } userID = strings.TrimSpace(userID) if userID == "" { - return errors.New("missing user") + return nil, false } - return s.repo.User.UpdateFields(ctx, userID, map[string]any{"danmaku_merge_sources": enabled}) + user, err := s.repo.User.FindByID(ctx, userID) + return user, err == nil && user != nil +} + +func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig { + if user == nil { + return DanmakuRenderConfig{Enabled: true, Opacity: "1", FontSize: "24", Area: "1", Volume: 1} + } + opacity := user.DanmakuOpacity + if opacity < 0.1 || opacity > 1 { + opacity = 1 + } + fontSize := user.DanmakuFontSize + if fontSize < 10 || fontSize > 96 { + fontSize = 24 + } + area := user.DanmakuArea + if area < 0.1 || area > 1 { + area = 1 + } + volume := user.PlayerVolume + if volume < 0 || volume > 1 { + volume = 1 + } + return DanmakuRenderConfig{ + Enabled: user.DanmakuEnabled, + Source: strings.TrimSpace(user.DanmakuSource), + AppID: strings.TrimSpace(user.DanmakuAppID), + Opacity: strconv.FormatFloat(opacity, 'f', -1, 64), + FontSize: strconv.Itoa(fontSize), + Area: strconv.FormatFloat(area, 'f', -1, 64), + Volume: volume, + MergeSources: user.DanmakuMergeSources, + AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "", + } +} + +// UpdateUserSettings 持久化当前用户的播放器与弹幕设置,并返回脱敏后的完整配置。 +func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string, patch DanmakuSettingsPatch) (DanmakuRenderConfig, error) { + if s == nil || s.repo == nil || s.repo.User == nil { + return DanmakuRenderConfig{}, errors.New("player settings unavailable") + } + userID = strings.TrimSpace(userID) + if userID == "" { + return DanmakuRenderConfig{}, errors.New("missing user") + } + + updates := map[string]any{} + if patch.Enabled != nil { + updates["danmaku_enabled"] = *patch.Enabled + } + if patch.Source != nil { + source, err := normalizeDanmakuSource(*patch.Source) + if err != nil { + return DanmakuRenderConfig{}, err + } + updates["danmaku_source"] = source + } + if patch.AppID != nil { + appID := strings.TrimSpace(*patch.AppID) + if len(appID) > 128 { + return DanmakuRenderConfig{}, fmt.Errorf("%w: app_id is too long", ErrInvalidDanmakuSettings) + } + updates["danmaku_app_id"] = appID + } + if patch.ClearAppKey { + updates["danmaku_app_key"] = "" + } else if patch.AppKey != nil { + if key := strings.TrimSpace(*patch.AppKey); key != "" { + if len(key) > 256 { + return DanmakuRenderConfig{}, fmt.Errorf("%w: app_key is too long", ErrInvalidDanmakuSettings) + } + updates["danmaku_app_key"] = key + } + } + if patch.Opacity != nil { + if *patch.Opacity < 0.1 || *patch.Opacity > 1 { + return DanmakuRenderConfig{}, fmt.Errorf("%w: opacity must be between 0.1 and 1", ErrInvalidDanmakuSettings) + } + updates["danmaku_opacity"] = *patch.Opacity + } + if patch.FontSize != nil { + if *patch.FontSize < 10 || *patch.FontSize > 96 { + return DanmakuRenderConfig{}, fmt.Errorf("%w: font_size must be between 10 and 96", ErrInvalidDanmakuSettings) + } + updates["danmaku_font_size"] = *patch.FontSize + } + if patch.Area != nil { + if *patch.Area < 0.1 || *patch.Area > 1 { + return DanmakuRenderConfig{}, fmt.Errorf("%w: area must be between 0.1 and 1", ErrInvalidDanmakuSettings) + } + updates["danmaku_area"] = *patch.Area + } + if patch.MergeSources != nil { + updates["danmaku_merge_sources"] = *patch.MergeSources + } + if patch.Volume != nil { + if *patch.Volume < 0 || *patch.Volume > 1 { + return DanmakuRenderConfig{}, fmt.Errorf("%w: volume must be between 0 and 1", ErrInvalidDanmakuSettings) + } + updates["player_volume"] = *patch.Volume + } + if len(updates) == 0 { + return DanmakuRenderConfig{}, ErrNoDanmakuSettings + } + if err := s.repo.User.UpdateFields(ctx, userID, updates); err != nil { + return DanmakuRenderConfig{}, err + } + return s.ConfigForUser(ctx, userID), nil +} + +// normalizeDanmakuSource validates a user-configured dandanplay endpoint. An +// empty value means the official endpoint; only explicit HTTP(S) origins are +// accepted so malformed values never reach the outbound request layer. +func normalizeDanmakuSource(raw string) (string, error) { + source := strings.TrimRight(strings.TrimSpace(raw), "/") + if source == "" { + return "", nil + } + if len(source) > 512 { + return "", fmt.Errorf("%w: source is too long", ErrInvalidDanmakuSettings) + } + u, err := url.Parse(source) + if err != nil || u.Host == "" || (!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https")) || u.User != nil { + return "", fmt.Errorf("%w: source must be an http(s) URL", ErrInvalidDanmakuSettings) + } + return source, nil +} + +// SetMergeSources 持久化该用户的弹幕合并偏好,保留旧调用点兼容性。 +func (s *DanmakuService) SetMergeSources(ctx context.Context, userID string, enabled bool) error { + _, err := s.UpdateUserSettings(ctx, userID, DanmakuSettingsPatch{MergeSources: &enabled}) + return err } // Fetch retrieves danmaku for the given media. keyword overrides the @@ -388,11 +562,23 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword, return nil, errors.New("danmaku service unavailable") } cfg := s.Config(ctx) + credentialFingerprint := "" + if user, ok := s.findUser(ctx, opts.UserID); ok { + cfg = danmakuConfigFromUser(user) + opts.MergeSources = cfg.MergeSources + credentialFingerprint = danmakuCredentialFingerprint(user.DanmakuAppID, user.DanmakuAppKey) + ctx = context.WithValue(ctx, danmakuUserContextKey{}, danmakuUserContext{ + source: cfg.Source, + appID: strings.TrimSpace(user.DanmakuAppID), + appKey: strings.TrimSpace(user.DanmakuAppKey), + }) + } if !cfg.Enabled { return &DanmakuFetchResult{DanmakuRenderConfig: cfg, SourceType: "auto"}, nil } - key := danmakuResultCacheKey(cfg.Source, mediaID, keyword, episodeID, opts.MergeSources) + key := danmakuResultCacheKey(opts.UserID, cfg.Source, credentialFingerprint, mediaID, keyword, episodeID, opts.MergeSources) if cached, ok := s.resultCacheGet(key); ok { + cached.DanmakuRenderConfig = cfg return cached, nil } value, err, _ := s.fetchGroup.Do(key, func() (any, error) { @@ -400,7 +586,7 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword, if cached, ok := s.resultCacheGet(key); ok { return cached, nil } - res, err := s.fetchWithOptionsUncached(ctx, mediaID, keyword, episodeID, opts) + res, err := s.fetchWithOptionsUncached(ctx, cfg, mediaID, keyword, episodeID, opts) if err != nil { // 与原实现一致:失败时仍把已填充的渲染配置/匹配信息交给调用方。 return res, err @@ -409,6 +595,9 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword, return res, nil }) res, _ := value.(*DanmakuFetchResult) + if res != nil { + res.DanmakuRenderConfig = cfg + } if err != nil { return cloneDanmakuFetchResult(res), err } @@ -416,8 +605,8 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword, } // fetchWithOptionsUncached 是未命中缓存时执行的原始抓取流程。 -func (s *DanmakuService) fetchWithOptionsUncached(ctx context.Context, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) { - res := &DanmakuFetchResult{DanmakuRenderConfig: s.Config(ctx), SourceType: "auto"} +func (s *DanmakuService) fetchWithOptionsUncached(ctx context.Context, cfg DanmakuRenderConfig, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) { + res := &DanmakuFetchResult{DanmakuRenderConfig: cfg, SourceType: "auto"} if !res.Enabled { return res, nil } @@ -870,19 +1059,37 @@ func (s *DanmakuService) fetchBody(ctx context.Context, sourceURL string, follow return string(body), nil } -// danmakuCredentials resolves the application credentials for the official -// dandanplay API. Admin-configured values (danmaku.app_id / danmaku.app_key) -// win; otherwise the built-in obfuscated fallback pair is used. Returns -// ok=false for any other host so credentials — including the built-in pair — -// are never sent to third-party dandanplay protocol mirrors. The "official" -// host follows danmakuOfficialBase (overridable in tests). +// danmakuCredentials resolves application credentials. Per-user explicit +// credentials are used for any configured source. Legacy global credentials +// and the built-in fallback pair are only sent to the official host, never to +// third-party dandanplay protocol mirrors. func (s *DanmakuService) danmakuCredentials(ctx context.Context, sourceURL string) (appID, appKey string, ok bool) { u, err := url.Parse(sourceURL) if err != nil { return "", "", false } official, err := url.Parse(danmakuOfficialBase) - if err != nil || !strings.EqualFold(u.Hostname(), official.Hostname()) { + if err != nil { + return "", "", false + } + isOfficial := strings.EqualFold(u.Hostname(), official.Hostname()) + if userCtx, ok := ctx.Value(danmakuUserContextKey{}).(danmakuUserContext); ok { + userSource := strings.TrimSpace(userCtx.source) + if userSource == "" { + userSource = danmakuOfficialBase + } + if sameDanmakuBase(sourceURL, userSource) { + id, key := strings.TrimSpace(userCtx.appID), strings.TrimSpace(userCtx.appKey) + if id != "" && key != "" { + return id, key, true + } + if id != "" || key != "" { + s.log.Warn("danmaku user credentials incomplete, ignoring them", + zap.Bool("has_app_id", id != ""), zap.Bool("has_app_key", key != "")) + } + } + } + if !isOfficial { return "", "", false } var id, key string diff --git a/internal/service/danmaku_service_test.go b/internal/service/danmaku_service_test.go index 48f48a8..c863251 100644 --- a/internal/service/danmaku_service_test.go +++ b/internal/service/danmaku_service_test.go @@ -60,6 +60,7 @@ func seedDanmakuMedia(t *testing.T, svc *DanmakuService, id, title, originalName type danmakuSourceServer struct { server *httptest.Server lastSearch string // full query (anime=...&episode=...) + lastHeaders http.Header searchResponse string // JSON body served for /api/v2/search/episodes } @@ -76,6 +77,7 @@ func newDanmakuSourceServerWithSearch(t *testing.T, searchResponse string) *danm mux := http.NewServeMux() mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) { ds.lastSearch = r.URL.RawQuery + ds.lastHeaders = r.Header.Clone() w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, ds.searchResponse) }) @@ -404,3 +406,41 @@ func TestDanmakuFetchCoalescesConcurrentRequests(t *testing.T) { } require.EqualValues(t, 1, atomic.LoadInt32(&searchCalls)) } + +func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) { + srv := newDanmakuSourceServer(t) + svc := newDanmakuTestService(t) + ctx := context.Background() + + user := model.User{Username: "danmaku-user", PasswordHash: "x", Role: "user", IsActive: true} + user.ID = "danmaku-user-1" + user.DanmakuEnabled = true + user.DanmakuSource = srv.URL() + user.DanmakuAppID = "user-app-id" + user.DanmakuAppKey = "user-app-secret" + user.DanmakuOpacity = 0.65 + user.DanmakuFontSize = 30 + user.DanmakuArea = 0.7 + user.PlayerVolume = 0.42 + require.NoError(t, svc.repo.User.Create(ctx, &user)) + seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0) + + res, err := svc.FetchWithOptions(ctx, "per-user-media", "", "", DanmakuFetchOptions{UserID: user.ID}) + require.NoError(t, err) + require.Contains(t, res.Raw, "弹幕A") + require.Equal(t, srv.URL(), res.Source) + require.Equal(t, 0.42, res.Volume) + require.Equal(t, "0.65", res.Opacity) + require.Equal(t, "30", res.FontSize) + require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id") + require.NotEmpty(t, srv.lastHeaders.Get("X-Signature")) + + // Credential rotation must invalidate the cached fetch for the same user. + require.NoError(t, svc.repo.User.UpdateFields(ctx, user.ID, map[string]any{ + "danmaku_app_id": "user-app-id-2", + "danmaku_app_key": "user-app-secret-2", + })) + _, err = svc.FetchWithOptions(ctx, "per-user-media", "", "", DanmakuFetchOptions{UserID: user.ID}) + require.NoError(t, err) + require.Equal(t, "user-app-id-2", srv.lastHeaders.Get("X-AppId")) +} diff --git a/web/src/api/danmaku.ts b/web/src/api/danmaku.ts index a8f941d..630c69d 100644 --- a/web/src/api/danmaku.ts +++ b/web/src/api/danmaku.ts @@ -62,6 +62,33 @@ export type DanmakuFetchOptions = { episodeId?: number | string } +export interface DanmakuConfig { + enabled: boolean + source?: string + app_id?: string + app_key_configured: boolean + opacity: string + font_size: string + area: string + volume: number + /** Per-user preference: merge the same episode's multiple sources. */ + merge_sources: boolean +} + +export interface DanmakuSettingsPatch { + enabled?: boolean + source?: string + app_id?: string + /** 留空不会修改密钥;清空请使用 clear_app_key。 */ + app_key?: string + clear_app_key?: boolean + opacity?: number + font_size?: number + area?: number + merge_sources?: boolean + volume?: number +} + // danmakuAPI fetches danmaku comments for a media item. The backend resolves // the configured dandanplay source by the video's name (search for episode, // then fetch its comment library) and returns the raw Bilibili-format XML; @@ -75,26 +102,11 @@ export const danmakuAPI = { }) .then((r) => r.data), - // config returns the renderer knobs so the player can initialize its - // danmaku control panel without admin privileges. + // config returns the current user's player volume and danmaku preferences. config: () => - api - .get<{ - enabled: boolean - source?: string - opacity: string - font_size: string - area: string - /** Per-user preference: merge the same episode's multiple sources. */ - merge_sources: boolean - }>('/danmaku/config') - .then((r) => r.data), + api.get('/danmaku/config').then((r) => r.data), - // updateSettings persists per-user danmaku preferences (survives reload). - updateSettings: (settings: { mergeSources: boolean }) => - api - .put<{ merge_sources: boolean }>('/danmaku/settings', { - merge_sources: settings.mergeSources, - }) - .then((r) => r.data), + // updateSettings persists a partial per-user player/danmaku settings patch. + updateSettings: (settings: DanmakuSettingsPatch) => + api.put('/danmaku/settings', settings).then((r) => r.data), } diff --git a/web/src/components/AssSubtitleStage.tsx b/web/src/components/AssSubtitleStage.tsx index 5e03b62..7bb4110 100644 --- a/web/src/components/AssSubtitleStage.tsx +++ b/web/src/components/AssSubtitleStage.tsx @@ -10,6 +10,7 @@ import { type SubtitleChineseMode, } from '../utils/subtitleChinese' import { convertASSContent } from '../utils/subtitleASS' +import { runWithoutGlobalVideoFrame } from '../utils/withoutVideoFrame' type AssSubtitleStageProps = { videoRef: React.RefObject @@ -60,18 +61,21 @@ export function AssSubtitleStage({ if (!body.trim()) throw new Error('empty subtitle payload') if (cancelled) return - instance = new JASSUB({ - video, - subContent: convertASSContent(body, converter), - timeOffset: timeOffsetRef.current, - workerUrl: jassubWorkerUrl, - wasmUrl: jassubWasmUrl, - modernWasmUrl: jassubModernWasmUrl, - fonts: [notoSansSCUrl], - defaultFont: 'Noto Sans SC', - queryFonts: 'local', - prescaleFactor: 1, - }) + instance = runWithoutGlobalVideoFrame( + () => + new JASSUB({ + video, + subContent: convertASSContent(body, converter), + timeOffset: timeOffsetRef.current, + workerUrl: jassubWorkerUrl, + wasmUrl: jassubWasmUrl, + modernWasmUrl: jassubModernWasmUrl, + fonts: [notoSansSCUrl], + defaultFont: 'Noto Sans SC', + queryFonts: 'local', + prescaleFactor: 1, + }), + ) instanceRef.current = instance await instance.ready if (cancelled) { diff --git a/web/src/components/DanmakuStage.tsx b/web/src/components/DanmakuStage.tsx index 4924141..a1a5498 100644 --- a/web/src/components/DanmakuStage.tsx +++ b/web/src/components/DanmakuStage.tsx @@ -264,7 +264,7 @@ export function DanmakuStage({ managerRef.current = null } // search / episodeId / searchTrigger 变化时重新拉取弹幕(含媒体/开关切换)。 - }, [media, videoRef, enabled, search, episodeId, searchTrigger, onLoaded, onCandidates]) + }, [media, videoRef, enabled, search, episodeId, searchTrigger, onLoaded, onCandidates, onAlternatives]) // Live renderer knobs: opacity / area / font size without recreating the // engine. font size additionally rescales currently visible comments. diff --git a/web/src/components/PlayerControls.tsx b/web/src/components/PlayerControls.tsx index 655f28f..b71d3da 100644 --- a/web/src/components/PlayerControls.tsx +++ b/web/src/components/PlayerControls.tsx @@ -58,6 +58,10 @@ function isEditableTarget(target: EventTarget | null): boolean { type PlayerControlsProps = { videoRef: React.RefObject + /** 用户级播放器音量(0 ~ 1),由播放页从数据库读取。 */ + volume?: number + onVolumeChange?: (volume: number) => void + onVolumeCommit?: (volume: number) => void uiVisible: boolean onUiVisibleChange: (visible: boolean) => void subs: SubtitleTrack[] @@ -92,6 +96,9 @@ type PlayerControlsProps = { export function PlayerControls({ videoRef, + volume: volumeProp = 1, + onVolumeChange, + onVolumeCommit, uiVisible, onUiVisibleChange, subs, @@ -128,8 +135,8 @@ export function PlayerControls({ const [playing, setPlaying] = useState(false) const [currentTime, setCurrentTime] = useState(0) const [duration, setDuration] = useState(0) - const [volume, setVolume] = useState(1) - const [muted, setMuted] = useState(false) + const [volume, setVolume] = useState(volumeProp) + const [muted, setMuted] = useState(volumeProp === 0) const [fullscreen, setFullscreen] = useState(false) const [pip, setPip] = useState(false) const [controlsHovered, setControlsHovered] = useState(false) @@ -181,6 +188,17 @@ export function PlayerControls({ setStageEl(container()) }, [container]) + // 音量由播放页按用户持久化;配置加载或切换对象后同步到当前 video。 + useEffect(() => { + const el = video() + if (!el) return + const next = Math.min(1, Math.max(0, volumeProp)) + el.volume = next + el.muted = next === 0 + setVolume(next) + setMuted(next === 0) + }, [video, volumeProp]) + // 点击控制栏外部时关闭字幕菜单 useEffect(() => { if (!subtitleMenuOpen) return @@ -336,19 +354,25 @@ export function PlayerControls({ else el.pause() } - const applyAbsoluteSeek = (absolute: number) => { - const el = video() - if (!el) return - if (onSeekAbsolute?.(absolute)) { - pendingSeekRef.current = absolute - setCurrentTime(absolute) - return - } - const local = Math.max(0, absolute - streamOffset) - el.currentTime = local - setCurrentTime(streamOffset + local) - } - applySeekRef.current = applyAbsoluteSeek + const applyAbsoluteSeek = useCallback( + (absolute: number) => { + const el = video() + if (!el) return + if (onSeekAbsolute?.(absolute)) { + pendingSeekRef.current = absolute + setCurrentTime(absolute) + return + } + const local = Math.max(0, absolute - streamOffset) + el.currentTime = local + setCurrentTime(streamOffset + local) + }, + [onSeekAbsolute, streamOffset, video], + ) + + useEffect(() => { + applySeekRef.current = applyAbsoluteSeek + }, [applyAbsoluteSeek]) const handleSeekChange = (v: number) => { setScrubValue(v) @@ -474,10 +498,17 @@ export function PlayerControls({ const changeVolume = (v: number) => { const el = video() if (!el) return - el.volume = v - el.muted = v === 0 - setVolume(v) - setMuted(v === 0) + const next = Math.min(1, Math.max(0, v)) + el.volume = next + el.muted = next === 0 + setVolume(next) + setMuted(next === 0) + onVolumeChange?.(next) + } + + const commitVolume = () => { + const el = video() + onVolumeCommit?.(el?.volume ?? volume) } const toggleMute = () => { @@ -803,6 +834,9 @@ export function PlayerControls({ step={0.05} value={muted ? 0 : volume} onChange={(e) => changeVolume(Number(e.target.value))} + onPointerUp={commitVolume} + onKeyUp={commitVolume} + onTouchEnd={commitVolume} className="hidden w-16 accent-rose-500 sm:block" aria-label="音量" /> diff --git a/web/src/components/PlayerDanmakuPanel.tsx b/web/src/components/PlayerDanmakuPanel.tsx index 37c729d..bb47390 100644 --- a/web/src/components/PlayerDanmakuPanel.tsx +++ b/web/src/components/PlayerDanmakuPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Check, ChevronRight, Film, Hash, Loader2, MessageSquareText, RefreshCw, Search, Sparkles, Tag, X } from 'lucide-react' +import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Film, Hash, KeyRound, Loader2, MessageSquareText, RefreshCw, Search, Server, Settings2, Sparkles, Tag, X } from 'lucide-react' import type { DanmakuAnime, DanmakuEpisode, DanmakuLoadedInfo } from '../api/danmaku' @@ -17,10 +17,24 @@ type PlayerDanmakuPanelProps = { searching: boolean area: number onAreaChange: (v: number) => void + onAreaCommit: (v: number) => void opacity: number onOpacityChange: (v: number) => void + onOpacityCommit: (v: number) => void fontSize: number onFontSizeChange: (v: number) => void + onFontSizeCommit: (v: number) => void + /** Per-user danmaku service endpoint and optional application credentials. */ + source: string + appId: string + appKeyConfigured: boolean + settingsSaving?: boolean + onSaveAdvanced: (values: { + source: string + appId: string + appKey: string + clearAppKey: boolean + }) => Promise /** Multiple anime matched — user must pick one. */ candidates: DanmakuAnime[] /** @@ -53,10 +67,18 @@ export function PlayerDanmakuPanel({ searching, area, onAreaChange, + onAreaCommit, opacity, onOpacityChange, + onOpacityCommit, fontSize, onFontSizeChange, + onFontSizeCommit, + source, + appId, + appKeyConfigured, + settingsSaving = false, + onSaveAdvanced, candidates, alternatives, mergeSources, @@ -69,6 +91,13 @@ export function PlayerDanmakuPanel({ onResetAuto, }: PlayerDanmakuPanelProps) { const [draft, setDraft] = useState(search) + const [advancedOpen, setAdvancedOpen] = useState(false) + const [sourceDraft, setSourceDraft] = useState(source) + const [appIdDraft, setAppIdDraft] = useState(appId) + const [appKeyDraft, setAppKeyDraft] = useState('') + const [showAppKey, setShowAppKey] = useState(false) + const [clearAppKey, setClearAppKey] = useState(false) + const [advancedSaving, setAdvancedSaving] = useState(false) // 展开的番剧(动画 → 集数两级树),默认全展开便于选择。 const [openAnime, setOpenAnime] = useState>(new Set()) @@ -77,8 +106,32 @@ export function PlayerDanmakuPanel({ if (open) { setDraft(search) setOpenAnime(new Set(candidates.map((c) => c.animeId))) + setSourceDraft(source) + setAppIdDraft(appId) + setAppKeyDraft('') + setShowAppKey(false) + setClearAppKey(false) } - }, [open, search, candidates]) + }, [open, search, candidates, source, appId]) + + const saveAdvanced = async () => { + setAdvancedSaving(true) + try { + await onSaveAdvanced({ + source: sourceDraft, + appId: appIdDraft, + appKey: appKeyDraft, + clearAppKey, + }) + setAppKeyDraft('') + setShowAppKey(false) + setClearAppKey(false) + } catch { + // 父组件已展示错误提示,这里保持面板打开以便修正后重试。 + } finally { + setAdvancedSaving(false) + } + } if (!open) return null @@ -148,6 +201,7 @@ export function PlayerDanmakuPanel({
弹幕设置 + {settingsSaving && }
+ + {advancedOpen && ( +
+ + + + + + + {appKeyConfigured && ( + + )} + + +

+ 密钥不会回传到浏览器,留空时保持数据库中的原值;官方源留空凭据会使用内置回退,自定义源需自行提供。修改后仅对当前账号生效。 +

+
+ )} +
) } @@ -430,6 +585,7 @@ function SliderRow({ step, format, onChange, + onCommit, }: { label: string value: number @@ -438,6 +594,7 @@ function SliderRow({ step: number format: (v: number) => string onChange: (v: number) => void + onCommit: (v: number) => void }) { return (
@@ -452,6 +609,8 @@ function SliderRow({ step={step} value={value} onChange={(e) => onChange(Number(e.target.value))} + onPointerUp={(e) => onCommit(Number(e.currentTarget.value))} + onKeyUp={(e) => onCommit(Number(e.currentTarget.value))} className="w-full accent-rose-500" />
diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index f6f63ca..e64d68e 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -5,7 +5,13 @@ import toast from 'react-hot-toast' import { mediaAPI, libraryAPI } from '../api/library' import { hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client' -import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku' +import { + danmakuAPI, + type DanmakuAnime, + type DanmakuConfig, + type DanmakuLoadedInfo, + type DanmakuSettingsPatch, +} from '../api/danmaku' import { playbackAPI } from '../api/playback' import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles' import { systemAPI } from '../api/system' @@ -55,6 +61,12 @@ type PlaybackProgressSession = { sequence: number } +function normalizePlayerVolume(value: unknown): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return 1 + return Math.min(1, Math.max(0, parsed)) +} + function newPlaybackProgressSession(mediaId: string): PlaybackProgressSession { const randomID = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' @@ -94,7 +106,11 @@ export function PlayerPage() { ) const [subtitlePosition, setSubtitlePosition] = useState(loadSubtitlePosition) const [subtitleStyle, setSubtitleStyle] = useState(loadSubtitleStyle) + const [playerVolume, setPlayerVolume] = useState(() => + normalizePlayerVolume(authUser?.player_volume), + ) const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode) + const playerVolumeTouchedRef = useRef(false) const subtitlePreferenceTouchedRef = useRef(false) const subtitlePreferenceSaveQueueRef = useRef>(Promise.resolve()) const [hlsUnavailable, setHlsUnavailable] = useState(false) @@ -131,6 +147,10 @@ export function PlayerPage() { const [danmakuOpacity, setDanmakuOpacity] = useState(1) const [danmakuFontSize, setDanmakuFontSize] = useState(24) const [danmakuArea, setDanmakuArea] = useState(1) + const [danmakuSource, setDanmakuSource] = useState('') + const [danmakuAppID, setDanmakuAppID] = useState('') + const [danmakuAppKeyConfigured, setDanmakuAppKeyConfigured] = useState(false) + const [playerSettingsSaving, setPlayerSettingsSaving] = useState(false) // 选集 / 播放列表状态 const [playlistEpisodes, setPlaylistEpisodes] = useState([]) @@ -181,51 +201,151 @@ export function PlayerPage() { } }, [setAuthUser]) - // 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。 - useEffect(() => { - danmakuAPI - .config() - .then((cfg) => { - setDanmakuEnabled(cfg.enabled) - setDanmakuOpacity(Number(cfg.opacity) || 1) - setDanmakuFontSize(Number(cfg.font_size) || 24) - setDanmakuArea(Number(cfg.area) || 1) - }) - .catch(() => undefined) + const applyDanmakuConnectionConfig = useCallback((cfg: DanmakuConfig) => { + setDanmakuSource(cfg.source ?? '') + setDanmakuAppID(cfg.app_id ?? '') + setDanmakuAppKeyConfigured(Boolean(cfg.app_key_configured)) }, []) - // 读取弹幕配置(含按用户存储的合并偏好),初始化面板。 + const saveDanmakuSettings = useCallback( + async (patch: DanmakuSettingsPatch, failureMessage = '播放器设置保存失败,请重试') => { + setPlayerSettingsSaving(true) + try { + const cfg = await danmakuAPI.updateSettings(patch) + applyDanmakuConnectionConfig(cfg) + return cfg + } catch (error) { + toast.error(failureMessage) + throw error + } finally { + setPlayerSettingsSaving(false) + } + }, + [applyDanmakuConnectionConfig], + ) + + // 一次读取当前用户的音量、弹幕渲染参数、服务地址和凭据配置状态。 useEffect(() => { let cancelled = false danmakuAPI .config() .then((cfg) => { if (cancelled) return + const volume = normalizePlayerVolume(cfg.volume) + if (!playerVolumeTouchedRef.current) { + setPlayerVolume(volume) + } + setDanmakuEnabled(cfg.enabled) + setDanmakuOpacity(Number(cfg.opacity) || 1) + setDanmakuFontSize(Number(cfg.font_size) || 24) + setDanmakuArea(Number(cfg.area) || 1) setDanmakuMergeSources(Boolean(cfg.merge_sources)) + applyDanmakuConnectionConfig(cfg) + if (!playerVolumeTouchedRef.current) { + const current = useAuthStore.getState().user + if (current) setAuthUser({ ...current, player_volume: volume }) + } }) .catch(() => { - // 配置读取失败时保持默认(不合并),不影响播放。 + // 配置读取失败时保持本地默认值,不影响播放。 }) return () => { cancelled = true } + }, [applyDanmakuConnectionConfig, setAuthUser, authUser?.id]) + + const changePlayerVolume = useCallback((next: number) => { + playerVolumeTouchedRef.current = true + setPlayerVolume(normalizePlayerVolume(next)) }, []) + const commitPlayerVolume = useCallback( + (next: number) => { + playerVolumeTouchedRef.current = true + const volume = normalizePlayerVolume(next) + setPlayerVolume(volume) + void danmakuAPI + .updateSettings({ volume }) + .then((cfg) => { + const saved = normalizePlayerVolume(cfg.volume) + setPlayerVolume(saved) + const current = useAuthStore.getState().user + if (current) setAuthUser({ ...current, player_volume: saved }) + }) + .catch(() => { + toast.error('音量保存失败,请重试') + }) + }, + [setAuthUser], + ) + + const saveDanmakuAdvanced = useCallback( + async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => { + const patch: DanmakuSettingsPatch = { + source: values.source.trim(), + app_id: values.appId.trim(), + } + if (values.clearAppKey) { + patch.clear_app_key = true + } else if (values.appKey.trim()) { + patch.app_key = values.appKey.trim() + } + const cfg = await saveDanmakuSettings(patch, '弹幕服务设置保存失败,请重试') + setDanmakuSource(cfg.source ?? '') + setDanmakuAppID(cfg.app_id ?? '') + setDanmakuAppKeyConfigured(Boolean(cfg.app_key_configured)) + // 地址或凭据变化后立即按新配置重新抓取当前对象的弹幕。 + setDanmakuSearching(true) + setDanmakuSearchTrigger((prev) => prev + 1) + toast.success('弹幕服务设置已保存') + }, + [saveDanmakuSettings], + ) + // 切换合并开关:先落库,成功后再重新抓取,避免与后端读到的偏好不一致。 - const danmakuChangeMergeSources = useCallback((next: boolean) => { - setDanmakuMergeSaving(true) - danmakuAPI - .updateSettings({ mergeSources: next }) - .then(() => { - setDanmakuMergeSources(next) - setDanmakuSearching(true) - setDanmakuSearchTrigger((prev) => prev + 1) - }) - .catch(() => { - // 保存失败时保持原值,用户可重试。 - }) - .finally(() => setDanmakuMergeSaving(false)) - }, []) + const danmakuChangeMergeSources = useCallback( + (next: boolean) => { + setDanmakuMergeSaving(true) + saveDanmakuSettings({ merge_sources: next }) + .then(() => { + setDanmakuMergeSources(next) + setDanmakuSearching(true) + setDanmakuSearchTrigger((prev) => prev + 1) + }) + .catch(() => undefined) + .finally(() => setDanmakuMergeSaving(false)) + }, + [saveDanmakuSettings], + ) + + const danmakuChangeEnabled = useCallback( + (next: boolean) => { + setDanmakuEnabled(next) + void saveDanmakuSettings({ enabled: next }).catch(() => undefined) + }, + [saveDanmakuSettings], + ) + + const commitDanmakuArea = useCallback( + (next: number) => { + void saveDanmakuSettings({ area: next }).catch(() => undefined) + }, + [saveDanmakuSettings], + ) + + const commitDanmakuOpacity = useCallback( + (next: number) => { + void saveDanmakuSettings({ opacity: next }).catch(() => undefined) + }, + [saveDanmakuSettings], + ) + + const commitDanmakuFontSize = useCallback( + (next: number) => { + void saveDanmakuSettings({ font_size: next }).catch(() => undefined) + }, + [saveDanmakuSettings], + ) // 用户手动搜索:带关键词重新拉取(search=null 时按视频名)。 // loading 状态由 DanmakuStage 拉取完成回调(onLoaded)驱动。 @@ -1038,6 +1158,9 @@ export function PlayerPage() { onSubtitleStyleChange={changeSubtitleStyle} videoRef={ref} onVideoError={handleVideoError} + playerVolume={playerVolume} + onPlayerVolumeChange={changePlayerVolume} + onPlayerVolumeCommit={commitPlayerVolume} danmakuEnabled={danmakuEnabled} danmakuOpacity={danmakuOpacity} danmakuFontSize={danmakuFontSize} @@ -1076,16 +1199,24 @@ export function PlayerPage() { open={danmakuOpen} onClose={() => setDanmakuOpen(false)} enabled={danmakuEnabled} - onToggleEnabled={setDanmakuEnabled} + onToggleEnabled={danmakuChangeEnabled} search={danmakuSearch ?? ''} onSearch={searchDanmaku} searching={danmakuSearching} area={danmakuArea} onAreaChange={setDanmakuArea} + onAreaCommit={commitDanmakuArea} opacity={danmakuOpacity} onOpacityChange={setDanmakuOpacity} + onOpacityCommit={commitDanmakuOpacity} fontSize={danmakuFontSize} onFontSizeChange={setDanmakuFontSize} + onFontSizeCommit={commitDanmakuFontSize} + source={danmakuSource} + appId={danmakuAppID} + appKeyConfigured={danmakuAppKeyConfigured} + settingsSaving={playerSettingsSaving} + onSaveAdvanced={saveDanmakuAdvanced} candidates={danmakuCandidates} alternatives={danmakuAlternatives} mergeSources={danmakuMergeSources} diff --git a/web/src/pages/PlayerVideoStage.tsx b/web/src/pages/PlayerVideoStage.tsx index f129259..4e83552 100644 --- a/web/src/pages/PlayerVideoStage.tsx +++ b/web/src/pages/PlayerVideoStage.tsx @@ -147,6 +147,9 @@ type PlayerVideoStageProps = { onSubtitleStyleChange: (style: SubtitleStylePreset) => void videoRef: RefObject onVideoError: () => void + playerVolume: number + onPlayerVolumeChange: (volume: number) => void + onPlayerVolumeCommit: (volume: number) => void danmakuEnabled: boolean danmakuOpacity: number danmakuFontSize: number @@ -192,6 +195,9 @@ export function PlayerVideoStage({ onSubtitleStyleChange, videoRef, onVideoError, + playerVolume, + onPlayerVolumeChange, + onPlayerVolumeCommit, danmakuEnabled, danmakuOpacity, danmakuFontSize, @@ -631,6 +637,9 @@ export function PlayerVideoStage({ { + seenInside = typeof (globalThis as { VideoFrame?: unknown }).VideoFrame + return 7 +}) + +check('hides VideoFrame inside the callback', seenInside === 'undefined') +check('returns the callback result', result === 7) +check( + 'restores VideoFrame afterwards', + (globalThis as { VideoFrame?: unknown }).VideoFrame === FakeVideoFrame, +) + +console.log('withoutVideoFrame.test.ts ok') diff --git a/web/src/utils/withoutVideoFrame.ts b/web/src/utils/withoutVideoFrame.ts new file mode 100644 index 0000000..1f53825 --- /dev/null +++ b/web/src/utils/withoutVideoFrame.ts @@ -0,0 +1,27 @@ +/** + * JASSUB probes color space with `new VideoFrame(video)` during construct. + * Direct-play 302s to a CDN / remote Emby taint