mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
feat(studio): add M (mute) and Shift+L (loop) keyboard shortcuts
Adds NLE-style hotkeys for muting audio and toggling loop playback in the Studio player, matching the workflow conventions in DaVinci Resolve, Premiere, and Final Cut. - M toggles audio mute (no-op above 1x playback, matching the mute button's existing gating behavior). - Shift+L toggles loop. Ctrl/Cmd+L was considered but is filtered out by shouldIgnorePlaybackShortcutEvent and conflicts with the browser address bar; Shift+L is also consistent with the existing Shift+I / Shift+O modifier pattern. The Shift+L handler runs before the existing plain-L shuttle case so it doesn't also start forward playback. Fixes #905
This commit is contained in:
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user