This commit is contained in:
truewhile
2026-09-24 22:27:09 +08:00
parent e273b5e87d
commit 065652d758
14 changed files with 161 additions and 199 deletions
+26 -16
View File
@@ -112,8 +112,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
t.Fatalf("status = %d, want 200", w.Code) t.Fatalf("status = %d, want 200", w.Code)
} }
var cfg struct { var cfg struct {
MergeSources bool `json:"merge_sources"` MergeSources bool `json:"merge_sources"`
PlaybackRate float64 `json:"playback_rate"`
} }
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil { if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err) t.Fatalf("decode: %v", err)
@@ -121,9 +120,6 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
if cfg.MergeSources { if cfg.MergeSources {
t.Fatal("default merge preference should be false") 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 { if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
t.Fatalf("set: %v", err) t.Fatalf("set: %v", err)
@@ -141,7 +137,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) { func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
svc := newDanmakuSettingsService(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") c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
updateDanmakuSettingsHandler(svc)(c) 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" { if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
t.Fatalf("unexpected render config: %+v", cfg) 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) t.Fatalf("unexpected user preferences: %+v", cfg)
} }
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured { 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 { if err != nil || user == nil {
t.Fatalf("read persisted user: %v", err) 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) 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) svc := newDanmakuSettingsService(t)
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", c, w := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
`{"playback_rate":4}`, "user-1") getDanmakuConfigHandler(svc)(c)
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("playback_rate")) {
t.Fatalf("config must not expose playback rate: %s", w.Body.String())
}
if w.Code != http.StatusBadRequest { c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String()) `{"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") user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil { if err != nil || user == nil {
t.Fatalf("read user: %v", err) t.Fatalf("read user: %v", err)
} }
if user.PlayerPlaybackRate != 1 { if user.PlayerVolume != 0.5 {
t.Fatalf("invalid playback rate was persisted: %v", user.PlayerPlaybackRate) t.Fatalf("volume not persisted: %v", user.PlayerVolume)
} }
} }
+2 -2
View File
@@ -37,8 +37,8 @@ type User struct {
// original / simplified / traditional。 // original / simplified / traditional。
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"` SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。 // 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"` // 播放倍速不在此列:它只作用于当前视频,不进数据库。
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"` PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
// PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明, // PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明,
// 按用户保存:看过一次之后不再弹出。 // 按用户保存:看过一次之后不再弹出。
PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"` PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"`
+9 -36
View File
@@ -49,12 +49,6 @@ const (
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used. // dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
const DanmakuDefaultSource = "https://api.dandanplay.net" 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 // danmakuOfficialBase is where identification (/api/v2/match) and the
// comment/search fallback always go, regardless of the configured source. // 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. // 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"` FontSize string `json:"font_size"`
Area string `json:"area"` Area string `json:"area"`
Volume float64 `json:"volume"` Volume float64 `json:"volume"`
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
PlaybackRate float64 `json:"playback_rate"`
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。 // Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
Vr360GuideSeen bool `json:"vr360_guide_seen"` Vr360GuideSeen bool `json:"vr360_guide_seen"`
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。 // MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
@@ -99,7 +91,6 @@ type DanmakuSettingsPatch struct {
Area *float64 `json:"area"` Area *float64 `json:"area"`
MergeSources *bool `json:"merge_sources"` MergeSources *bool `json:"merge_sources"`
Volume *float64 `json:"volume"` Volume *float64 `json:"volume"`
PlaybackRate *float64 `json:"playback_rate"`
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。 // Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
Vr360GuideSeen *bool `json:"vr360_guide_seen"` Vr360GuideSeen *bool `json:"vr360_guide_seen"`
} }
@@ -358,11 +349,10 @@ func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
// Config reads danmaku settings from the runtime settings table. // Config reads danmaku settings from the runtime settings table.
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig { func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
cfg := DanmakuRenderConfig{ cfg := DanmakuRenderConfig{
Opacity: "1", Opacity: "1",
FontSize: "24", FontSize: "24",
Area: "1", Area: "1",
Volume: 1, Volume: 1,
PlaybackRate: defaultPlayerPlaybackRate,
} }
if s == nil || s.repo == nil || s.repo.Setting == nil { if s == nil || s.repo == nil || s.repo.Setting == nil {
return cfg return cfg
@@ -414,12 +404,11 @@ func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.Us
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig { func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
if user == nil { if user == nil {
return DanmakuRenderConfig{ return DanmakuRenderConfig{
Enabled: true, Enabled: true,
Opacity: "1", Opacity: "1",
FontSize: "24", FontSize: "24",
Area: "1", Area: "1",
Volume: 1, Volume: 1,
PlaybackRate: defaultPlayerPlaybackRate,
} }
} }
opacity := user.DanmakuOpacity opacity := user.DanmakuOpacity
@@ -438,10 +427,6 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
if volume < 0 || volume > 1 { if volume < 0 || volume > 1 {
volume = 1 volume = 1
} }
playbackRate := user.PlayerPlaybackRate
if playbackRate < minPlayerPlaybackRate || playbackRate > maxPlayerPlaybackRate {
playbackRate = defaultPlayerPlaybackRate
}
return DanmakuRenderConfig{ return DanmakuRenderConfig{
Enabled: user.DanmakuEnabled, Enabled: user.DanmakuEnabled,
Source: strings.TrimSpace(user.DanmakuSource), Source: strings.TrimSpace(user.DanmakuSource),
@@ -450,7 +435,6 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
FontSize: strconv.Itoa(fontSize), FontSize: strconv.Itoa(fontSize),
Area: strconv.FormatFloat(area, 'f', -1, 64), Area: strconv.FormatFloat(area, 'f', -1, 64),
Volume: volume, Volume: volume,
PlaybackRate: playbackRate,
Vr360GuideSeen: user.PlayerVr360GuideSeen, Vr360GuideSeen: user.PlayerVr360GuideSeen,
MergeSources: user.DanmakuMergeSources, MergeSources: user.DanmakuMergeSources,
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "", AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
@@ -522,17 +506,6 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
} }
updates["player_volume"] = *patch.Volume 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 { if patch.Vr360GuideSeen != nil {
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
} }
-2
View File
@@ -422,7 +422,6 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
user.DanmakuFontSize = 30 user.DanmakuFontSize = 30
user.DanmakuArea = 0.7 user.DanmakuArea = 0.7
user.PlayerVolume = 0.42 user.PlayerVolume = 0.42
user.PlayerPlaybackRate = 1.25
require.NoError(t, svc.repo.User.Create(ctx, &user)) require.NoError(t, svc.repo.User.Create(ctx, &user))
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0) seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
@@ -431,7 +430,6 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
require.Contains(t, res.Raw, "弹幕A") require.Contains(t, res.Raw, "弹幕A")
require.Equal(t, srv.URL(), res.Source) require.Equal(t, srv.URL(), res.Source)
require.Equal(t, 0.42, res.Volume) require.Equal(t, 0.42, res.Volume)
require.Equal(t, 1.25, res.PlaybackRate)
require.Equal(t, "0.65", res.Opacity) require.Equal(t, "0.65", res.Opacity)
require.Equal(t, "30", res.FontSize) require.Equal(t, "30", res.FontSize)
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id") require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
-2
View File
@@ -71,7 +71,6 @@ export interface DanmakuConfig {
font_size: string font_size: string
area: string area: string
volume: number volume: number
playback_rate: number
/** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */ /** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */
vr360_guide_seen: boolean vr360_guide_seen: boolean
/** Per-user preference: merge the same episode's multiple sources. */ /** Per-user preference: merge the same episode's multiple sources. */
@@ -90,7 +89,6 @@ export interface DanmakuSettingsPatch {
area?: number area?: number
merge_sources?: boolean merge_sources?: boolean
volume?: number volume?: number
playback_rate?: number
/** 看过 VR 操作说明后置为 true,之后不再弹出。 */ /** 看过 VR 操作说明后置为 true,之后不再弹出。 */
vr360_guide_seen?: boolean vr360_guide_seen?: boolean
} }
+1 -1
View File
@@ -52,7 +52,7 @@ export function ExternalPlayerButton({
className={ className={
compact 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' ? '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'
} }
> >
<PlaySquare size={compact ? 13 : 14} className="mr-1 inline" /> <PlaySquare size={compact ? 13 : 14} className="mr-1 inline" />
+4 -24
View File
@@ -133,10 +133,9 @@ type PlayerControlsProps = {
volume?: number volume?: number
onVolumeChange?: (volume: number) => void onVolumeChange?: (volume: number) => void
onVolumeCommit?: (volume: number) => void onVolumeCommit?: (volume: number) => void
/** 用户级播放倍速,由播放页从数据库读取。 */ /** 播放倍速(1x = 原速)。只作用于当前视频,不落库。 */
playbackRate?: number playbackRate?: number
onPlaybackRateChange?: (rate: number) => void onPlaybackRateChange?: (rate: number) => void
onPlaybackRateCommit?: (rate: number) => void
uiVisible: boolean uiVisible: boolean
onUiVisibleChange: (visible: boolean) => void onUiVisibleChange: (visible: boolean) => void
/** 外部浮层(VR 工具条/设置面板)正被鼠标悬停:此时控制栏不自动隐藏。 */ /** 外部浮层(VR 工具条/设置面板)正被鼠标悬停:此时控制栏不自动隐藏。 */
@@ -213,7 +212,6 @@ export function PlayerControls({
onVolumeCommit, onVolumeCommit,
playbackRate: playbackRateProp = 1, playbackRate: playbackRateProp = 1,
onPlaybackRateChange, onPlaybackRateChange,
onPlaybackRateCommit,
uiVisible, uiVisible,
onUiVisibleChange, onUiVisibleChange,
uiHold = false, uiHold = false,
@@ -381,7 +379,7 @@ export function PlayerControls({
setMuted(next === 0) setMuted(next === 0)
}, [video, volumeProp]) }, [video, volumeProp])
// 倍速由播放页按用户持久化;配置加载、切换剧集或切换播放源后同步到当前 video。 // 倍速只影响当前视频:挂载、切换剧集或切换播放源后同步到当前 video。
useEffect(() => { useEffect(() => {
const el = video() const el = video()
if (!el) return if (!el) return
@@ -626,15 +624,9 @@ export function PlayerControls({
}, PLAYBACK_RATE_HINT_MS) }, PLAYBACK_RATE_HINT_MS)
}, [onPlaybackRateChange]) }, [onPlaybackRateChange])
const commitPlaybackRate = useCallback(() => {
onPlaybackRateCommit?.(playbackRateRef.current)
}, [onPlaybackRateCommit])
const selectPlaybackRate = useCallback((next: number) => { const selectPlaybackRate = useCallback((next: number) => {
const normalized = normalizePlaybackRate(next) changePlaybackRate(normalizePlaybackRate(next))
changePlaybackRate(normalized) }, [changePlaybackRate])
onPlaybackRateCommit?.(normalized)
}, [changePlaybackRate, onPlaybackRateCommit])
const applyAbsoluteSeek = useCallback( const applyAbsoluteSeek = useCallback(
(absolute: number) => { (absolute: number) => {
@@ -787,18 +779,6 @@ export function PlayerControls({
return () => window.removeEventListener('keydown', onKey, true) return () => window.removeEventListener('keydown', onKey, true)
}, [changePlaybackRate, queueRelativeSeek]) }, [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(() => { useEffect(() => {
return () => { return () => {
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current) if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
+30 -24
View File
@@ -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 { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import type { Media } from '../types' import type { Media } from '../types'
@@ -34,50 +34,56 @@ export function MediaDetailAdminPanel({
} }
return ( return (
<div className="rounded-2xl border border-gray-200 bg-gray-50/50 p-5 space-y-3"> <section className="overflow-hidden rounded-2xl border border-gray-200 bg-gray-50/50">
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p> <div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200/60 px-4 py-3 sm:px-5">
{isEpisodeArtworkTarget(media) && ( <p className="inline-flex items-center gap-2 text-2xs font-bold uppercase tracking-[0.18em] text-gray-500">
<EpisodeArtworkToggle <ShieldAlert size={14} className="text-brand-500" />
checked={scrapeEpisodeArtwork} 系统后台高级控制面板
onChange={onScrapeEpisodeArtworkChange} </p>
title="关闭后仍会获取每集简介、评分和时长,只跳过单集图片" {isEpisodeArtworkTarget(media) && (
className="h-10" <EpisodeArtworkToggle
/> checked={scrapeEpisodeArtwork}
)} onChange={onScrapeEpisodeArtworkChange}
<div className="flex flex-wrap gap-2"> title="关闭后仍会获取每集简介、评分和时长,只跳过单集图片"
<button onClick={onSmartScrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50"> className="h-9 text-xs"
/>
)}
</div>
<div className="flex flex-wrap items-center gap-2 px-4 py-4 sm:px-5">
<button onClick={onSmartScrape} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Sparkles size={13} className="text-[#c9954a]" /> <Sparkles size={13} className="text-[#c9954a]" />
<span>智能刮削 (TMDB)</span> <span>智能刮削 (TMDB)</span>
</button> </button>
<button onClick={onManualScrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50"> <button onClick={onManualScrape} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Search size={13} className="text-[#c9954a]" /> <Search size={13} className="text-[#c9954a]" />
<span>手动匹配刮削</span> <span>手动匹配刮削</span>
</button> </button>
<button onClick={onMetadataEdit} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50"> <button onClick={onMetadataEdit} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Pencil size={13} className="text-gray-600" /> <Pencil size={13} className="text-brand-500" />
<span>编辑元数据</span> <span>编辑元数据</span>
</button> </button>
<button onClick={onOrganize} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50"> <button onClick={onOrganize} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<FolderInput size={13} className="text-[#c9954a]" /> <FolderInput size={13} className="text-[#c9954a]" />
<span>整理入库</span> <span>整理入库</span>
</button> </button>
<button onClick={onProbe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50"> <button onClick={onProbe} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Database size={13} className="text-gray-600" /> <Database size={13} className="text-brand-500" />
<span>探测媒体轨 (ffprobe)</span> <span>探测媒体轨 (ffprobe)</span>
</button> </button>
<button onClick={onExportNFO} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50"> <button onClick={onExportNFO} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<FileText size={13} /> <FileText size={13} className="text-brand-500" />
<span>写出本地 NFO 属性</span> <span>写出本地 NFO 属性</span>
</button> </button>
<button <button
onClick={onDelete} onClick={onDelete}
className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200" className="btn-outline ml-auto h-9 rounded-lg px-3 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
> >
<Trash2 size={13} /> <Trash2 size={13} className="text-red-500" />
<span>删除</span> <span>删除</span>
</button> </button>
</div> </div>
</div> </section>
) )
} }
+47 -23
View File
@@ -9,6 +9,10 @@ type MediaDetailMetadataProps = {
export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) { export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
const heading = media.episode_title?.trim() || media.title const heading = media.episode_title?.trim() || media.title
const showTitleContext = Boolean(media.episode_title?.trim() && media.title && media.title !== heading) 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 ( return (
<> <>
@@ -21,26 +25,26 @@ export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
{media.title} {media.title}
</p> </p>
)} )}
<div className="flex flex-wrap items-center gap-2.5 text-xs text-gray-500 font-bold tracking-wide uppercase"> <div className="flex flex-wrap items-center gap-2">
{media.year > 0 && ( {media.year > 0 && (
<span className="inline-flex items-center gap-1 bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700"> <span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide gap-1.5 text-gray-700">
<Calendar size={13} className="text-brand-500" /> <Calendar size={13} className="text-brand-500" />
<span>{media.year} 年</span> <span>{media.year} 年</span>
</span> </span>
)} )}
{media.width > 0 && ( {media.width > 0 && media.height > 0 && (
<span className="inline-flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100/50 px-2.5 py-1 rounded-xl"> <span className="inline-flex h-7 items-center rounded-lg border border-brand-100/50 bg-brand-50 px-2.5 text-2xs font-bold uppercase tracking-wide text-brand-700">
<span>{media.width} × {media.height}</span> <span>{media.width} × {media.height}</span>
</span> </span>
)} )}
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700"> {media.size_bytes > 0 && (
{fmtSize(media.size_bytes)} <span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide text-gray-700">{fmtSize(media.size_bytes)}</span>
</span> )}
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700"> {media.duration_sec > 0 && (
{fmtDuration(media.duration_sec)} <span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide text-gray-700">{fmtDuration(media.duration_sec)}</span>
</span> )}
{media.container && ( {media.container && (
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700 font-mono"> <span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide text-gray-700 font-mono">
{media.container} {media.container}
</span> </span>
)} )}
@@ -56,24 +60,44 @@ export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
</div> </div>
)} )}
<div className="space-y-4"> {hasTaxonomy && (
<MetadataTags label="类型流派" values={parseCSV(media.genres)} primary /> <div className="space-y-3">
<MetadataTags label="语言" values={parseCSV(media.languages)} /> <MetadataRow label="类型流派" values={parseCSV(media.genres)} tone="brand" />
<MetadataTags label="国家/地区" values={parseCSV(media.countries)} /> <MetadataRow label="语言" values={parseCSV(media.languages)} />
</div> <MetadataRow label="国家/地区" values={parseCSV(media.countries)} />
</div>
)}
</> </>
) )
} }
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 if (values.length === 0) return null
const tagClass = primary const tagClass = [
? 'rounded-full bg-brand-50 text-brand-700 border border-brand-100/30 px-3 py-1 text-2xs font-bold uppercase tracking-wider' 'inline-flex items-center rounded-lg border px-2.5 py-1 text-2xs font-bold uppercase tracking-wide',
: 'rounded-xl bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold' tone === 'brand'
? 'border-brand-100/50 bg-brand-50 text-brand-700'
: 'border-gray-200/60 bg-gray-100 text-gray-600',
].join(' ')
return ( return (
<div className="flex flex-wrap items-center gap-3"> <div className="grid grid-cols-[4.5rem_1fr] items-start gap-2 sm:grid-cols-[5.5rem_1fr]">
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">{label}</span> <span className="pt-1 text-xs font-semibold text-gray-500">{label}</span>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-1.5">
{values.map((value) => ( {values.map((value) => (
<span key={value} className={tagClass}> <span key={value} className={tagClass}>
{value} {value}
+29 -25
View File
@@ -99,19 +99,23 @@ export function MediaDetailPlaybackActions({
}: MediaDetailPlaybackActionsProps) { }: MediaDetailPlaybackActionsProps) {
const targetId = media.id || playTargetId || '' const targetId = media.id || playTargetId || ''
return ( return (
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap items-center gap-2.5">
<Link to={`/play/${targetId}`} state={{ from: `/media/${media.id}` }} className="btn-primary px-6 py-3.5 shadow-sm"> <Link
to={`/play/${targetId}`}
state={{ from: `/media/${media.id}` }}
className="btn-primary h-11 px-6 shadow-sm"
>
<Play size={16} fill="currentColor" /> <Play size={16} fill="currentColor" />
<span>立即播放</span> <span>立即播放</span>
</Link> </Link>
{!isDirectStreamMedia(media) && ( {!isDirectStreamMedia(media) && (
<Link <Link
to={`/play/${targetId}?mode=hls`} to={`/play/${targetId}?mode=hls`}
state={{ from: `/media/${media.id}` }} state={{ from: `/media/${media.id}` }}
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5" className="btn-outline h-11 border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50"
> >
<RefreshCw size={14} className="animate-spin-slow" /> <RefreshCw size={14} />
<span>HLS 兼容转码播放</span> <span>HLS 兼容转码播放</span>
</Link> </Link>
)} )}
@@ -121,7 +125,7 @@ export function MediaDetailPlaybackActions({
<button <button
onClick={onToggleFavourite} onClick={onToggleFavourite}
className={ className={
'btn-outline gap-2 ' + 'btn-outline h-11 gap-2 ' +
(favourite (favourite
? '!border-red-200 !bg-red-50 !text-red-600 hover:!bg-red-100/50' ? '!border-red-200 !bg-red-50 !text-red-600 hover:!bg-red-100/50'
: 'hover:border-red-200 hover:text-red-600 hover:bg-red-50/50') : 'hover:border-red-200 hover:text-red-600 hover:bg-red-50/50')
@@ -154,10 +158,10 @@ export function MediaDetailMainContent({
<div className="relative z-10 p-6 sm:p-10 flex flex-col md:flex-row gap-8 lg:gap-12"> <div className="relative z-10 p-6 sm:p-10 flex flex-col md:flex-row gap-8 lg:gap-12">
<MediaDetailPoster media={media} /> <MediaDetailPoster media={media} />
<div className="flex-1 space-y-6"> <div className="min-w-0 flex-1 space-y-5">
<MediaDetailMetadata media={media} /> <MediaDetailMetadata media={media} />
<div className="divider border-gray-200/60" /> <div className="divider" />
<div className="flex flex-col gap-5"> <div className="space-y-4">
<MediaDetailPlaybackActions <MediaDetailPlaybackActions
media={media} media={media}
favourite={favourite} favourite={favourite}
@@ -165,21 +169,21 @@ export function MediaDetailMainContent({
playTargetId={playTargetId} playTargetId={playTargetId}
/> />
<MediaVersionSwitcher media={media} /> <MediaVersionSwitcher media={media} />
{isAdmin && (
<MediaDetailAdminPanel
media={media}
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
onScrapeEpisodeArtworkChange={onScrapeEpisodeArtworkChange}
onSmartScrape={onSmartScrape}
onManualScrape={onManualScrape}
onMetadataEdit={onMetadataEdit}
onOrganize={onOrganize}
onProbe={onProbe}
onExportNFO={onExportNFO}
onDelete={onDelete}
/>
)}
</div> </div>
{isAdmin && (
<MediaDetailAdminPanel
media={media}
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
onScrapeEpisodeArtworkChange={onScrapeEpisodeArtworkChange}
onSmartScrape={onSmartScrape}
onManualScrape={onManualScrape}
onMetadataEdit={onMetadataEdit}
onOrganize={onOrganize}
onProbe={onProbe}
onExportNFO={onExportNFO}
onDelete={onDelete}
/>
)}
</div> </div>
</div> </div>
) )
+10 -39
View File
@@ -20,7 +20,7 @@ import { useAuthStore } from '../stores/auth'
import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types' import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries' import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion' import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
import { normalizePlaybackRate } from '../utils/playbackRate' import { DEFAULT_PLAYBACK_RATE, normalizePlaybackRate } from '../utils/playbackRate'
import { resolveActiveSkip, skippedNoticeText, toSkipSegments, type SkipPrompt } from '../utils/skipSegments' import { resolveActiveSkip, skippedNoticeText, toSkipSegments, type SkipPrompt } from '../utils/skipSegments'
import { isRemoteEmbyID } from '../utils/remoteEmby' import { isRemoteEmbyID } from '../utils/remoteEmby'
import { import {
@@ -139,16 +139,13 @@ export function PlayerPage() {
const [playerVolume, setPlayerVolume] = useState(() => const [playerVolume, setPlayerVolume] = useState(() =>
normalizePlayerVolume(authUser?.player_volume), normalizePlayerVolume(authUser?.player_volume),
) )
const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() => // 播放倍速只作用于当前视频:不入库、不跨视频沿用(换集/换片会重置回 1x)。
normalizePlaybackRate(authUser?.player_playback_rate), const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() => DEFAULT_PLAYBACK_RATE)
)
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode) const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
// VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来, // VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来,
// 这时不弹说明,避免给老用户闪一下。 // 这时不弹说明,避免给老用户闪一下。
const [vr360GuideSeen, setVr360GuideSeen] = useState<boolean | null>(null) const [vr360GuideSeen, setVr360GuideSeen] = useState<boolean | null>(null)
const playerVolumeTouchedRef = useRef(false) const playerVolumeTouchedRef = useRef(false)
const playerPlaybackRateTouchedRef = useRef(false)
const playerPlaybackRateSaveSeqRef = useRef(0)
const subtitlePreferenceTouchedRef = useRef(false) const subtitlePreferenceTouchedRef = useRef(false)
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve()) const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
const [hlsUnavailable, setHlsUnavailable] = useState(false) const [hlsUnavailable, setHlsUnavailable] = useState(false)
@@ -285,10 +282,6 @@ export function PlayerPage() {
if (!playerVolumeTouchedRef.current) { if (!playerVolumeTouchedRef.current) {
setPlayerVolume(volume) setPlayerVolume(volume)
} }
const playbackRate = normalizePlaybackRate(cfg.playback_rate)
if (!playerPlaybackRateTouchedRef.current) {
setPlayerPlaybackRate(playbackRate)
}
setDanmakuEnabled(cfg.enabled) setDanmakuEnabled(cfg.enabled)
setDanmakuOpacity(Number(cfg.opacity) || 1) setDanmakuOpacity(Number(cfg.opacity) || 1)
setDanmakuFontSize(Number(cfg.font_size) || 24) setDanmakuFontSize(Number(cfg.font_size) || 24)
@@ -300,10 +293,6 @@ export function PlayerPage() {
const current = useAuthStore.getState().user const current = useAuthStore.getState().user
if (current) setAuthUser({ ...current, player_volume: volume }) if (current) setAuthUser({ ...current, player_volume: volume })
} }
if (!playerPlaybackRateTouchedRef.current) {
const current = useAuthStore.getState().user
if (current) setAuthUser({ ...current, player_playback_rate: playbackRate })
}
}) })
.catch(() => { .catch(() => {
// 配置读取失败时保持本地默认值,不影响播放。 // 配置读取失败时保持本地默认值,不影响播放。
@@ -338,34 +327,11 @@ export function PlayerPage() {
[setAuthUser], [setAuthUser],
) )
// 倍速只改本机播放状态:不写回服务端,也不跨视频沿用。
const changePlayerPlaybackRate = useCallback((next: number) => { const changePlayerPlaybackRate = useCallback((next: number) => {
playerPlaybackRateTouchedRef.current = true
setPlayerPlaybackRate(normalizePlaybackRate(next)) setPlayerPlaybackRate(normalizePlaybackRate(next))
}, []) }, [])
const commitPlayerPlaybackRate = useCallback(
(next: number) => {
playerPlaybackRateTouchedRef.current = true
const playbackRate = normalizePlaybackRate(next)
setPlayerPlaybackRate(playbackRate)
const saveSeq = ++playerPlaybackRateSaveSeqRef.current
void danmakuAPI
.updateSettings({ playback_rate: playbackRate })
.then((cfg) => {
if (saveSeq !== playerPlaybackRateSaveSeqRef.current) return
const saved = normalizePlaybackRate(cfg.playback_rate)
setPlayerPlaybackRate(saved)
const current = useAuthStore.getState().user
if (current) setAuthUser({ ...current, player_playback_rate: saved })
})
.catch(() => {
if (saveSeq !== playerPlaybackRateSaveSeqRef.current) return
toast.error('倍速保存失败,请重试')
})
},
[setAuthUser],
)
const saveDanmakuAdvanced = useCallback( const saveDanmakuAdvanced = useCallback(
async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => { async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => {
const patch: DanmakuSettingsPatch = { const patch: DanmakuSettingsPatch = {
@@ -674,6 +640,12 @@ export function PlayerPage() {
const mediaId = media?.id const mediaId = media?.id
const playbackProvider = playbackInfo?.provider const playbackProvider = playbackInfo?.provider
// 倍速只影响当前视频:换到另一集/另一部片时回到 1x,避免上一个视频的
// 倍速被顺手带到下一集。
useEffect(() => {
setPlayerPlaybackRate(DEFAULT_PLAYBACK_RATE)
}, [mediaId])
// 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。 // 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。
useEffect(() => { useEffect(() => {
if (!mediaId) return if (!mediaId) return
@@ -1907,7 +1879,6 @@ export function PlayerPage() {
onPlayerVolumeCommit={commitPlayerVolume} onPlayerVolumeCommit={commitPlayerVolume}
playerPlaybackRate={playerPlaybackRate} playerPlaybackRate={playerPlaybackRate}
onPlayerPlaybackRateChange={changePlayerPlaybackRate} onPlayerPlaybackRateChange={changePlayerPlaybackRate}
onPlayerPlaybackRateCommit={commitPlayerPlaybackRate}
danmakuEnabled={danmakuEnabled} danmakuEnabled={danmakuEnabled}
danmakuOpacity={danmakuOpacity} danmakuOpacity={danmakuOpacity}
danmakuFontSize={danmakuFontSize} danmakuFontSize={danmakuFontSize}
-3
View File
@@ -184,7 +184,6 @@ type PlayerVideoStageProps = {
onPlayerVolumeCommit: (volume: number) => void onPlayerVolumeCommit: (volume: number) => void
playerPlaybackRate: number playerPlaybackRate: number
onPlayerPlaybackRateChange: (rate: number) => void onPlayerPlaybackRateChange: (rate: number) => void
onPlayerPlaybackRateCommit: (rate: number) => void
danmakuEnabled: boolean danmakuEnabled: boolean
danmakuOpacity: number danmakuOpacity: number
danmakuFontSize: number danmakuFontSize: number
@@ -268,7 +267,6 @@ export function PlayerVideoStage({
onPlayerVolumeCommit, onPlayerVolumeCommit,
playerPlaybackRate, playerPlaybackRate,
onPlayerPlaybackRateChange, onPlayerPlaybackRateChange,
onPlayerPlaybackRateCommit,
danmakuEnabled, danmakuEnabled,
danmakuOpacity, danmakuOpacity,
danmakuFontSize, danmakuFontSize,
@@ -892,7 +890,6 @@ export function PlayerVideoStage({
onVolumeCommit={onPlayerVolumeCommit} onVolumeCommit={onPlayerVolumeCommit}
playbackRate={playerPlaybackRate} playbackRate={playerPlaybackRate}
onPlaybackRateChange={onPlayerPlaybackRateChange} onPlaybackRateChange={onPlayerPlaybackRateChange}
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
uiVisible={controlsVisible && !locked} uiVisible={controlsVisible && !locked}
onUiVisibleChange={setControlsVisible} onUiVisibleChange={setControlsVisible}
uiHold={vrUiHovered} uiHold={vrUiHovered}
-2
View File
@@ -12,8 +12,6 @@ export interface User {
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional' subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
/** 本地缓存的播放器音量,服务端配置接口会刷新。 */ /** 本地缓存的播放器音量,服务端配置接口会刷新。 */
player_volume?: number player_volume?: number
/** 本地缓存的播放倍速,服务端配置接口会刷新。 */
player_playback_rate?: number
force_password_reset: boolean force_password_reset: boolean
is_active: boolean is_active: boolean
allowed_library_ids?: string[] allowed_library_ids?: string[]
+3
View File
@@ -3,6 +3,9 @@ export const PLAYBACK_RATE_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3]
export const MIN_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[0] export const MIN_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[0]
export const MAX_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[PLAYBACK_RATE_OPTIONS.length - 1] export const MAX_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[PLAYBACK_RATE_OPTIONS.length - 1]
/** 默认倍速(1x)。倍速不落库,每个视频都从这个值开始。 */
export const DEFAULT_PLAYBACK_RATE = 1
export function normalizePlaybackRate(value: unknown): number { export function normalizePlaybackRate(value: unknown): number {
const parsed = Number(value) const parsed = Number(value)
if (!Number.isFinite(parsed)) return 1 if (!Number.isFinite(parsed)) return 1