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

218 lines
5 KiB
TypeScript

/**
* Vue composable for handling YouTube-style keyboard shortcuts in video player
*/
import { ref } from 'vue';
import type { Ref } from 'vue';
import {
playPause,
rewind,
forward,
mute,
changeVolume,
changeSpeed,
frameStep,
seekToPercent,
seekBackward,
seekForward,
seekStart,
seekEnd,
fullscreen,
pictureInPicture,
toggleCaptions,
shouldHandleKeyboardShortcut,
modifierKey,
} from '~/utils/keyboard';
import type { KeyboardShortcutContext } from '~/types/video';
export interface UseKeyboardShortcutsOptions {
videoElement: Ref<HTMLVideoElement | null | undefined>;
enabled?: Ref<boolean>;
closePlayer?: () => void;
onHelpToggle?: () => void;
}
export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
const { videoElement, enabled, closePlayer, onHelpToggle } = options;
const showHelp = ref(false);
const handleKeyDown = async (event: KeyboardEvent) => {
// Don't handle if composable is disabled
if (enabled && !enabled.value) {
return;
}
// Don't handle if user is typing in an input
if (!shouldHandleKeyboardShortcut(event)) {
return;
}
const video = videoElement.value;
if (!video) {
return;
}
// Skip if modifier keys are pressed (except for shortcuts that need them)
if (modifierKey(event) && !['f', 'p', '?'].includes(event.key.toLowerCase())) {
return;
}
const key = event.key.toLowerCase();
const ctx: KeyboardShortcutContext = { video };
try {
switch (key) {
// Play/Pause
case ' ':
case 'k':
event.preventDefault();
playPause(ctx);
break;
// Rewind 10 seconds (J key)
case 'j':
event.preventDefault();
rewind(ctx, 10);
break;
// Forward 10 seconds (L key)
case 'l':
event.preventDefault();
forward(ctx, 10);
break;
// Seek backward 5 seconds (left arrow)
case 'arrowleft':
event.preventDefault();
seekBackward(ctx, 5);
break;
// Seek forward 5 seconds (right arrow)
case 'arrowright':
event.preventDefault();
seekForward(ctx, 5);
break;
// Increase volume (up arrow)
case 'arrowup':
event.preventDefault();
changeVolume(ctx, 0.1);
break;
// Decrease volume (down arrow)
case 'arrowdown':
event.preventDefault();
changeVolume(ctx, -0.1);
break;
// Mute/Unmute
case 'm':
event.preventDefault();
mute(ctx);
break;
// Toggle fullscreen
case 'f':
event.preventDefault();
fullscreen(video);
break;
// Picture-in-Picture
case 'p':
event.preventDefault();
await pictureInPicture(video);
break;
// Toggle captions
case 'c':
event.preventDefault();
toggleCaptions(video);
break;
// Frame advance (period key) / Increase playback speed (> or ')
case '.':
case "'": {
event.preventDefault();
if ('.' === key) {
frameStep(ctx, 'forward');
} else {
changeSpeed(ctx, 0.25);
}
break;
}
// Frame rewind (comma key) / Decrease playback speed (< or ;)
case ',':
case ';': {
event.preventDefault();
if (',' === key) {
frameStep(ctx, 'backward');
} else {
changeSpeed(ctx, -0.25);
}
break;
}
// Jump to percentage (0-9 keys)
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9': {
event.preventDefault();
const percent = parseInt(key) * 10;
seekToPercent(ctx, percent);
break;
}
// Jump to start (Home)
case 'home':
event.preventDefault();
seekStart(ctx);
break;
// Jump to end (End)
case 'end':
event.preventDefault();
seekEnd(ctx);
break;
// Show/hide help (Shift+/)
case '?':
case '/': {
event.preventDefault();
showHelp.value = !showHelp.value;
onHelpToggle?.();
break;
}
// Close player (Escape - already handled elsewhere but kept for completeness)
case 'escape':
event.preventDefault();
closePlayer?.();
break;
default:
// No action for unrecognized keys
break;
}
} catch (error) {
console.error('Error handling keyboard shortcut:', error);
}
};
const attach = () => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
};
return {
showHelp,
attach,
};
};