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({
)} +
+ + {speedMenuOpen && ( +
+

+ 播放速度 +

+ {PLAYBACK_RATE_OPTIONS.map((rate) => { + const current = normalizePlaybackRate(playbackRateProp) === rate + return ( + + ) + })} +
+ )} +
+ {subs.length > 0 && (