diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index c528cffb8..efce623a1 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -15,6 +15,8 @@ const SHORTCUT_SECTIONS = [ { key: "J", label: "Play backward" }, { key: "K", label: "Stop" }, { key: "L", label: "Play forward" }, + { key: "M", label: "Toggle mute" }, + { key: "⇧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..ddd1bb413 100644 --- a/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts +++ b/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts @@ -172,3 +172,58 @@ describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => { expect(spies.play).toHaveBeenCalledTimes(1); }); }); + +describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => { + it("M toggles audioMuted", () => { + const { dispatch } = setupHook(); + expect(usePlayerStore.getState().audioMuted).toBe(false); + + act(() => { + dispatch(keydown({ code: "KeyM", key: "m" })); + }); + expect(usePlayerStore.getState().audioMuted).toBe(true); + + act(() => { + dispatch(keydown({ code: "KeyM", key: "m" })); + }); + expect(usePlayerStore.getState().audioMuted).toBe(false); + }); + + it("M does NOT toggle audioMuted above 1x playback (matches button gating)", () => { + const { dispatch } = setupHook(); + usePlayerStore.setState({ playbackRate: 2, audioMuted: false }); + + act(() => { + dispatch(keydown({ code: "KeyM", key: "m" })); + }); + + expect(usePlayerStore.getState().audioMuted).toBe(false); + }); + + it("Shift+L toggles loopEnabled without starting forward shuttle", () => { + const { dispatch, spies } = setupHook(); + expect(usePlayerStore.getState().loopEnabled).toBe(false); + + act(() => { + dispatch(keydown({ code: "KeyL", key: "L", shiftKey: true })); + }); + expect(usePlayerStore.getState().loopEnabled).toBe(true); + expect(spies.play).not.toHaveBeenCalled(); + + act(() => { + dispatch(keydown({ code: "KeyL", key: "L", shiftKey: true })); + }); + expect(usePlayerStore.getState().loopEnabled).toBe(false); + }); + + it("Plain L still starts forward shuttle (regression guard)", () => { + const { dispatch, spies } = setupHook(); + + act(() => { + dispatch(keydown({ code: "KeyL", key: "l" })); + }); + + expect(spies.play).toHaveBeenCalledTimes(1); + expect(usePlayerStore.getState().loopEnabled).toBe(false); + }); +}); diff --git a/packages/studio/src/player/hooks/usePlaybackKeyboard.ts b/packages/studio/src/player/hooks/usePlaybackKeyboard.ts index d4733ca58..a0c3f3054 100644 --- a/packages/studio/src/player/hooks/usePlaybackKeyboard.ts +++ b/packages/studio/src/player/hooks/usePlaybackKeyboard.ts @@ -108,6 +108,21 @@ export function usePlaybackKeyboard({ return; } if (e.repeat) return; + if (key === "m") { + e.preventDefault(); + const state = usePlayerStore.getState(); + // Audio is force-muted above 1x playback — match the mute button's gating. + if (state.playbackRate <= 1) { + state.setAudioMuted(!state.audioMuted); + } + return; + } + if (key === "l" && e.shiftKey) { + e.preventDefault(); + const state = usePlayerStore.getState(); + state.setLoopEnabled(!state.loopEnabled); + return; + } if (key === "k") { e.preventDefault(); pause();