From a563ee11d6d9bd57084c61884e462d64ad8027cf Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:00:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E8=AE=B0=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/danmaku_settings_test.go | 31 +++- internal/model/user.go | 1 + internal/service/danmaku_service.go | 43 ++++- internal/service/danmaku_service_test.go | 2 + web/src/api/danmaku.ts | 2 + web/src/components/Layout.tsx | 6 +- web/src/components/LayoutSections.tsx | 10 +- web/src/components/PlayerControls.tsx | 203 ++++++++++++++++++++-- web/src/hooks/useScrollMemory.test.ts | 14 ++ web/src/hooks/useScrollMemory.ts | 177 +++++++++++++++++++ web/src/pages/PlayerPage.tsx | 45 +++++ web/src/pages/PlayerVideoStage.tsx | 9 + web/src/types/auth.ts | 2 + web/src/utils/playbackRate.test.ts | 22 +++ web/src/utils/playbackRate.ts | 31 ++++ 15 files changed, 572 insertions(+), 26 deletions(-) create mode 100644 web/src/hooks/useScrollMemory.test.ts create mode 100644 web/src/hooks/useScrollMemory.ts create mode 100644 web/src/utils/playbackRate.test.ts create mode 100644 web/src/utils/playbackRate.ts diff --git a/internal/handler/danmaku_settings_test.go b/internal/handler/danmaku_settings_test.go index a1d7a38..acd3cd2 100644 --- a/internal/handler/danmaku_settings_test.go +++ b/internal/handler/danmaku_settings_test.go @@ -112,7 +112,8 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) { t.Fatalf("status = %d, want 200", w.Code) } 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 { t.Fatalf("decode: %v", err) @@ -120,6 +121,9 @@ 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) @@ -137,7 +141,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,\"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,\"playback_rate\":1.5,\"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) @@ -154,7 +158,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 { + if !cfg.MergeSources || cfg.Volume != 0.35 || cfg.PlaybackRate != 1.5 { t.Fatalf("unexpected user preferences: %+v", cfg) } if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured { @@ -165,11 +169,30 @@ 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.DanmakuSource != "https://dm.example/base" { + if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.PlayerPlaybackRate != 1.5 || user.DanmakuSource != "https://dm.example/base" { t.Fatalf("preferences not persisted: %+v", user) } } +func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) { + svc := newDanmakuSettingsService(t) + + c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", + `{"playback_rate":4}`, "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.PlayerPlaybackRate != 1 { + t.Fatalf("invalid playback rate was persisted: %v", user.PlayerPlaybackRate) + } +} + func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) { svc := newDanmakuSettingsService(t) diff --git a/internal/model/user.go b/internal/model/user.go index e0bd65c..6750b87 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -33,6 +33,7 @@ type User struct { SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"` // 网页播放器偏好按用户存储,切换媒体对象后继续沿用。 PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"` + PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"` DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"` DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"` DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"` diff --git a/internal/service/danmaku_service.go b/internal/service/danmaku_service.go index 209be29..3b0d170 100644 --- a/internal/service/danmaku_service.go +++ b/internal/service/danmaku_service.go @@ -49,6 +49,12 @@ 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. @@ -63,6 +69,8 @@ type DanmakuRenderConfig struct { FontSize string `json:"font_size"` Area string `json:"area"` Volume float64 `json:"volume"` + // PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。 + PlaybackRate float64 `json:"playback_rate"` // MergeSources 是当前用户的弹幕合并偏好(按用户存储)。 MergeSources bool `json:"merge_sources"` // AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。 @@ -89,6 +97,7 @@ type DanmakuSettingsPatch struct { Area *float64 `json:"area"` MergeSources *bool `json:"merge_sources"` Volume *float64 `json:"volume"` + PlaybackRate *float64 `json:"playback_rate"` } type danmakuUserContextKey struct{} @@ -345,10 +354,11 @@ 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, + Opacity: "1", + FontSize: "24", + Area: "1", + Volume: 1, + PlaybackRate: defaultPlayerPlaybackRate, } if s == nil || s.repo == nil || s.repo.Setting == nil { return cfg @@ -399,7 +409,14 @@ 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} + return DanmakuRenderConfig{ + Enabled: true, + Opacity: "1", + FontSize: "24", + Area: "1", + Volume: 1, + PlaybackRate: defaultPlayerPlaybackRate, + } } opacity := user.DanmakuOpacity if opacity < 0.1 || opacity > 1 { @@ -417,6 +434,10 @@ 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), @@ -425,6 +446,7 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig { FontSize: strconv.Itoa(fontSize), Area: strconv.FormatFloat(area, 'f', -1, 64), Volume: volume, + PlaybackRate: playbackRate, MergeSources: user.DanmakuMergeSources, AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "", } @@ -495,6 +517,17 @@ 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 len(updates) == 0 { return DanmakuRenderConfig{}, ErrNoDanmakuSettings } diff --git a/internal/service/danmaku_service_test.go b/internal/service/danmaku_service_test.go index c863251..e4ca045 100644 --- a/internal/service/danmaku_service_test.go +++ b/internal/service/danmaku_service_test.go @@ -422,6 +422,7 @@ 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) @@ -430,6 +431,7 @@ 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 630c69d..e8e04cb 100644 --- a/web/src/api/danmaku.ts +++ b/web/src/api/danmaku.ts @@ -71,6 +71,7 @@ export interface DanmakuConfig { font_size: string area: string volume: number + playback_rate: number /** Per-user preference: merge the same episode's multiple sources. */ merge_sources: boolean } @@ -87,6 +88,7 @@ export interface DanmakuSettingsPatch { area?: number merge_sources?: boolean volume?: number + playback_rate?: number } // danmakuAPI fetches danmaku comments for a media item. The backend resolves diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 5c9aa56..f4993ef 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -87,7 +87,11 @@ export function Layout() { pathname={location.pathname} /> )} - + {showMobileBottomNav && ( sidebar.setIsMobileDrawerOpen(true)} /> )} diff --git a/web/src/components/LayoutSections.tsx b/web/src/components/LayoutSections.tsx index 02ed330..c9b3a5d 100644 --- a/web/src/components/LayoutSections.tsx +++ b/web/src/components/LayoutSections.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx' import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent' import { isPlayerRoute } from './layoutNavigation' import { RouteErrorBoundary } from './RouteErrorBoundary' +import { useScrollMemory } from '../hooks/useScrollMemory' import type { useLayoutSidebar } from './useLayoutSidebar' type LayoutSidebarState = ReturnType @@ -30,6 +31,7 @@ type LayoutSidebarsProps = Omit< type LayoutWorkspaceProps = { routeKey: string + userKey?: string showMobileBottomNav?: boolean } @@ -109,7 +111,13 @@ export function LayoutSidebars({ ) } -export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: LayoutWorkspaceProps) { +export function LayoutWorkspace({ + routeKey, + userKey = 'anonymous', + showMobileBottomNav = false, +}: LayoutWorkspaceProps) { + useScrollMemory(routeKey, userKey) + const bottomPad = showMobileBottomNav ? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10' : '' diff --git a/web/src/components/PlayerControls.tsx b/web/src/components/PlayerControls.tsx index e537cb6..ba0363e 100644 --- a/web/src/components/PlayerControls.tsx +++ b/web/src/components/PlayerControls.tsx @@ -18,11 +18,18 @@ import { Rewind, SkipBack, SkipForward, + Timer, Volume2, VolumeX, } from 'lucide-react' import type { SubtitleTrack } from '../api/subtitles' import type { PlaybackQuality } from '../types' +import { + formatPlaybackRate, + normalizePlaybackRate, + PLAYBACK_RATE_OPTIONS, + stepPlaybackRate, +} from '../utils/playbackRate' import type { SubtitleChineseMode } from '../utils/subtitleChinese' import { SUBTITLE_POSITION_OPTIONS, @@ -47,18 +54,28 @@ const SEEK_STEP_SEC = 10 const SEEK_REPEAT_MS = 160 const SEEK_APPLY_MS = 220 const SEEK_HINT_MS = 700 +const PLAYBACK_RATE_REPEAT_MS = 140 +const PLAYBACK_RATE_HINT_MS = 900 type SeekHint = { dir: 'back' | 'forward' seconds: number } -function isEditableTarget(target: EventTarget | null): boolean { +function isPlayerSeekRange(target: EventTarget | null): boolean { + return target instanceof HTMLInputElement && target.dataset.playerSeekRange === 'true' +} + +function shouldIgnorePlayerSeekShortcut(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false - return Boolean( - target.closest('input, textarea, select, [contenteditable="true"]') || - target.isContentEditable, + const control = target.closest( + 'input, textarea, select, [contenteditable="true"]', ) + if (control instanceof HTMLInputElement) { + // 进度条需要继续走播放器的 10 秒快捷键;其它输入控件保留原生键盘行为。 + return !isPlayerSeekRange(control) + } + return Boolean(control || target.isContentEditable) } type PlayerControlsProps = { @@ -67,6 +84,10 @@ type PlayerControlsProps = { volume?: number onVolumeChange?: (volume: number) => void onVolumeCommit?: (volume: number) => void + /** 用户级播放倍速,由播放页从数据库读取。 */ + playbackRate?: number + onPlaybackRateChange?: (rate: number) => void + onPlaybackRateCommit?: (rate: number) => void uiVisible: boolean onUiVisibleChange: (visible: boolean) => void subs: SubtitleTrack[] @@ -108,6 +129,9 @@ export function PlayerControls({ volume: volumeProp = 1, onVolumeChange, onVolumeCommit, + playbackRate: playbackRateProp = 1, + onPlaybackRateChange, + onPlaybackRateCommit, uiVisible, onUiVisibleChange, subs, @@ -159,11 +183,14 @@ export function PlayerControls({ const subtitleMenuRef = useRef(null) const [qualityMenuOpen, setQualityMenuOpen] = useState(false) const qualityMenuRef = useRef(null) + const [speedMenuOpen, setSpeedMenuOpen] = useState(false) + const speedMenuRef = useRef(null) const hideTimerRef = useRef | null>(null) const controlsHoveredRef = useRef(false) const isScrubbingRef = useRef(false) const subtitleMenuOpenRef = useRef(false) const qualityMenuOpenRef = useRef(false) + const speedMenuOpenRef = useRef(false) const danmakuOpenRef = useRef(false) const playlistOpenRef = useRef(false) const pendingSeekRef = useRef(null) @@ -173,7 +200,11 @@ export function PlayerControls({ const seekApplyTimerRef = useRef | null>(null) const seekHintTimerRef = useRef | null>(null) const lastSeekAtRef = useRef(0) + const playbackRateRef = useRef(normalizePlaybackRate(playbackRateProp)) + const playbackRateHintTimerRef = useRef | null>(null) + const lastPlaybackRateAtRef = useRef(0) const [seekHint, setSeekHint] = useState(null) + const [playbackRateHint, setPlaybackRateHint] = useState(null) const [stageEl, setStageEl] = useState(null) useEffect(() => { @@ -192,6 +223,10 @@ export function PlayerControls({ qualityMenuOpenRef.current = qualityMenuOpen }, [qualityMenuOpen]) + useEffect(() => { + speedMenuOpenRef.current = speedMenuOpen + }, [speedMenuOpen]) + useEffect(() => { danmakuOpenRef.current = danmakuOpen }, [danmakuOpen]) @@ -204,6 +239,10 @@ export function PlayerControls({ durationRef.current = duration }, [duration]) + useEffect(() => { + playbackRateRef.current = normalizePlaybackRate(playbackRateProp) + }, [playbackRateProp]) + useEffect(() => { setStageEl(container()) }, [container]) @@ -219,9 +258,19 @@ export function PlayerControls({ setMuted(next === 0) }, [video, volumeProp]) - // 点击控制栏外部时关闭字幕/画质菜单 + // 倍速由播放页按用户持久化;配置加载、切换剧集或切换播放源后同步到当前 video。 useEffect(() => { - if (!subtitleMenuOpen && !qualityMenuOpen) return + const el = video() + if (!el) return + const next = normalizePlaybackRate(playbackRateProp) + if (Math.abs(el.playbackRate - next) > 0.001) { + el.playbackRate = next + } + }, [video, playbackRateProp]) + + // 点击控制栏外部时关闭字幕/画质/倍速菜单 + useEffect(() => { + if (!subtitleMenuOpen && !qualityMenuOpen && !speedMenuOpen) return const onDocClick = (e: MouseEvent) => { if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) { setSubtitleMenuOpen(false) @@ -229,10 +278,13 @@ export function PlayerControls({ if (qualityMenuRef.current && !qualityMenuRef.current.contains(e.target as Node)) { setQualityMenuOpen(false) } + if (speedMenuRef.current && !speedMenuRef.current.contains(e.target as Node)) { + setSpeedMenuOpen(false) + } } document.addEventListener('mousedown', onDocClick) return () => document.removeEventListener('mousedown', onDocClick) - }, [subtitleMenuOpen, qualityMenuOpen]) + }, [subtitleMenuOpen, qualityMenuOpen, speedMenuOpen]) // 播放时 3 秒无操作自动隐藏控制栏;暂停/悬停/拖动进度条/打开菜单时保持显示。 // 监听挂在整个播放器舞台容器(data-player-stage)上,避免光标移到控制栏时因离开视频画面而误触发 mouseleave。 @@ -249,6 +301,7 @@ export function PlayerControls({ !isScrubbingRef.current && !subtitleMenuOpenRef.current && !qualityMenuOpenRef.current && + !speedMenuOpenRef.current && !danmakuOpenRef.current && !playlistOpenRef.current ) { @@ -258,6 +311,7 @@ export function PlayerControls({ !isScrubbingRef.current && !subtitleMenuOpenRef.current && !qualityMenuOpenRef.current && + !speedMenuOpenRef.current && !danmakuOpenRef.current && !playlistOpenRef.current ) { @@ -360,7 +414,7 @@ export function PlayerControls({ // 当悬停或菜单状态改变时,更新控制栏计时器 useEffect(() => { - if (controlsHovered || isScrubbing || subtitleMenuOpen || qualityMenuOpen || danmakuOpen || playlistOpen) { + if (controlsHovered || isScrubbing || subtitleMenuOpen || qualityMenuOpen || speedMenuOpen || danmakuOpen || playlistOpen) { onUiVisibleChange(true) if (hideTimerRef.current) clearTimeout(hideTimerRef.current) } else { @@ -370,7 +424,7 @@ export function PlayerControls({ hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000) } } - }, [controlsHovered, isScrubbing, subtitleMenuOpen, qualityMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video]) + }, [controlsHovered, isScrubbing, subtitleMenuOpen, qualityMenuOpen, speedMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video]) const togglePlay = () => { const el = video() @@ -379,6 +433,30 @@ export function PlayerControls({ else el.pause() } + const changePlaybackRate = useCallback((next: number) => { + const normalized = normalizePlaybackRate(next) + playbackRateRef.current = normalized + onPlaybackRateChange?.(normalized) + setPlaybackRateHint(normalized) + if (playbackRateHintTimerRef.current) { + clearTimeout(playbackRateHintTimerRef.current) + } + playbackRateHintTimerRef.current = setTimeout(() => { + setPlaybackRateHint(null) + playbackRateHintTimerRef.current = null + }, 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]) + const applyAbsoluteSeek = useCallback( (absolute: number) => { const el = video() @@ -454,7 +532,7 @@ export function PlayerControls({ }, 3000) }, [onUiVisibleChange, video]) - const queueRelativeSeek = useCallback((delta: number) => { + const queueRelativeSeek = useCallback((delta: number, immediate: boolean) => { const el = video() if (!el) return if (!seekBurstRef.current) { @@ -489,6 +567,16 @@ export function PlayerControls({ if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current) seekHintTimerRef.current = setTimeout(() => setSeekHint(null), SEEK_HINT_MS) + if (immediate) { + if (seekApplyTimerRef.current) { + clearTimeout(seekApplyTimerRef.current) + seekApplyTimerRef.current = null + } + const burst = seekBurstRef.current + seekBurstRef.current = null + applySeekRef.current(burst.base + burst.delta) + return + } if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current) seekApplyTimerRef.current = setTimeout(() => { const burst = seekBurstRef.current @@ -502,23 +590,47 @@ export function PlayerControls({ useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return - if (isEditableTarget(e.target)) return + if (shouldIgnorePlayerSeekShortcut(e.target)) return + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault() + const now = Date.now() + if (e.repeat && now - lastPlaybackRateAtRef.current < PLAYBACK_RATE_REPEAT_MS) return + lastPlaybackRateAtRef.current = now + changePlaybackRate( + stepPlaybackRate(playbackRateRef.current, e.key === 'ArrowUp' ? 1 : -1), + ) + return + } if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return e.preventDefault() const now = Date.now() if (e.repeat && now - lastSeekAtRef.current < SEEK_REPEAT_MS) return + const immediate = !e.repeat && now - lastSeekAtRef.current >= SEEK_APPLY_MS lastSeekAtRef.current = now e.preventDefault() - queueRelativeSeek(e.key === 'ArrowLeft' ? -SEEK_STEP_SEC : SEEK_STEP_SEC) + queueRelativeSeek(e.key === 'ArrowLeft' ? -SEEK_STEP_SEC : SEEK_STEP_SEC, immediate) } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }, [queueRelativeSeek]) + window.addEventListener('keydown', onKey, true) + 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) if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current) + if (playbackRateHintTimerRef.current) clearTimeout(playbackRateHintTimerRef.current) } }, []) @@ -612,9 +724,24 @@ export function PlayerControls({ ) : null + const playbackRateOverlay = playbackRateHint && stageEl + ? createPortal( + + + + + {formatPlaybackRate(playbackRateHint)} 倍速 + + + , + stageEl, + ) + : null + return ( <> {seekOverlay} + {playbackRateOverlay} { setSubtitleMenuOpen(false) + setSpeedMenuOpen(false) setQualityMenuOpen((v) => !v) }} className="flex items-center gap-1 rounded-full p-1.5 transition hover:bg-white/15" @@ -754,11 +883,55 @@ export function PlayerControls({ )} + + { + setSubtitleMenuOpen(false) + setQualityMenuOpen(false) + setSpeedMenuOpen((v) => !v) + }} + className="flex items-center gap-1 rounded-full p-1.5 transition hover:bg-white/15" + title="播放速度(↑/↓ 调节)" + > + + + {formatPlaybackRate(playbackRateProp)} + + + {speedMenuOpen && ( + + + 播放速度 + + {PLAYBACK_RATE_OPTIONS.map((rate) => { + const current = normalizePlaybackRate(playbackRateProp) === rate + return ( + { + selectPlaybackRate(rate) + setSpeedMenuOpen(false) + }} + className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition ${ + current ? 'text-rose-400 hover:bg-white/10' : 'text-white/85 hover:bg-white/10' + }`} + > + {formatPlaybackRate(rate)} + {current && } + + ) + })} + + )} + + {subs.length > 0 && ( { setQualityMenuOpen(false) + setSpeedMenuOpen(false) setSubtitleMenuOpen((v) => !v) }} className="rounded-full p-1.5 transition hover:bg-white/15" diff --git a/web/src/hooks/useScrollMemory.test.ts b/web/src/hooks/useScrollMemory.test.ts new file mode 100644 index 0000000..28a376e --- /dev/null +++ b/web/src/hooks/useScrollMemory.test.ts @@ -0,0 +1,14 @@ +import { shouldRememberScroll } from './useScrollMemory.ts' + +function check(name: string, condition: boolean) { + if (!condition) throw new Error(`useScrollMemory: ${name}`) +} + +check('home remembers scroll', shouldRememberScroll('/')) +check('library list remembers scroll', shouldRememberScroll('/libraries')) +check('library detail remembers scroll', shouldRememberScroll('/library/lib-1')) +check('media detail remembers scroll', shouldRememberScroll('/media/media-1')) +check('settings does not remember scroll', !shouldRememberScroll('/settings')) +check('player does not remember scroll', !shouldRememberScroll('/play/media-1')) + +console.log('useScrollMemory.test.ts ok') diff --git a/web/src/hooks/useScrollMemory.ts b/web/src/hooks/useScrollMemory.ts new file mode 100644 index 0000000..0ff4848 --- /dev/null +++ b/web/src/hooks/useScrollMemory.ts @@ -0,0 +1,177 @@ +import { useLayoutEffect } from 'react' + +const SCROLL_STORAGE_PREFIX = 'mebox.scroll.' +const scrollPositions = new Map() + +function readScrollPosition(key: string): number { + const cached = scrollPositions.get(key) + if (cached !== undefined) return cached + if (typeof window === 'undefined') return 0 + try { + const value = Number(window.sessionStorage.getItem(SCROLL_STORAGE_PREFIX + key)) + if (Number.isFinite(value) && value >= 0) { + scrollPositions.set(key, value) + return value + } + } catch { + // sessionStorage 不可用时仍可使用内存缓存。 + } + return 0 +} + +function writeScrollPosition(key: string, value: number): void { + const next = Math.max(0, Math.round(value)) + scrollPositions.set(key, next) + if (typeof window === 'undefined') return + try { + window.sessionStorage.setItem(SCROLL_STORAGE_PREFIX + key, String(next)) + } catch { + // 私密模式等场景忽略存储失败,不影响页面滚动。 + } +} + +export function shouldRememberScroll(pathname: string): boolean { + return ( + pathname === '/' || + pathname === '/libraries' || + pathname.startsWith('/library/') || + pathname.startsWith('/media/') + ) +} + +/** + * 记住列表页的滚动位置。页面内容会异步长高,因此恢复期间会监听内容高度, + * 直到目标位置可达;期间用户主动滚动会立即接管,避免和恢复逻辑抢滚动条。 + */ +export function useScrollMemory(pathname: string, userKey = 'anonymous'): void { + useLayoutEffect(() => { + const el = document.getElementById('app-main-scroll') + if (!el) return + + if (!shouldRememberScroll(pathname)) { + el.scrollTop = 0 + return + } + + const key = `${userKey}:${pathname}` + const saved = readScrollPosition(key) + let restoring = saved > 0 + let observer: ResizeObserver | null = null + let restoreTimer: number | null = null + let restoreFrame = 0 + let restorePumpUntil = 0 + let lastSaved = saved + + const stopRestore = () => { + if (restoreFrame) { + window.cancelAnimationFrame(restoreFrame) + restoreFrame = 0 + } + if (restoreTimer != null) { + window.clearTimeout(restoreTimer) + restoreTimer = null + } + observer?.disconnect() + observer = null + } + + const finishRestore = () => { + if (!restoring) return + restoring = false + stopRestore() + lastSaved = Math.round(el.scrollTop) + writeScrollPosition(key, lastSaved) + } + + const tryRestore = () => { + if (!restoring) return + const max = Math.max(0, el.scrollHeight - el.clientHeight) + const target = Math.min(saved, max) + if (target > 0 && Math.abs(el.scrollTop - target) > 1) { + el.scrollTop = target + } + if (target >= saved - 1) { + finishRestore() + } + } + + const pumpRestore = () => { + if (!restoring) return + tryRestore() + if (restoring && performance.now() < restorePumpUntil) { + restoreFrame = window.requestAnimationFrame(pumpRestore) + } else { + restoreFrame = 0 + } + } + + const saveNow = () => { + if (restoring) return + const current = Math.round(el.scrollTop) + if (current === lastSaved) return + lastSaved = current + writeScrollPosition(key, current) + } + + const cancelRestore = () => { + if (!restoring) return + restoring = false + stopRestore() + lastSaved = Math.round(el.scrollTop) + writeScrollPosition(key, lastSaved) + } + + if (saved <= 0) { + el.scrollTop = 0 + } else { + tryRestore() + if (restoring) { + const content = el.firstElementChild + if (typeof ResizeObserver !== 'undefined' && content instanceof HTMLElement) { + observer = new ResizeObserver(() => tryRestore()) + observer.observe(content) + } + restorePumpUntil = performance.now() + 1000 + restoreFrame = window.requestAnimationFrame(pumpRestore) + restoreTimer = window.setTimeout(finishRestore, 10000) + } + } + + const onPointerDown = (event: PointerEvent) => { + if (event.target === el) { + cancelRestore() + } + saveNow() + } + + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.key === 'PageDown' || + event.key === 'PageUp' || + event.key === 'Home' || + event.key === 'End' || + event.key === 'ArrowDown' || + event.key === 'ArrowUp' || + event.key === ' ' + ) { + cancelRestore() + } + saveNow() + } + + el.addEventListener('scroll', saveNow, { passive: true }) + el.addEventListener('pointerdown', onPointerDown, true) + window.addEventListener('wheel', cancelRestore, { passive: true, capture: true }) + window.addEventListener('touchstart', cancelRestore, { passive: true, capture: true }) + window.addEventListener('keydown', onKeyDown, true) + + return () => { + el.removeEventListener('scroll', saveNow) + el.removeEventListener('pointerdown', onPointerDown, true) + window.removeEventListener('wheel', cancelRestore, true) + window.removeEventListener('touchstart', cancelRestore, true) + window.removeEventListener('keydown', onKeyDown, true) + stopRestore() + } + }, [pathname, userKey]) +} diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index d15c085..d90c0eb 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -19,6 +19,7 @@ import { profileAPI } from '../api/profile' import { useAuthStore } from '../stores/auth' import type { Media, PlaybackInfo, PlaybackQuality } from '../types' import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries' +import { normalizePlaybackRate } from '../utils/playbackRate' import { isRemoteEmbyID } from '../utils/remoteEmby' import { normalizeSubtitleChineseMode, @@ -109,8 +110,13 @@ export function PlayerPage() { const [playerVolume, setPlayerVolume] = useState(() => normalizePlayerVolume(authUser?.player_volume), ) + const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() => + normalizePlaybackRate(authUser?.player_playback_rate), + ) const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode) 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) @@ -245,6 +251,10 @@ 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) @@ -255,6 +265,10 @@ 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(() => { // 配置读取失败时保持本地默认值,不影响播放。 @@ -289,6 +303,34 @@ 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 = { @@ -1424,6 +1466,9 @@ export function PlayerPage() { playerVolume={playerVolume} onPlayerVolumeChange={changePlayerVolume} 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 bfd0d87..81ff8db 100644 --- a/web/src/pages/PlayerVideoStage.tsx +++ b/web/src/pages/PlayerVideoStage.tsx @@ -151,6 +151,9 @@ type PlayerVideoStageProps = { playerVolume: number onPlayerVolumeChange: (volume: number) => void onPlayerVolumeCommit: (volume: number) => void + playerPlaybackRate: number + onPlayerPlaybackRateChange: (rate: number) => void + onPlayerPlaybackRateCommit: (rate: number) => void danmakuEnabled: boolean danmakuOpacity: number danmakuFontSize: number @@ -205,6 +208,9 @@ export function PlayerVideoStage({ playerVolume, onPlayerVolumeChange, onPlayerVolumeCommit, + playerPlaybackRate, + onPlayerPlaybackRateChange, + onPlayerPlaybackRateCommit, danmakuEnabled, danmakuOpacity, danmakuFontSize, @@ -653,6 +659,9 @@ export function PlayerVideoStage({ volume={playerVolume} onVolumeChange={onPlayerVolumeChange} onVolumeCommit={onPlayerVolumeCommit} + playbackRate={playerPlaybackRate} + onPlaybackRateChange={onPlayerPlaybackRateChange} + onPlaybackRateCommit={onPlayerPlaybackRateCommit} uiVisible={controlsVisible} onUiVisibleChange={setControlsVisible} subs={subs} diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts index 5abfc94..d4cd29a 100644 --- a/web/src/types/auth.ts +++ b/web/src/types/auth.ts @@ -10,6 +10,8 @@ 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.test.ts b/web/src/utils/playbackRate.test.ts new file mode 100644 index 0000000..3fd8fc1 --- /dev/null +++ b/web/src/utils/playbackRate.test.ts @@ -0,0 +1,22 @@ +import { + formatPlaybackRate, + normalizePlaybackRate, + stepPlaybackRate, +} from './playbackRate.ts' + +function check(name: string, condition: boolean) { + if (!condition) throw new Error(`playbackRate: ${name}`) +} + +check('invalid value falls back to 1x', normalizePlaybackRate(undefined) === 1) +check('value snaps to nearest selectable rate', normalizePlaybackRate(1.2) === 1.25) +check('value below range clamps to the minimum', normalizePlaybackRate(0.1) === 0.5) +check('value above range clamps to the maximum', normalizePlaybackRate(9) === 3) +check('up steps to the next rate', stepPlaybackRate(1, 1) === 1.25) +check('down steps to the previous rate', stepPlaybackRate(1, -1) === 0.75) +check('up stops at the maximum', stepPlaybackRate(3, 1) === 3) +check('down stops at the minimum', stepPlaybackRate(0.5, -1) === 0.5) +check('integer formatting', formatPlaybackRate(1) === '1x') +check('decimal formatting', formatPlaybackRate(1.25) === '1.25x') + +console.log('playbackRate.test.ts ok') diff --git a/web/src/utils/playbackRate.ts b/web/src/utils/playbackRate.ts new file mode 100644 index 0000000..b80d417 --- /dev/null +++ b/web/src/utils/playbackRate.ts @@ -0,0 +1,31 @@ +export const PLAYBACK_RATE_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3] as const + +export const MIN_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[0] +export const MAX_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[PLAYBACK_RATE_OPTIONS.length - 1] + +export function normalizePlaybackRate(value: unknown): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return 1 + let closest: number = PLAYBACK_RATE_OPTIONS[0] + let closestDistance = Number.POSITIVE_INFINITY + for (const option of PLAYBACK_RATE_OPTIONS) { + const distance = Math.abs(option - parsed) + if (distance < closestDistance) { + closest = option + closestDistance = distance + } + } + return closest +} + +export function stepPlaybackRate(current: unknown, direction: 1 | -1): number { + const normalized = normalizePlaybackRate(current) + const index = PLAYBACK_RATE_OPTIONS.indexOf(normalized as (typeof PLAYBACK_RATE_OPTIONS)[number]) + const next = Math.min(PLAYBACK_RATE_OPTIONS.length - 1, Math.max(0, index + direction)) + return PLAYBACK_RATE_OPTIONS[next] +} + +export function formatPlaybackRate(rate: unknown): string { + const normalized = normalizePlaybackRate(rate) + return `${Number.isInteger(normalized) ? normalized.toFixed(0) : String(normalized)}x` +}
+ 播放速度 +