From 501c323848bdaaf91dbbd53da10fbdddd8a3a7ff Mon Sep 17 00:00:00 2001 From: AjTheSpidey Date: Wed, 20 May 2026 02:00:01 +0800 Subject: [PATCH] feat: add studio playback hotkeys --- .../src/player/components/PlayerControls.tsx | 3 ++ .../player/hooks/usePlaybackKeyboard.test.ts | 45 ++++++++++++++++++- .../src/player/hooks/usePlaybackKeyboard.ts | 31 +++++++++++-- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index c528cffb8..78f0c51b0 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -12,9 +12,12 @@ const SHORTCUT_SECTIONS = [ title: "Playback", hints: [ { key: "Space", label: "Play / Pause" }, + { key: "M", label: "Mute / Unmute" }, { key: "J", label: "Play backward" }, { key: "K", label: "Stop" }, { key: "L", label: "Play forward" }, + { key: "Shift+L", label: "Toggle loop" }, + { key: "Ctrl+L", label: "Toggle loop" }, { key: "←/→", label: "Step 1 frame" }, { key: "⇧←/⇧→", label: "Step 10 frames" }, ], diff --git a/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts b/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts index ab6f7b623..1ea98f8fa 100644 --- a/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts +++ b/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts @@ -71,11 +71,17 @@ function setupHook(): HookHandle { }; } -function keydown(init: { code: string; key: string; shiftKey?: boolean }): KeyboardEvent { +function keydown(init: { + code: string; + key: string; + shiftKey?: boolean; + ctrlKey?: boolean; +}): KeyboardEvent { return new KeyboardEvent("keydown", { code: init.code, key: init.key, shiftKey: init.shiftKey ?? false, + ctrlKey: init.ctrlKey ?? false, cancelable: true, }); } @@ -171,4 +177,41 @@ describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => { expect(spies.play).toHaveBeenCalledTimes(1); }); + + it("M toggles preview audio mute", () => { + const { dispatch } = setupHook(); + usePlayerStore.setState({ audioMuted: false }); + + act(() => { + dispatch(keydown({ code: "KeyM", key: "m" })); + }); + + expect(usePlayerStore.getState().audioMuted).toBe(true); + }); + + it("Shift+L toggles loop instead of forward shuttle", () => { + const { dispatch, spies } = setupHook(); + usePlayerStore.setState({ loopEnabled: false }); + + act(() => { + dispatch(keydown({ code: "KeyL", key: "L", shiftKey: true })); + }); + + expect(usePlayerStore.getState().loopEnabled).toBe(true); + expect(spies.play).not.toHaveBeenCalled(); + }); + + it("Ctrl+L toggles loop and prevents the browser location shortcut", () => { + const { dispatch, spies } = setupHook(); + usePlayerStore.setState({ loopEnabled: false }); + const event = keydown({ code: "KeyL", key: "l", ctrlKey: true }); + + act(() => { + dispatch(event); + }); + + expect(usePlayerStore.getState().loopEnabled).toBe(true); + expect(event.defaultPrevented).toBe(true); + expect(spies.play).not.toHaveBeenCalled(); + }); }); diff --git a/packages/studio/src/player/hooks/usePlaybackKeyboard.ts b/packages/studio/src/player/hooks/usePlaybackKeyboard.ts index d4733ca58..ef42a855a 100644 --- a/packages/studio/src/player/hooks/usePlaybackKeyboard.ts +++ b/packages/studio/src/player/hooks/usePlaybackKeyboard.ts @@ -8,7 +8,11 @@ import { useRef, useCallback } from "react"; import { useCaptionStore } from "../../captions/store"; -import { shouldIgnorePlaybackShortcutEvent, SHUTTLE_SPEEDS } from "../lib/playbackShortcuts"; +import { + shouldIgnorePlaybackShortcutEvent, + shouldIgnorePlaybackShortcutTarget, + SHUTTLE_SPEEDS, +} from "../lib/playbackShortcuts"; import { usePlayerStore } from "../store/playerStore"; import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time"; import type { PlaybackAdapter } from "../lib/playbackTypes"; @@ -78,10 +82,27 @@ export function usePlaybackKeyboard({ } }, [play, pause]); + const toggleAudioMuted = useCallback(() => { + const { audioMuted, setAudioMuted } = usePlayerStore.getState(); + setAudioMuted(!audioMuted); + }, []); + + const toggleLoop = useCallback(() => { + const { loopEnabled, setLoopEnabled } = usePlayerStore.getState(); + setLoopEnabled(!loopEnabled); + }, []); + const handlePlaybackKeyDown = useCallback( (e: KeyboardEvent) => { if (e.defaultPrevented) return; const captionState = useCaptionStore.getState(); + if (shouldIgnorePlaybackShortcutTarget(e.target)) return; + const key = e.key.toLowerCase(); + if (key === "l" && (e.ctrlKey || e.shiftKey) && !e.altKey && !e.metaKey) { + e.preventDefault(); + toggleLoop(); + return; + } if ( shouldIgnorePlaybackShortcutEvent(e, { isCaptionEditMode: captionState.isEditMode, @@ -90,7 +111,6 @@ export function usePlaybackKeyboard({ ) { return; } - const key = e.key.toLowerCase(); pressedKeysRef.current.add(key); if (e.code === "Space") { e.preventDefault(); @@ -108,6 +128,11 @@ export function usePlaybackKeyboard({ return; } if (e.repeat) return; + if (key === "m") { + e.preventDefault(); + toggleAudioMuted(); + return; + } if (key === "k") { e.preventDefault(); pause(); @@ -157,7 +182,7 @@ export function usePlaybackKeyboard({ return; } }, - [pause, shuttle, stepFrames, togglePlay, getAdapter, seek], + [pause, shuttle, stepFrames, toggleAudioMuted, toggleLoop, togglePlay, getAdapter, seek], ); const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {