From 065652d75894efd839510071dcb5778bd29d0224 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:27:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/danmaku_settings_test.go | 42 ++++++++----- internal/model/user.go | 4 +- internal/service/danmaku_service.go | 45 +++---------- internal/service/danmaku_service_test.go | 2 - web/src/api/danmaku.ts | 2 - web/src/components/ExternalPlayerButton.tsx | 2 +- web/src/components/PlayerControls.tsx | 28 ++------- web/src/pages/MediaDetailAdminPanel.tsx | 54 +++++++++------- web/src/pages/MediaDetailMetadata.tsx | 70 ++++++++++++++------- web/src/pages/MediaDetailPageSections.tsx | 54 ++++++++-------- web/src/pages/PlayerPage.tsx | 49 +++------------ web/src/pages/PlayerVideoStage.tsx | 3 - web/src/types/auth.ts | 2 - web/src/utils/playbackRate.ts | 3 + 14 files changed, 161 insertions(+), 199 deletions(-) diff --git a/internal/handler/danmaku_settings_test.go b/internal/handler/danmaku_settings_test.go index cb0422c..9a0158c 100644 --- a/internal/handler/danmaku_settings_test.go +++ b/internal/handler/danmaku_settings_test.go @@ -112,8 +112,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) { t.Fatalf("status = %d, want 200", w.Code) } var cfg struct { - MergeSources bool `json:"merge_sources"` - PlaybackRate float64 `json:"playback_rate"` + MergeSources bool `json:"merge_sources"` } if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil { t.Fatalf("decode: %v", err) @@ -121,9 +120,6 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) { if cfg.MergeSources { t.Fatal("default merge preference should be false") } - if cfg.PlaybackRate != 1 { - t.Fatalf("default playback rate = %v, want 1", cfg.PlaybackRate) - } if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil { t.Fatalf("set: %v", err) @@ -141,7 +137,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) { 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,\"playback_rate\":1.5,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}" + 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) @@ -158,7 +154,7 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) { 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 || cfg.PlaybackRate != 1.5 { + 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 { @@ -169,7 +165,7 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) { if err != nil || user == nil { t.Fatalf("read persisted user: %v", err) } - if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.PlayerPlaybackRate != 1.5 || user.DanmakuSource != "https://dm.example/base" { + if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" { t.Fatalf("preferences not persisted: %+v", user) } } @@ -207,22 +203,36 @@ func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) { } } -func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) { +// 播放倍速只作用于当前视频:接口既不下发也不保存它。老客户端仍可能带上 +// playback_rate,服务端应当忽略而不是报错。 +func TestDanmakuSettingsIgnorePlaybackRate(t *testing.T) { svc := newDanmakuSettingsService(t) - c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", - `{"playback_rate":4}`, "user-1") - updateDanmakuSettingsHandler(svc)(c) + c, w := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1") + getDanmakuConfigHandler(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("playback_rate")) { + t.Fatalf("config must not expose playback rate: %s", w.Body.String()) + } - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String()) + c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", + `{"volume":0.5,"playback_rate":1.5}`, "user-1") + updateDanmakuSettingsHandler(svc)(c2) + + if w2.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", w2.Code, w2.Body.String()) + } + if bytes.Contains(w2.Body.Bytes(), []byte("playback_rate")) { + t.Fatalf("settings response must not expose playback rate: %s", w2.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.PlayerPlaybackRate != 1 { - t.Fatalf("invalid playback rate was persisted: %v", user.PlayerPlaybackRate) + if user.PlayerVolume != 0.5 { + t.Fatalf("volume not persisted: %v", user.PlayerVolume) } } diff --git a/internal/model/user.go b/internal/model/user.go index e1f5e0a..bbfac11 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -37,8 +37,8 @@ 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"` + // 播放倍速不在此列:它只作用于当前视频,不进数据库。 + PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"` // PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明, // 按用户保存:看过一次之后不再弹出。 PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"` diff --git a/internal/service/danmaku_service.go b/internal/service/danmaku_service.go index 2aba865..0744f37 100644 --- a/internal/service/danmaku_service.go +++ b/internal/service/danmaku_service.go @@ -49,12 +49,6 @@ const ( // dandanplay protocol (search/episodes + comment/{episodeId}) may be used. const DanmakuDefaultSource = "https://api.dandanplay.net" -const ( - minPlayerPlaybackRate = 0.5 - maxPlayerPlaybackRate = 3.0 - defaultPlayerPlaybackRate = 1.0 -) - // danmakuOfficialBase is where identification (/api/v2/match) and the // comment/search fallback always go, regardless of the configured source. // A package var (not a const) so tests can point it at a local server. @@ -69,8 +63,6 @@ type DanmakuRenderConfig struct { FontSize string `json:"font_size"` Area string `json:"area"` Volume float64 `json:"volume"` - // PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。 - PlaybackRate float64 `json:"playback_rate"` // Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。 Vr360GuideSeen bool `json:"vr360_guide_seen"` // MergeSources 是当前用户的弹幕合并偏好(按用户存储)。 @@ -99,7 +91,6 @@ type DanmakuSettingsPatch struct { Area *float64 `json:"area"` MergeSources *bool `json:"merge_sources"` Volume *float64 `json:"volume"` - PlaybackRate *float64 `json:"playback_rate"` // Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。 Vr360GuideSeen *bool `json:"vr360_guide_seen"` } @@ -358,11 +349,10 @@ func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime { // Config reads danmaku settings from the runtime settings table. func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig { cfg := DanmakuRenderConfig{ - Opacity: "1", - FontSize: "24", - Area: "1", - Volume: 1, - PlaybackRate: defaultPlayerPlaybackRate, + Opacity: "1", + FontSize: "24", + Area: "1", + Volume: 1, } if s == nil || s.repo == nil || s.repo.Setting == nil { return cfg @@ -414,12 +404,11 @@ func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.Us func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig { if user == nil { return DanmakuRenderConfig{ - Enabled: true, - Opacity: "1", - FontSize: "24", - Area: "1", - Volume: 1, - PlaybackRate: defaultPlayerPlaybackRate, + Enabled: true, + Opacity: "1", + FontSize: "24", + Area: "1", + Volume: 1, } } opacity := user.DanmakuOpacity @@ -438,10 +427,6 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig { if volume < 0 || volume > 1 { volume = 1 } - playbackRate := user.PlayerPlaybackRate - if playbackRate < minPlayerPlaybackRate || playbackRate > maxPlayerPlaybackRate { - playbackRate = defaultPlayerPlaybackRate - } return DanmakuRenderConfig{ Enabled: user.DanmakuEnabled, Source: strings.TrimSpace(user.DanmakuSource), @@ -450,7 +435,6 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig { FontSize: strconv.Itoa(fontSize), Area: strconv.FormatFloat(area, 'f', -1, 64), Volume: volume, - PlaybackRate: playbackRate, Vr360GuideSeen: user.PlayerVr360GuideSeen, MergeSources: user.DanmakuMergeSources, AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "", @@ -522,17 +506,6 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string, } updates["player_volume"] = *patch.Volume } - if patch.PlaybackRate != nil { - if *patch.PlaybackRate < minPlayerPlaybackRate || *patch.PlaybackRate > maxPlayerPlaybackRate { - return DanmakuRenderConfig{}, fmt.Errorf( - "%w: playback_rate must be between %.2f and %.2f", - ErrInvalidDanmakuSettings, - minPlayerPlaybackRate, - maxPlayerPlaybackRate, - ) - } - updates["player_playback_rate"] = *patch.PlaybackRate - } if patch.Vr360GuideSeen != nil { updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen } diff --git a/internal/service/danmaku_service_test.go b/internal/service/danmaku_service_test.go index e4ca045..c863251 100644 --- a/internal/service/danmaku_service_test.go +++ b/internal/service/danmaku_service_test.go @@ -422,7 +422,6 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) { user.DanmakuFontSize = 30 user.DanmakuArea = 0.7 user.PlayerVolume = 0.42 - user.PlayerPlaybackRate = 1.25 require.NoError(t, svc.repo.User.Create(ctx, &user)) seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0) @@ -431,7 +430,6 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) { require.Contains(t, res.Raw, "弹幕A") require.Equal(t, srv.URL(), res.Source) require.Equal(t, 0.42, res.Volume) - require.Equal(t, 1.25, res.PlaybackRate) require.Equal(t, "0.65", res.Opacity) require.Equal(t, "30", res.FontSize) require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id") diff --git a/web/src/api/danmaku.ts b/web/src/api/danmaku.ts index eb81c10..c13d58f 100644 --- a/web/src/api/danmaku.ts +++ b/web/src/api/danmaku.ts @@ -71,7 +71,6 @@ export interface DanmakuConfig { font_size: string area: string volume: number - playback_rate: number /** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */ vr360_guide_seen: boolean /** Per-user preference: merge the same episode's multiple sources. */ @@ -90,7 +89,6 @@ export interface DanmakuSettingsPatch { area?: number merge_sources?: boolean volume?: number - playback_rate?: number /** 看过 VR 操作说明后置为 true,之后不再弹出。 */ vr360_guide_seen?: boolean } diff --git a/web/src/components/ExternalPlayerButton.tsx b/web/src/components/ExternalPlayerButton.tsx index 20ebf50..e6c2242 100644 --- a/web/src/components/ExternalPlayerButton.tsx +++ b/web/src/components/ExternalPlayerButton.tsx @@ -52,7 +52,7 @@ export function ExternalPlayerButton({ className={ compact ? 'shrink-0 inline-flex items-center rounded-lg border border-primary-400/35 bg-white px-2 py-1 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:opacity-50 transition-colors whitespace-nowrap' - : 'btn-outline border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50' + : 'btn-outline h-11 border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50' } > diff --git a/web/src/components/PlayerControls.tsx b/web/src/components/PlayerControls.tsx index e21aab7..e75edb2 100644 --- a/web/src/components/PlayerControls.tsx +++ b/web/src/components/PlayerControls.tsx @@ -133,10 +133,9 @@ type PlayerControlsProps = { volume?: number onVolumeChange?: (volume: number) => void onVolumeCommit?: (volume: number) => void - /** 用户级播放倍速,由播放页从数据库读取。 */ + /** 播放倍速(1x = 原速)。只作用于当前视频,不落库。 */ playbackRate?: number onPlaybackRateChange?: (rate: number) => void - onPlaybackRateCommit?: (rate: number) => void uiVisible: boolean onUiVisibleChange: (visible: boolean) => void /** 外部浮层(VR 工具条/设置面板)正被鼠标悬停:此时控制栏不自动隐藏。 */ @@ -213,7 +212,6 @@ export function PlayerControls({ onVolumeCommit, playbackRate: playbackRateProp = 1, onPlaybackRateChange, - onPlaybackRateCommit, uiVisible, onUiVisibleChange, uiHold = false, @@ -381,7 +379,7 @@ export function PlayerControls({ setMuted(next === 0) }, [video, volumeProp]) - // 倍速由播放页按用户持久化;配置加载、切换剧集或切换播放源后同步到当前 video。 + // 倍速只影响当前视频:挂载、切换剧集或切换播放源后同步到当前 video。 useEffect(() => { const el = video() if (!el) return @@ -626,15 +624,9 @@ export function PlayerControls({ }, PLAYBACK_RATE_HINT_MS) }, [onPlaybackRateChange]) - const commitPlaybackRate = useCallback(() => { - onPlaybackRateCommit?.(playbackRateRef.current) - }, [onPlaybackRateCommit]) - const selectPlaybackRate = useCallback((next: number) => { - const normalized = normalizePlaybackRate(next) - changePlaybackRate(normalized) - onPlaybackRateCommit?.(normalized) - }, [changePlaybackRate, onPlaybackRateCommit]) + changePlaybackRate(normalizePlaybackRate(next)) + }, [changePlaybackRate]) const applyAbsoluteSeek = useCallback( (absolute: number) => { @@ -787,18 +779,6 @@ export function PlayerControls({ return () => window.removeEventListener('keydown', onKey, true) }, [changePlaybackRate, queueRelativeSeek]) - useEffect(() => { - const onKeyUp = (e: KeyboardEvent) => { - if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return - if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return - if (shouldIgnorePlayerSeekShortcut(e.target)) return - e.preventDefault() - commitPlaybackRate() - } - window.addEventListener('keyup', onKeyUp, true) - return () => window.removeEventListener('keyup', onKeyUp, true) - }, [commitPlaybackRate]) - useEffect(() => { return () => { if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current) diff --git a/web/src/pages/MediaDetailAdminPanel.tsx b/web/src/pages/MediaDetailAdminPanel.tsx index 54eb8f5..ba8e1bd 100644 --- a/web/src/pages/MediaDetailAdminPanel.tsx +++ b/web/src/pages/MediaDetailAdminPanel.tsx @@ -1,4 +1,4 @@ -import { Database, FileText, FolderInput, Pencil, Search, Sparkles, Trash2 } from 'lucide-react' +import { Database, FileText, FolderInput, Pencil, Search, ShieldAlert, Sparkles, Trash2 } from 'lucide-react' import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle' import type { Media } from '../types' @@ -34,50 +34,56 @@ export function MediaDetailAdminPanel({ } return ( -
-

系统后台高级控制面板

- {isEpisodeArtworkTarget(media) && ( - - )} -
- - - - - -
-
+ ) } diff --git a/web/src/pages/MediaDetailMetadata.tsx b/web/src/pages/MediaDetailMetadata.tsx index b2cd965..d105f48 100644 --- a/web/src/pages/MediaDetailMetadata.tsx +++ b/web/src/pages/MediaDetailMetadata.tsx @@ -9,6 +9,10 @@ type MediaDetailMetadataProps = { export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) { const heading = media.episode_title?.trim() || media.title const showTitleContext = Boolean(media.episode_title?.trim() && media.title && media.title !== heading) + const genres = parseCSV(media.genres) + const languages = parseCSV(media.languages) + const countries = parseCSV(media.countries) + const hasTaxonomy = genres.length > 0 || languages.length > 0 || countries.length > 0 return ( <> @@ -21,26 +25,26 @@ export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) { {media.title}

)} -
+
{media.year > 0 && ( - + {media.year} 年 )} - {media.width > 0 && ( - + {media.width > 0 && media.height > 0 && ( + {media.width} × {media.height} )} - - {fmtSize(media.size_bytes)} - - - {fmtDuration(media.duration_sec)} - + {media.size_bytes > 0 && ( + {fmtSize(media.size_bytes)} + )} + {media.duration_sec > 0 && ( + {fmtDuration(media.duration_sec)} + )} {media.container && ( - + {media.container} )} @@ -56,24 +60,44 @@ export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
)} -
- - - -
+ {hasTaxonomy && ( +
+ + + +
+ )} ) } -function MetadataTags({ label, values, primary = false }: { label: string; values: string[]; primary?: boolean }) { +/** + * One "label - values" row of the taxonomy block. + * + * All rows share the same label gutter, so every value column starts on the + * same vertical line. Pills keep one geometry and differ by tone only. + */ +function MetadataRow({ + label, + values, + tone = 'neutral', +}: { + label: string + values: string[] + tone?: 'neutral' | 'brand' +}) { if (values.length === 0) return null - const tagClass = primary - ? 'rounded-full bg-brand-50 text-brand-700 border border-brand-100/30 px-3 py-1 text-2xs font-bold uppercase tracking-wider' - : 'rounded-xl bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold' + const tagClass = [ + 'inline-flex items-center rounded-lg border px-2.5 py-1 text-2xs font-bold uppercase tracking-wide', + tone === 'brand' + ? 'border-brand-100/50 bg-brand-50 text-brand-700' + : 'border-gray-200/60 bg-gray-100 text-gray-600', + ].join(' ') + return ( -
- {label} -
+
+ {label} +
{values.map((value) => ( {value} diff --git a/web/src/pages/MediaDetailPageSections.tsx b/web/src/pages/MediaDetailPageSections.tsx index df116dc..7de4cb9 100644 --- a/web/src/pages/MediaDetailPageSections.tsx +++ b/web/src/pages/MediaDetailPageSections.tsx @@ -99,19 +99,23 @@ export function MediaDetailPlaybackActions({ }: MediaDetailPlaybackActionsProps) { const targetId = media.id || playTargetId || '' return ( -
- +
+ 立即播放 {!isDirectStreamMedia(media) && ( - + to={`/play/${targetId}?mode=hls`} + state={{ from: `/media/${media.id}` }} + className="btn-outline h-11 border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50" + > + HLS 兼容转码播放 )} @@ -121,7 +125,7 @@ export function MediaDetailPlaybackActions({