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({
-
+
-
-
+
+
- {isAdmin && (
-
- )}
+ {isAdmin && (
+
+ )}
)
diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx
index 4890ad2..99901df 100644
--- a/web/src/pages/PlayerPage.tsx
+++ b/web/src/pages/PlayerPage.tsx
@@ -20,7 +20,7 @@ import { useAuthStore } from '../stores/auth'
import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
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 { isRemoteEmbyID } from '../utils/remoteEmby'
import {
@@ -139,16 +139,13 @@ export function PlayerPage() {
const [playerVolume, setPlayerVolume] = useState(() =>
normalizePlayerVolume(authUser?.player_volume),
)
- const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() =>
- normalizePlaybackRate(authUser?.player_playback_rate),
- )
+ // 播放倍速只作用于当前视频:不入库、不跨视频沿用(换集/换片会重置回 1x)。
+ const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() => DEFAULT_PLAYBACK_RATE)
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
// VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来,
// 这时不弹说明,避免给老用户闪一下。
const [vr360GuideSeen, setVr360GuideSeen] = useState
(null)
const playerVolumeTouchedRef = useRef(false)
- const playerPlaybackRateTouchedRef = useRef(false)
- const playerPlaybackRateSaveSeqRef = useRef(0)
const subtitlePreferenceTouchedRef = useRef(false)
const subtitlePreferenceSaveQueueRef = useRef>(Promise.resolve())
const [hlsUnavailable, setHlsUnavailable] = useState(false)
@@ -285,10 +282,6 @@ export function PlayerPage() {
if (!playerVolumeTouchedRef.current) {
setPlayerVolume(volume)
}
- const playbackRate = normalizePlaybackRate(cfg.playback_rate)
- if (!playerPlaybackRateTouchedRef.current) {
- setPlayerPlaybackRate(playbackRate)
- }
setDanmakuEnabled(cfg.enabled)
setDanmakuOpacity(Number(cfg.opacity) || 1)
setDanmakuFontSize(Number(cfg.font_size) || 24)
@@ -300,10 +293,6 @@ export function PlayerPage() {
const current = useAuthStore.getState().user
if (current) setAuthUser({ ...current, player_volume: volume })
}
- if (!playerPlaybackRateTouchedRef.current) {
- const current = useAuthStore.getState().user
- if (current) setAuthUser({ ...current, player_playback_rate: playbackRate })
- }
})
.catch(() => {
// 配置读取失败时保持本地默认值,不影响播放。
@@ -338,34 +327,11 @@ export function PlayerPage() {
[setAuthUser],
)
+ // 倍速只改本机播放状态:不写回服务端,也不跨视频沿用。
const changePlayerPlaybackRate = useCallback((next: number) => {
- playerPlaybackRateTouchedRef.current = true
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(
async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => {
const patch: DanmakuSettingsPatch = {
@@ -674,6 +640,12 @@ export function PlayerPage() {
const mediaId = media?.id
const playbackProvider = playbackInfo?.provider
+ // 倍速只影响当前视频:换到另一集/另一部片时回到 1x,避免上一个视频的
+ // 倍速被顺手带到下一集。
+ useEffect(() => {
+ setPlayerPlaybackRate(DEFAULT_PLAYBACK_RATE)
+ }, [mediaId])
+
// 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。
useEffect(() => {
if (!mediaId) return
@@ -1907,7 +1879,6 @@ export function PlayerPage() {
onPlayerVolumeCommit={commitPlayerVolume}
playerPlaybackRate={playerPlaybackRate}
onPlayerPlaybackRateChange={changePlayerPlaybackRate}
- onPlayerPlaybackRateCommit={commitPlayerPlaybackRate}
danmakuEnabled={danmakuEnabled}
danmakuOpacity={danmakuOpacity}
danmakuFontSize={danmakuFontSize}
diff --git a/web/src/pages/PlayerVideoStage.tsx b/web/src/pages/PlayerVideoStage.tsx
index c193a65..e4d3ab0 100644
--- a/web/src/pages/PlayerVideoStage.tsx
+++ b/web/src/pages/PlayerVideoStage.tsx
@@ -184,7 +184,6 @@ type PlayerVideoStageProps = {
onPlayerVolumeCommit: (volume: number) => void
playerPlaybackRate: number
onPlayerPlaybackRateChange: (rate: number) => void
- onPlayerPlaybackRateCommit: (rate: number) => void
danmakuEnabled: boolean
danmakuOpacity: number
danmakuFontSize: number
@@ -268,7 +267,6 @@ export function PlayerVideoStage({
onPlayerVolumeCommit,
playerPlaybackRate,
onPlayerPlaybackRateChange,
- onPlayerPlaybackRateCommit,
danmakuEnabled,
danmakuOpacity,
danmakuFontSize,
@@ -892,7 +890,6 @@ export function PlayerVideoStage({
onVolumeCommit={onPlayerVolumeCommit}
playbackRate={playerPlaybackRate}
onPlaybackRateChange={onPlayerPlaybackRateChange}
- onPlaybackRateCommit={onPlayerPlaybackRateCommit}
uiVisible={controlsVisible && !locked}
onUiVisibleChange={setControlsVisible}
uiHold={vrUiHovered}
diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts
index 51620c9..020623d 100644
--- a/web/src/types/auth.ts
+++ b/web/src/types/auth.ts
@@ -12,8 +12,6 @@ export interface User {
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
/** 本地缓存的播放器音量,服务端配置接口会刷新。 */
player_volume?: number
- /** 本地缓存的播放倍速,服务端配置接口会刷新。 */
- player_playback_rate?: number
force_password_reset: boolean
is_active: boolean
allowed_library_ids?: string[]
diff --git a/web/src/utils/playbackRate.ts b/web/src/utils/playbackRate.ts
index b80d417..a48ea18 100644
--- a/web/src/utils/playbackRate.ts
+++ b/web/src/utils/playbackRate.ts
@@ -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 MAX_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[PLAYBACK_RATE_OPTIONS.length - 1]
+/** 默认倍速(1x)。倍速不落库,每个视频都从这个值开始。 */
+export const DEFAULT_PLAYBACK_RATE = 1
+
export function normalizePlaybackRate(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 1