ytptube/ui/app/composables/usePlayerShortcuts.ts
2026-05-05 23:01:50 +03:00

223 lines
5 KiB
TypeScript

import {
getCurrentScope,
onScopeDispose,
ref,
watch,
type MaybeRefOrGetter,
type Ref,
toValue,
} from 'vue';
import {
changeSpeed,
changeVolume,
forward,
frameStep,
hasModifierKey,
playPause,
rewind,
seekBackward,
seekEnd,
seekForward,
seekStart,
seekToPercent,
shouldHandleKeyboardShortcut,
toggleCaptions,
} from '~/utils/keyboard';
import type { KeyboardShortcutContext } from '~/types/video';
type UsePlayerShortcutsOptions = {
enabled: MaybeRefOrGetter<boolean>;
media: MaybeRefOrGetter<HTMLMediaElement | null>;
video: MaybeRefOrGetter<HTMLVideoElement | null>;
adjustVolume?: (delta: number) => void;
canToggleSubs: MaybeRefOrGetter<boolean>;
helpOpen?: Ref<boolean>;
toggleSubtitles: () => void;
toggleFullscreen: () => Promise<void> | void;
toggleMute?: () => void;
closePlayer?: () => void;
};
export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
const showHelp = options.helpOpen || ref(false);
async function handleKeyDown(event: KeyboardEvent) {
if (!toValue(options.enabled) || !shouldHandleKeyboardShortcut(event)) {
return;
}
const media = toValue(options.media);
if (!media) {
return;
}
const ctx: KeyboardShortcutContext = { video: media };
const key = event.key.toLowerCase();
if (hasModifierKey(event) && !['f', '?', '/'].includes(key)) {
return;
}
switch (key) {
case ' ':
case 'k':
event.preventDefault();
event.stopPropagation();
playPause(ctx);
break;
case 'j':
event.preventDefault();
event.stopPropagation();
rewind(ctx, 10);
break;
case 'l':
event.preventDefault();
event.stopPropagation();
forward(ctx, 10);
break;
case 'arrowleft':
event.preventDefault();
event.stopPropagation();
seekBackward(ctx, 5);
break;
case 'arrowright':
event.preventDefault();
event.stopPropagation();
seekForward(ctx, 5);
break;
case 'home':
event.preventDefault();
event.stopPropagation();
seekStart(ctx);
break;
case 'end':
event.preventDefault();
event.stopPropagation();
seekEnd(ctx);
break;
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9': {
event.preventDefault();
event.stopPropagation();
seekToPercent(ctx, parseInt(key, 10) * 10);
break;
}
case 'arrowup':
event.preventDefault();
event.stopPropagation();
if (options.adjustVolume) {
options.adjustVolume(0.1);
break;
}
changeVolume(ctx, 0.1);
break;
case 'arrowdown':
event.preventDefault();
event.stopPropagation();
if (options.adjustVolume) {
options.adjustVolume(-0.1);
break;
}
changeVolume(ctx, -0.1);
break;
case 'm':
event.preventDefault();
event.stopPropagation();
if (options.toggleMute) {
options.toggleMute();
break;
}
media.muted = !media.muted;
break;
case ';':
event.preventDefault();
event.stopPropagation();
changeSpeed(ctx, -0.25);
break;
case "'":
event.preventDefault();
event.stopPropagation();
changeSpeed(ctx, 0.25);
break;
case ',':
event.preventDefault();
event.stopPropagation();
frameStep(ctx, 'backward');
break;
case '.':
event.preventDefault();
event.stopPropagation();
frameStep(ctx, 'forward');
break;
case 'f':
event.preventDefault();
event.stopPropagation();
await options.toggleFullscreen();
break;
case 'c': {
if (!toValue(options.canToggleSubs)) {
return;
}
event.preventDefault();
event.stopPropagation();
const video = toValue(options.video);
if (video?.textTracks.length) {
toggleCaptions(video);
}
options.toggleSubtitles();
break;
}
case '?':
case '/':
event.preventDefault();
event.stopPropagation();
showHelp.value = !showHelp.value;
break;
case 'escape':
event.preventDefault();
event.stopPropagation();
if (showHelp.value) {
showHelp.value = false;
break;
}
options.closePlayer?.();
break;
default:
break;
}
}
document.addEventListener('keydown', handleKeyDown, { capture: true });
watch(
() => toValue(options.enabled),
(enabled) => {
if (!enabled) {
showHelp.value = false;
}
},
);
if (getCurrentScope()) {
onScopeDispose(() => {
document.removeEventListener('keydown', handleKeyDown, { capture: true });
});
}
return {
showHelp,
};
}