Skip to content

feat: LEAP-1737: Add keyboard shortcut for audio play/pause action #6863

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jan 15, 2025
4 changes: 3 additions & 1 deletion web/libs/editor/src/common/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface ButtonProps extends HTMLButtonProps {
danger?: boolean;
style?: CSSProperties;
hotkey?: keyof typeof Hotkey.keymap;
hotkeyScope?: string;
tooltip?: string;
tooltipTheme?: "light" | "dark";
nopadding?: boolean;
Expand Down Expand Up @@ -63,6 +64,7 @@ export const Button: ButtonType<ButtonProps> = forwardRef(
primary,
danger,
hotkey,
hotkeyScope,
tooltip,
tooltipTheme = "light",
nopadding,
Expand Down Expand Up @@ -101,7 +103,7 @@ export const Button: ButtonType<ButtonProps> = forwardRef(
}
}, [icon, size]);

useHotkey(hotkey, rest.onClick as unknown as Keymaster.KeyHandler);
useHotkey(hotkey, rest.onClick as unknown as Keymaster.KeyHandler, hotkeyScope);

const buttonBody = (
<Block name="button" mod={mods} mix={className} ref={ref} tag={finalTag} type={type} {...rest}>
Expand Down
2 changes: 2 additions & 0 deletions web/libs/editor/src/components/Timeline/Controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from "../../assets/icons/timeline";
import { Button, type ButtonProps } from "../../common/Button/Button";
import { Space } from "../../common/Space/Space";
import { Hotkey } from "../../core/Hotkey";
import { Block, Elem } from "../../utils/bem";
import { isDefined } from "../../utils/utilities";
import { TimelineContext } from "./Context";
Expand Down Expand Up @@ -247,6 +248,7 @@ export const Controls: FC<TimelineControlsProps> = memo(
data-testid={`playback-button:${playing ? "pause" : "play"}`}
onClick={handlePlay}
hotkey={settings?.playpauseHotkey}
hotkeyScope={Hotkey.ALL_SCOPES}
>
{playing ? <IconPause /> : <IconPlay />}
</ControlButton>
Expand Down
2 changes: 2 additions & 0 deletions web/libs/editor/src/core/Hotkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,8 @@ Hotkey.DEFAULT_SCOPE = DEFAULT_SCOPE;

Hotkey.INPUT_SCOPE = INPUT_SCOPE;

Hotkey.ALL_SCOPES = [DEFAULT_SCOPE, INPUT_SCOPE].join(",");

Hotkey.keymap = { ...defaultKeymap } as Keymap;

Hotkey.setKeymap = (newKeymap: Keymap) => {
Expand Down
9 changes: 7 additions & 2 deletions web/libs/editor/src/core/settings/keymap.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
"mac": "command+b",
"description": "Back for one second"
},
"audio:playpause": {},
"audio:playpause": {
"key": "ctrl+p",
"mac": "command+p",
"description": "Play/pause"
},
"ts:grow-left": {
"key": "left",
"description": "Increase region to the left"
Expand Down Expand Up @@ -119,7 +123,8 @@
"description": "Delete selected region"
},
"media:playpause": {
"key": "alt+space",
"key": "ctrl+alt+space",
"mac": "control+space",
"description": "Play/pause"
},
"media:step-backward": {
Expand Down
24 changes: 13 additions & 11 deletions web/libs/editor/src/hooks/useHotkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,25 @@ type Keyname = keyof typeof Hotkey.keymap;

const hotkeys = Hotkey();

const attachHotkey = (key: Keyname, handler: Keymaster.KeyHandler) => {
const attachHotkey = (key: Keyname, handler: Keymaster.KeyHandler, scope?: string) => {
if (Hotkey.keymap[key]) {
hotkeys.overwriteNamed(key as string, handler);
hotkeys.overwriteNamed(key as string, handler, scope);
} else {
hotkeys.overwriteKey(key as string, handler);
hotkeys.overwriteKey(key as string, handler, scope);
}
};

const removeHotkey = (key: Keyname) => {
const removeHotkey = (key: Keyname, scope?: string) => {
if (Hotkey.keymap[key]) {
hotkeys.removeNamed(key as string);
hotkeys.removeNamed(key as string, scope);
} else {
hotkeys.removeKey(key as string);
hotkeys.removeKey(key as string, scope);
}
};

export const useHotkey = (hotkey?: Keyname, handler?: Keymaster.KeyHandler) => {
export const useHotkey = (hotkey?: Keyname, handler?: Keymaster.KeyHandler, scope?: string) => {
const lastHotkey = useRef<Keyname | null>(null);
const lastScope = useRef<string | null>(null);
const handlerFunction = useRef<Keymaster.KeyHandler | undefined>(handler);

// we wanna cache handler function so the prop change does not re-attac a hotkey
Expand All @@ -34,24 +35,25 @@ export const useHotkey = (hotkey?: Keyname, handler?: Keymaster.KeyHandler) => {

useEffect(() => {
const hotkeyChanged = hotkey !== lastHotkey.current;
const scopeChanged = scope !== lastScope.current;

// hotkey itself only references a cached version of a function
// so it's never re-attached even if handler changes
// handler update might happen if it's wrapped with useCallback
// and will trigger infinite loop if we use it as a dependency for
// current effect
(() => {
if (!hotkeyChanged) return;
if (!hotkeyChanged && !scopeChanged) return;

if (hotkey) {
attachHotkey(hotkey, handlerWrapper.current);
attachHotkey(hotkey, handlerWrapper.current, scope);
lastHotkey.current = hotkey;
} else if (lastHotkey.current && !hotkey) {
removeHotkey(lastHotkey.current);
removeHotkey(lastHotkey.current, lastScope.current);
lastHotkey.current = null;
}
})();
}, [hotkey]);
}, [hotkey, scope]);

// by changing the ref we can safely update the handler
// as refs are mutable and doesn't trigger react-updates
Expand Down
2 changes: 1 addition & 1 deletion web/libs/editor/src/stores/Annotation/Annotation.js
Original file line number Diff line number Diff line change
Expand Up @@ -888,7 +888,7 @@ const _Annotation = types
else audioNode = node;

node.hotkey = comb;
hotkeys.addKey(comb, node.onHotKey, "Play an audio", `${Hotkey.DEFAULT_SCOPE},${Hotkey.INPUT_SCOPE}`);
hotkeys.addKey(comb, node.onHotKey, "Play an audio", Hotkey.ALL_SCOPES);

audiosNum++;
}
Expand Down
97 changes: 58 additions & 39 deletions web/libs/editor/src/tags/object/AudioUltra/view.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { observer } from "mobx-react";
import { type FC, useEffect, useRef } from "react";
import { type FC, useEffect, useMemo, useRef } from "react";
import { TimelineContextProvider } from "../../../components/Timeline/Context";
import { Hotkey } from "../../../core/Hotkey";
import { useWaveform } from "../../../lib/AudioUltra/react";
import { Controls } from "../../../components/Timeline/Controls";
Expand Down Expand Up @@ -152,6 +153,22 @@ const AudioUltraView: FC<AudioUltraProps> = ({ item }) => {
};
}, []);

const contextValue = useMemo(() => {
return {
position: 0,
length: 0,
regions: [],
step: 10,
playing: false,
visibleWidth: 0,
seekOffset: 0,
data: undefined,
settings: {
playpauseHotkey: "audio:playpause",
},
};
}, []);

return (
<Block name="audio-tag">
{item.errors?.map((error: any, i: any) => (
Expand All @@ -163,45 +180,47 @@ const AudioUltraView: FC<AudioUltraProps> = ({ item }) => {
item.stageRef.current = el;
}}
/>
<Controls
position={controls.currentTime}
playing={controls.playing}
volume={controls.volume}
speed={controls.rate}
zoom={controls.zoom}
duration={controls.duration}
onPlay={() => controls.setPlaying(true)}
onPause={() => controls.setPlaying(false)}
allowFullscreen={false}
onVolumeChange={(vol) => controls.setVolume(vol)}
onStepBackward={() => {
waveform.current?.seekBackward(NORMALIZED_STEP);
waveform.current?.syncCursor();
}}
onStepForward={() => {
waveform.current?.seekForward(NORMALIZED_STEP);
waveform.current?.syncCursor();
}}
onPositionChange={(pos) => {
waveform.current?.seek(pos);
waveform.current?.syncCursor();
}}
onSpeedChange={(speed) => controls.setRate(speed)}
onZoom={(zoom) => controls.setZoom(zoom)}
amp={controls.amp}
onAmpChange={(amp) => controls.setAmp(amp)}
mediaType="audio"
toggleVisibility={(layerName: string, isVisible: boolean) => {
if (waveform.current) {
const layer = waveform.current?.getLayer(layerName);

if (layer) {
layer.setVisibility(isVisible);
<TimelineContextProvider value={contextValue}>
<Controls
position={controls.currentTime}
playing={controls.playing}
volume={controls.volume}
speed={controls.rate}
zoom={controls.zoom}
duration={controls.duration}
onPlay={() => controls.setPlaying(true)}
onPause={() => controls.setPlaying(false)}
allowFullscreen={false}
onVolumeChange={(vol) => controls.setVolume(vol)}
onStepBackward={() => {
waveform.current?.seekBackward(NORMALIZED_STEP);
waveform.current?.syncCursor();
}}
onStepForward={() => {
waveform.current?.seekForward(NORMALIZED_STEP);
waveform.current?.syncCursor();
}}
onPositionChange={(pos) => {
waveform.current?.seek(pos);
waveform.current?.syncCursor();
}}
onSpeedChange={(speed) => controls.setRate(speed)}
onZoom={(zoom) => controls.setZoom(zoom)}
amp={controls.amp}
onAmpChange={(amp) => controls.setAmp(amp)}
mediaType="audio"
toggleVisibility={(layerName: string, isVisible: boolean) => {
if (waveform.current) {
const layer = waveform.current?.getLayer(layerName);

if (layer) {
layer.setVisibility(isVisible);
}
}
}
}}
layerVisibility={controls.layerVisibility}
/>
}}
layerVisibility={controls.layerVisibility}
/>
</TimelineContextProvider>
</Block>
);
};
Expand Down
Loading