mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
300 lines
6.8 KiB
TypeScript
300 lines
6.8 KiB
TypeScript
/**
|
|
* Keyboard shortcuts configuration and utilities
|
|
*/
|
|
|
|
export type KeyboardModifier = "Shift" | "Alt";
|
|
|
|
export interface KeyboardShortcutConfig {
|
|
modifier?: KeyboardModifier;
|
|
key?: string;
|
|
}
|
|
|
|
export type KeyboardShortcuts = Record<string, KeyboardShortcutConfig>;
|
|
|
|
/**
|
|
* Shortcut IDs for customizable shortcuts
|
|
*/
|
|
export enum ShortcutId {
|
|
// Video playback
|
|
SKIP_FORWARD_5 = "skipForward5",
|
|
SKIP_BACKWARD_5 = "skipBackward5",
|
|
SKIP_FORWARD_10 = "skipForward10",
|
|
SKIP_BACKWARD_10 = "skipBackward10",
|
|
SKIP_FORWARD_1 = "skipForward1",
|
|
SKIP_BACKWARD_1 = "skipBackward1",
|
|
NEXT_EPISODE = "nextEpisode",
|
|
PREVIOUS_EPISODE = "previousEpisode",
|
|
|
|
// Jump to position
|
|
JUMP_TO_0 = "jumpTo0",
|
|
JUMP_TO_9 = "jumpTo9",
|
|
|
|
// Audio/Video
|
|
INCREASE_VOLUME = "increaseVolume",
|
|
DECREASE_VOLUME = "decreaseVolume",
|
|
MUTE = "mute",
|
|
TOGGLE_FULLSCREEN = "toggleFullscreen",
|
|
|
|
// Subtitles/Accessibility
|
|
TOGGLE_CAPTIONS = "toggleCaptions",
|
|
RANDOM_CAPTION = "randomCaption",
|
|
SYNC_SUBTITLES_EARLIER = "syncSubtitlesEarlier",
|
|
SYNC_SUBTITLES_LATER = "syncSubtitlesLater",
|
|
TOGGLE_NATIVE_SUBTITLES = "toggleNativeSubtitles",
|
|
|
|
// Interface
|
|
BARREL_ROLL = "barrelRoll",
|
|
}
|
|
|
|
/**
|
|
* Default keyboard shortcuts configuration
|
|
*/
|
|
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
|
[ShortcutId.SKIP_FORWARD_5]: { key: "ArrowRight" },
|
|
[ShortcutId.SKIP_BACKWARD_5]: { key: "ArrowLeft" },
|
|
[ShortcutId.SKIP_FORWARD_10]: { key: "L" },
|
|
[ShortcutId.SKIP_BACKWARD_10]: { key: "J" },
|
|
[ShortcutId.SKIP_FORWARD_1]: { key: "." },
|
|
[ShortcutId.SKIP_BACKWARD_1]: { key: "," },
|
|
[ShortcutId.NEXT_EPISODE]: { key: "P" },
|
|
[ShortcutId.PREVIOUS_EPISODE]: { key: "O" },
|
|
[ShortcutId.JUMP_TO_0]: { key: "0" },
|
|
[ShortcutId.JUMP_TO_9]: { key: "9" },
|
|
[ShortcutId.INCREASE_VOLUME]: { key: "ArrowUp" },
|
|
[ShortcutId.DECREASE_VOLUME]: { key: "ArrowDown" },
|
|
[ShortcutId.MUTE]: { key: "M" },
|
|
[ShortcutId.TOGGLE_FULLSCREEN]: { key: "F" },
|
|
[ShortcutId.TOGGLE_CAPTIONS]: { key: "C" },
|
|
[ShortcutId.RANDOM_CAPTION]: { modifier: "Shift", key: "C" },
|
|
[ShortcutId.SYNC_SUBTITLES_EARLIER]: { key: "[" },
|
|
[ShortcutId.SYNC_SUBTITLES_LATER]: { key: "]" },
|
|
[ShortcutId.TOGGLE_NATIVE_SUBTITLES]: { key: "S" },
|
|
[ShortcutId.BARREL_ROLL]: { key: "R" },
|
|
};
|
|
|
|
/**
|
|
* Locked shortcuts that cannot be customized
|
|
*/
|
|
export const LOCKED_SHORTCUTS = {
|
|
PLAY_PAUSE_SPACE: " ",
|
|
PLAY_PAUSE_K: "K",
|
|
MODAL_HOTKEY: "`",
|
|
ARROW_UP: "ArrowUp",
|
|
ARROW_DOWN: "ArrowDown",
|
|
ARROW_LEFT: "ArrowLeft",
|
|
ARROW_RIGHT: "ArrowRight",
|
|
ESCAPE: "Escape",
|
|
JUMP_TO_0: "0",
|
|
JUMP_TO_9: "9",
|
|
} as const;
|
|
|
|
/**
|
|
* Locked shortcut IDs that cannot be customized
|
|
*/
|
|
export const LOCKED_SHORTCUT_IDS: string[] = [
|
|
"playPause",
|
|
"playPauseAlt",
|
|
"skipForward5",
|
|
"skipBackward5",
|
|
"increaseVolume",
|
|
"decreaseVolume",
|
|
"modalHotkey",
|
|
"closeOverlay",
|
|
"jumpTo0",
|
|
"jumpTo9",
|
|
];
|
|
|
|
/**
|
|
* Check if a key is a number key (0-9)
|
|
*/
|
|
export function isNumberKey(key: string): boolean {
|
|
return /^[0-9]$/.test(key);
|
|
}
|
|
|
|
/**
|
|
* Key equivalence map for bidirectional mapping
|
|
* Maps keys that should be treated as equivalent (e.g., 1 and !)
|
|
*/
|
|
export const KEY_EQUIVALENCE_MAP: Record<string, string> = {
|
|
// Number keys and their shift equivalents
|
|
"1": "!",
|
|
"!": "1",
|
|
"2": "@",
|
|
"@": "2",
|
|
"3": "#",
|
|
"#": "3",
|
|
"4": "$",
|
|
$: "4",
|
|
"5": "%",
|
|
"%": "5",
|
|
"6": "^",
|
|
"^": "6",
|
|
"7": "&",
|
|
"&": "7",
|
|
"8": "*",
|
|
"*": "8",
|
|
"9": "(",
|
|
"(": "9",
|
|
"0": ")",
|
|
")": "0",
|
|
|
|
// Other symbol pairs
|
|
"-": "_",
|
|
_: "-",
|
|
"=": "+",
|
|
"+": "=",
|
|
"[": "{",
|
|
"{": "[",
|
|
"]": "}",
|
|
"}": "]",
|
|
"\\": "|",
|
|
"|": "\\",
|
|
";": ":",
|
|
":": ";",
|
|
"'": '"',
|
|
'"': "'",
|
|
",": "<",
|
|
"<": ",",
|
|
".": ">",
|
|
">": ".",
|
|
"/": "?",
|
|
"?": "/",
|
|
"`": "~",
|
|
"~": "`",
|
|
};
|
|
|
|
/**
|
|
* Get equivalent keys for a given key
|
|
*/
|
|
export function getEquivalentKeys(key: string): string[] {
|
|
const equivalent = KEY_EQUIVALENCE_MAP[key];
|
|
if (equivalent) {
|
|
return [key, equivalent];
|
|
}
|
|
return [key];
|
|
}
|
|
|
|
/**
|
|
* Normalize a key for comparison (handles case-insensitive matching)
|
|
*/
|
|
export function normalizeKey(key: string): string {
|
|
// For letter keys, use uppercase for consistency
|
|
if (/^[a-z]$/i.test(key)) {
|
|
return key.toUpperCase();
|
|
}
|
|
return key;
|
|
}
|
|
|
|
/**
|
|
* Check if two shortcut configs conflict
|
|
*/
|
|
export function checkShortcutConflict(
|
|
config1: KeyboardShortcutConfig | undefined,
|
|
config2: KeyboardShortcutConfig | undefined,
|
|
): boolean {
|
|
if (!config1 || !config2 || !config1.key || !config2.key) {
|
|
return false;
|
|
}
|
|
|
|
// Check if modifiers match
|
|
if (config1.modifier !== config2.modifier) {
|
|
return false;
|
|
}
|
|
|
|
// Check if keys match directly or are equivalent
|
|
const key1 = normalizeKey(config1.key);
|
|
const key2 = normalizeKey(config2.key);
|
|
|
|
if (key1 === key2) {
|
|
return true;
|
|
}
|
|
|
|
// Check equivalence
|
|
const equiv1 = getEquivalentKeys(key1);
|
|
const equiv2 = getEquivalentKeys(key2);
|
|
|
|
return equiv1.some((k1) => equiv2.includes(k1));
|
|
}
|
|
|
|
/**
|
|
* Find all conflicts in a shortcuts configuration
|
|
*/
|
|
export function findConflicts(
|
|
shortcuts: KeyboardShortcuts,
|
|
): Array<{ id1: string; id2: string }> {
|
|
const conflicts: Array<{ id1: string; id2: string }> = [];
|
|
const ids = Object.keys(shortcuts);
|
|
|
|
for (let i = 0; i < ids.length; i += 1) {
|
|
for (let j = i + 1; j < ids.length; j += 1) {
|
|
const id1 = ids[i];
|
|
const id2 = ids[j];
|
|
const config1 = shortcuts[id1];
|
|
const config2 = shortcuts[id2];
|
|
|
|
if (checkShortcutConflict(config1, config2)) {
|
|
conflicts.push({ id1, id2 });
|
|
}
|
|
}
|
|
}
|
|
|
|
return conflicts;
|
|
}
|
|
|
|
/**
|
|
* Check if a keyboard event matches a shortcut configuration
|
|
*/
|
|
export function matchesShortcut(
|
|
event: KeyboardEvent,
|
|
config: KeyboardShortcutConfig | undefined,
|
|
): boolean {
|
|
if (!config || !config.key) {
|
|
return false;
|
|
}
|
|
|
|
const eventKey = normalizeKey(event.key);
|
|
const configKey = normalizeKey(config.key);
|
|
|
|
// Check modifier match
|
|
if (config.modifier === "Shift" && !event.shiftKey) {
|
|
return false;
|
|
}
|
|
if (config.modifier === "Alt" && !event.altKey) {
|
|
return false;
|
|
}
|
|
// If no modifier specified, ensure no modifier is pressed (except ctrl/meta which we ignore)
|
|
if (!config.modifier && (event.shiftKey || event.altKey)) {
|
|
return false;
|
|
}
|
|
|
|
// Check key match (direct or equivalent)
|
|
if (eventKey === configKey) {
|
|
return true;
|
|
}
|
|
|
|
// Check equivalence
|
|
const equivKeys = getEquivalentKeys(configKey);
|
|
return equivKeys.includes(eventKey);
|
|
}
|
|
|
|
/**
|
|
* Get display name for a key
|
|
*/
|
|
export function getKeyDisplayName(key: string): string {
|
|
const displayNames: Record<string, string> = {
|
|
ArrowUp: "↑",
|
|
ArrowDown: "↓",
|
|
ArrowLeft: "←",
|
|
ArrowRight: "→",
|
|
" ": "Space",
|
|
};
|
|
|
|
return displayNames[key] || key;
|
|
}
|
|
|
|
/**
|
|
* Get display symbol for a modifier
|
|
*/
|
|
export function getModifierSymbol(modifier: KeyboardModifier): string {
|
|
return modifier === "Shift" ? "⇧" : "⌥";
|
|
}
|