pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

customize keyboard shortcuts!

Pas cb504ccf 71852409

+1303 -197
+18 -4
src/assets/locales/en.json
··· 255 255 "skipBackward5": "Skip backward 5 seconds", 256 256 "skipBackward10": "Skip backward 10 seconds", 257 257 "skipForward10": "Skip forward 10 seconds", 258 - "skipForward1": "Skip forward 1 second (when paused)", 259 - "skipBackward1": "Skip backward 1 second (when paused)", 258 + "skipForward1": "Skip forward 1 second", 259 + "skipBackward1": "Skip backward 1 second", 260 260 "jumpTo0": "Jump to 0% (beginning)", 261 261 "jumpTo9": "Jump to 90%", 262 262 "increaseVolume": "Increase volume", ··· 277 277 }, 278 278 "conditions": { 279 279 "notInWatchParty": "Not in watch party", 280 - "whenPaused": "When paused", 281 280 "showsOnly": "Shows only" 282 - } 281 + }, 282 + "editInSettings": "Edit keyboard commands from settings", 283 + "clickToEdit": "Click on a key badge to edit it", 284 + "conflict": "conflict", 285 + "conflicts": "conflicts", 286 + "detected": "detected", 287 + "resetAllToDefault": "Reset All to Default", 288 + "pressKey": "Press a key...", 289 + "none": "None", 290 + "save": "Save", 291 + "cancel": "Cancel", 292 + "saveChanges": "Save Changes", 293 + "resetToDefault": "Reset to default" 283 294 } 284 295 }, 285 296 "home": { ··· 1175 1186 "doubleClickToSeek": "Double tap to seek", 1176 1187 "doubleClickToSeekDescription": "Double tap on the left or right side of the player to seek 10 seconds forward or backward.", 1177 1188 "doubleClickToSeekLabel": "Enable double tap to seek", 1189 + "keyboardShortcuts": "Keyboard Shortcuts", 1190 + "keyboardShortcutsDescription": "Customize the keyboard shortcuts for the application. Hold ` to show this help anytime", 1191 + "keyboardShortcutsLabel": "Customize Keyboard Shortcuts", 1178 1192 "sourceOrder": "Reordering sources", 1179 1193 "sourceOrderDescription": "Drag and drop to reorder sources. This will determine the order in which sources are checked for the media you are trying to watch. If a source is greyed out, it means the <bold>extension</bold> is required for that source. <br><br> <strong>(The default order is best for most users)</strong>", 1180 1194 "sourceOrderEnableLabel": "Custom source order",
+3
src/backend/accounts/settings.ts
··· 2 2 3 3 import { getAuthHeaders } from "@/backend/accounts/auth"; 4 4 import { AccountWithToken } from "@/stores/auth"; 5 + import { KeyboardShortcuts } from "@/utils/keyboardShortcuts"; 5 6 6 7 export interface SettingsInput { 7 8 applicationLanguage?: string; ··· 36 37 manualSourceSelection?: boolean; 37 38 enableDoubleClickToSeek?: boolean; 38 39 enableAutoResumeOnPlaybackError?: boolean; 40 + keyboardShortcuts?: KeyboardShortcuts; 39 41 } 40 42 41 43 export interface SettingsResponse { ··· 71 73 manualSourceSelection?: boolean; 72 74 enableDoubleClickToSeek?: boolean; 73 75 enableAutoResumeOnPlaybackError?: boolean; 76 + keyboardShortcuts?: KeyboardShortcuts; 74 77 } 75 78 76 79 export function updateSettings(
+518
src/components/overlays/KeyboardCommandsEditModal.tsx
··· 1 + import { ReactNode, useCallback, useEffect, useState } from "react"; 2 + import { useTranslation } from "react-i18next"; 3 + 4 + import { updateSettings } from "@/backend/accounts/settings"; 5 + import { Button } from "@/components/buttons/Button"; 6 + import { Dropdown } from "@/components/form/Dropdown"; 7 + import { Icon, Icons } from "@/components/Icon"; 8 + import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; 9 + import { Heading2 } from "@/components/utils/Text"; 10 + import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 11 + import { useAuthStore } from "@/stores/auth"; 12 + import { useOverlayStack } from "@/stores/interface/overlayStack"; 13 + import { usePreferencesStore } from "@/stores/preferences"; 14 + import { 15 + DEFAULT_KEYBOARD_SHORTCUTS, 16 + KeyboardModifier, 17 + KeyboardShortcutConfig, 18 + KeyboardShortcuts, 19 + LOCKED_SHORTCUT_IDS, 20 + ShortcutId, 21 + findConflicts, 22 + getKeyDisplayName, 23 + getModifierSymbol, 24 + isNumberKey, 25 + } from "@/utils/keyboardShortcuts"; 26 + 27 + interface KeyboardShortcut { 28 + id: ShortcutId; 29 + config: KeyboardShortcutConfig; 30 + description: string; 31 + condition?: string; 32 + } 33 + 34 + interface ShortcutGroup { 35 + title: string; 36 + shortcuts: KeyboardShortcut[]; 37 + } 38 + 39 + function KeyBadge({ 40 + config, 41 + children, 42 + onClick, 43 + editing, 44 + hasConflict, 45 + }: { 46 + config?: KeyboardShortcutConfig; 47 + children: ReactNode; 48 + onClick?: () => void; 49 + editing?: boolean; 50 + hasConflict?: boolean; 51 + }) { 52 + const modifier = config?.modifier; 53 + 54 + return ( 55 + <kbd 56 + className={` 57 + relative inline-flex items-center justify-center min-w-[2rem] h-8 px-2 text-sm font-mono bg-gray-800 text-gray-200 rounded border shadow-sm 58 + ${onClick ? "cursor-pointer hover:bg-gray-700" : ""} 59 + ${editing ? "ring-2 ring-blue-500" : ""} 60 + ${hasConflict ? "border-red-500 bg-red-900/20" : "border-gray-600"} 61 + `} 62 + onClick={onClick} 63 + > 64 + {children} 65 + {modifier && ( 66 + <span className="absolute -top-1 -right-1 text-xs bg-blue-600 text-white rounded-full w-4 h-4 flex items-center justify-center"> 67 + {getModifierSymbol(modifier)} 68 + </span> 69 + )} 70 + </kbd> 71 + ); 72 + } 73 + 74 + const getShortcutGroups = ( 75 + t: (key: string) => string, 76 + shortcuts: KeyboardShortcuts, 77 + ): ShortcutGroup[] => { 78 + return [ 79 + { 80 + title: t("global.keyboardShortcuts.groups.videoPlayback"), 81 + shortcuts: [ 82 + { 83 + id: ShortcutId.SKIP_FORWARD_5, 84 + config: shortcuts[ShortcutId.SKIP_FORWARD_5], 85 + description: t("global.keyboardShortcuts.shortcuts.skipForward5"), 86 + }, 87 + { 88 + id: ShortcutId.SKIP_BACKWARD_5, 89 + config: shortcuts[ShortcutId.SKIP_BACKWARD_5], 90 + description: t("global.keyboardShortcuts.shortcuts.skipBackward5"), 91 + }, 92 + { 93 + id: ShortcutId.SKIP_FORWARD_10, 94 + config: shortcuts[ShortcutId.SKIP_FORWARD_10], 95 + description: t("global.keyboardShortcuts.shortcuts.skipForward10"), 96 + }, 97 + { 98 + id: ShortcutId.SKIP_BACKWARD_10, 99 + config: shortcuts[ShortcutId.SKIP_BACKWARD_10], 100 + description: t("global.keyboardShortcuts.shortcuts.skipBackward10"), 101 + }, 102 + { 103 + id: ShortcutId.SKIP_FORWARD_1, 104 + config: shortcuts[ShortcutId.SKIP_FORWARD_1], 105 + description: t("global.keyboardShortcuts.shortcuts.skipForward1"), 106 + }, 107 + { 108 + id: ShortcutId.SKIP_BACKWARD_1, 109 + config: shortcuts[ShortcutId.SKIP_BACKWARD_1], 110 + description: t("global.keyboardShortcuts.shortcuts.skipBackward1"), 111 + }, 112 + { 113 + id: ShortcutId.NEXT_EPISODE, 114 + config: shortcuts[ShortcutId.NEXT_EPISODE], 115 + description: t("global.keyboardShortcuts.shortcuts.nextEpisode"), 116 + condition: t("global.keyboardShortcuts.conditions.showsOnly"), 117 + }, 118 + { 119 + id: ShortcutId.PREVIOUS_EPISODE, 120 + config: shortcuts[ShortcutId.PREVIOUS_EPISODE], 121 + description: t("global.keyboardShortcuts.shortcuts.previousEpisode"), 122 + condition: t("global.keyboardShortcuts.conditions.showsOnly"), 123 + }, 124 + ], 125 + }, 126 + { 127 + title: t("global.keyboardShortcuts.groups.audioVideo"), 128 + shortcuts: [ 129 + { 130 + id: ShortcutId.MUTE, 131 + config: shortcuts[ShortcutId.MUTE], 132 + description: t("global.keyboardShortcuts.shortcuts.mute"), 133 + }, 134 + { 135 + id: ShortcutId.TOGGLE_FULLSCREEN, 136 + config: shortcuts[ShortcutId.TOGGLE_FULLSCREEN], 137 + description: t("global.keyboardShortcuts.shortcuts.toggleFullscreen"), 138 + }, 139 + ], 140 + }, 141 + { 142 + title: t("global.keyboardShortcuts.groups.subtitlesAccessibility"), 143 + shortcuts: [ 144 + { 145 + id: ShortcutId.TOGGLE_CAPTIONS, 146 + config: shortcuts[ShortcutId.TOGGLE_CAPTIONS], 147 + description: t("global.keyboardShortcuts.shortcuts.toggleCaptions"), 148 + }, 149 + { 150 + id: ShortcutId.RANDOM_CAPTION, 151 + config: shortcuts[ShortcutId.RANDOM_CAPTION], 152 + description: t("global.keyboardShortcuts.shortcuts.randomCaption"), 153 + }, 154 + { 155 + id: ShortcutId.SYNC_SUBTITLES_EARLIER, 156 + config: shortcuts[ShortcutId.SYNC_SUBTITLES_EARLIER], 157 + description: t( 158 + "global.keyboardShortcuts.shortcuts.syncSubtitlesEarlier", 159 + ), 160 + }, 161 + { 162 + id: ShortcutId.SYNC_SUBTITLES_LATER, 163 + config: shortcuts[ShortcutId.SYNC_SUBTITLES_LATER], 164 + description: t( 165 + "global.keyboardShortcuts.shortcuts.syncSubtitlesLater", 166 + ), 167 + }, 168 + ], 169 + }, 170 + { 171 + title: t("global.keyboardShortcuts.groups.interface"), 172 + shortcuts: [ 173 + { 174 + id: ShortcutId.BARREL_ROLL, 175 + config: shortcuts[ShortcutId.BARREL_ROLL], 176 + description: t("global.keyboardShortcuts.shortcuts.barrelRoll"), 177 + }, 178 + ], 179 + }, 180 + ]; 181 + }; 182 + 183 + interface KeyboardCommandsEditModalProps { 184 + id: string; 185 + } 186 + 187 + export function KeyboardCommandsEditModal({ 188 + id, 189 + }: KeyboardCommandsEditModalProps) { 190 + const { t } = useTranslation(); 191 + const account = useAuthStore((s) => s.account); 192 + const backendUrl = useBackendUrl(); 193 + const { hideModal } = useOverlayStack(); 194 + const modal = useModal(id); 195 + const keyboardShortcuts = usePreferencesStore((s) => s.keyboardShortcuts); 196 + const setKeyboardShortcuts = usePreferencesStore( 197 + (s) => s.setKeyboardShortcuts, 198 + ); 199 + 200 + const [editingShortcuts, setEditingShortcuts] = 201 + useState<KeyboardShortcuts>(keyboardShortcuts); 202 + const [editingId, setEditingId] = useState<ShortcutId | null>(null); 203 + const [editingModifier, setEditingModifier] = useState<KeyboardModifier | "">( 204 + "", 205 + ); 206 + const [editingKey, setEditingKey] = useState<string>(""); 207 + const [isCapturingKey, setIsCapturingKey] = useState(false); 208 + 209 + // Cancel any active editing when modal closes 210 + useEffect(() => { 211 + if (!modal.isShown) { 212 + setEditingId(null); 213 + setEditingModifier(""); 214 + setEditingKey(""); 215 + setIsCapturingKey(false); 216 + } 217 + }, [modal.isShown]); 218 + 219 + const shortcutGroups = getShortcutGroups(t, editingShortcuts).map( 220 + (group) => ({ 221 + ...group, 222 + shortcuts: group.shortcuts.filter( 223 + (s) => !LOCKED_SHORTCUT_IDS.includes(s.id), 224 + ), 225 + }), 226 + ); 227 + const conflicts = findConflicts(editingShortcuts); 228 + const conflictIds = new Set<string>(); 229 + conflicts.forEach((conflict: { id1: string; id2: string }) => { 230 + conflictIds.add(conflict.id1); 231 + conflictIds.add(conflict.id2); 232 + }); 233 + 234 + const modifierOptions = [ 235 + { id: "", name: "None" }, 236 + { id: "Shift", name: "Shift" }, 237 + { id: "Alt", name: "Alt" }, 238 + ]; 239 + 240 + const handleStartEdit = useCallback( 241 + (shortcutId: ShortcutId) => { 242 + const config = editingShortcuts[shortcutId]; 243 + setEditingId(shortcutId); 244 + setEditingModifier(config?.modifier || ""); 245 + setEditingKey(config?.key || ""); 246 + setIsCapturingKey(true); 247 + }, 248 + [editingShortcuts], 249 + ); 250 + 251 + const handleCancelEdit = useCallback(() => { 252 + setEditingId(null); 253 + setEditingModifier(""); 254 + setEditingKey(""); 255 + setIsCapturingKey(false); 256 + }, []); 257 + 258 + const handleKeyCapture = useCallback( 259 + (event: KeyboardEvent) => { 260 + if (!isCapturingKey || !editingId) return; 261 + 262 + // Don't capture modifier keys alone 263 + if ( 264 + event.key === "Shift" || 265 + event.key === "Alt" || 266 + event.key === "Control" || 267 + event.key === "Meta" || 268 + event.key === "Escape" 269 + ) { 270 + return; 271 + } 272 + 273 + // Block number keys (0-9) - they're reserved for progress skipping 274 + if (isNumberKey(event.key)) { 275 + event.preventDefault(); 276 + event.stopPropagation(); 277 + setIsCapturingKey(false); 278 + return; 279 + } 280 + 281 + event.preventDefault(); 282 + event.stopPropagation(); 283 + 284 + setEditingKey(event.key); 285 + setIsCapturingKey(false); 286 + }, 287 + [isCapturingKey, editingId], 288 + ); 289 + 290 + useEffect(() => { 291 + if (isCapturingKey) { 292 + const handleEscape = (event: KeyboardEvent) => { 293 + if (event.key === "Escape") { 294 + handleCancelEdit(); 295 + } 296 + }; 297 + window.addEventListener("keydown", handleKeyCapture); 298 + window.addEventListener("keydown", handleEscape); 299 + return () => { 300 + window.removeEventListener("keydown", handleKeyCapture); 301 + window.removeEventListener("keydown", handleEscape); 302 + }; 303 + } 304 + }, [isCapturingKey, handleKeyCapture, handleCancelEdit]); 305 + 306 + const handleSaveEdit = useCallback(() => { 307 + if (!editingId) return; 308 + 309 + const newConfig: KeyboardShortcutConfig = { 310 + modifier: editingModifier || undefined, 311 + key: editingKey || undefined, 312 + }; 313 + 314 + setEditingShortcuts((prev: KeyboardShortcuts) => ({ 315 + ...prev, 316 + [editingId]: newConfig, 317 + })); 318 + 319 + handleCancelEdit(); 320 + }, [editingId, editingModifier, editingKey, handleCancelEdit]); 321 + 322 + const handleResetShortcut = useCallback((shortcutId: ShortcutId) => { 323 + setEditingShortcuts((prev: KeyboardShortcuts) => ({ 324 + ...prev, 325 + [shortcutId]: DEFAULT_KEYBOARD_SHORTCUTS[shortcutId], 326 + })); 327 + }, []); 328 + 329 + const handleResetAll = useCallback(() => { 330 + setEditingShortcuts(DEFAULT_KEYBOARD_SHORTCUTS); 331 + }, []); 332 + 333 + const handleSave = useCallback(async () => { 334 + setKeyboardShortcuts(editingShortcuts); 335 + 336 + if (account && backendUrl) { 337 + try { 338 + await updateSettings(backendUrl, account, { 339 + keyboardShortcuts: editingShortcuts, 340 + }); 341 + } catch (error) { 342 + console.error("Failed to save keyboard shortcuts:", error); 343 + } 344 + } 345 + 346 + hideModal(id); 347 + }, [ 348 + editingShortcuts, 349 + account, 350 + backendUrl, 351 + setKeyboardShortcuts, 352 + hideModal, 353 + id, 354 + ]); 355 + 356 + const handleCancel = useCallback(() => { 357 + hideModal(id); 358 + }, [hideModal, id]); 359 + 360 + return ( 361 + <Modal id={id}> 362 + <ModalCard className="!max-w-2xl"> 363 + <div className="space-y-6"> 364 + <div className="text-center"> 365 + <Heading2 className="!mt-0 !mb-2"> 366 + {t("global.keyboardShortcuts.title")} 367 + </Heading2> 368 + <p className="text-type-secondary text-sm"> 369 + {t("global.keyboardShortcuts.clickToEdit")} 370 + </p> 371 + </div> 372 + 373 + <div className="flex flex-grow justify-between items-center gap-2"> 374 + {conflicts.length > 0 ? ( 375 + <p className="text-red-400 text-sm"> 376 + {conflicts.length}{" "} 377 + {conflicts.length > 1 378 + ? t("global.keyboardShortcuts.conflicts") 379 + : t("global.keyboardShortcuts.conflict")}{" "} 380 + {t("global.keyboardShortcuts.detected")} 381 + </p> 382 + ) : ( 383 + <div /> // Empty div to take up space 384 + )} 385 + <Button theme="secondary" onClick={handleResetAll}> 386 + <Icon icon={Icons.RELOAD} className="mr-2" /> 387 + {t("global.keyboardShortcuts.resetAllToDefault")} 388 + </Button> 389 + </div> 390 + 391 + <div className="space-y-6 max-h-[60vh] overflow-y-auto"> 392 + {shortcutGroups.map((group) => ( 393 + <div key={group.title} className="space-y-3"> 394 + <h3 className="text-lg font-semibold text-white border-b border-gray-700 pb-2"> 395 + {group.title} 396 + </h3> 397 + <div className="space-y-2"> 398 + {group.shortcuts.map((shortcut) => { 399 + const isEditing = editingId === shortcut.id; 400 + const hasConflict = conflictIds.has(shortcut.id); 401 + const config = editingShortcuts[shortcut.id]; 402 + 403 + return ( 404 + <div 405 + key={shortcut.id} 406 + className="flex items-center justify-between py-1" 407 + > 408 + <div className="flex items-center gap-3 flex-1"> 409 + {isEditing ? ( 410 + <div className="flex items-center justify-between w-full gap-2"> 411 + <div className="flex items-center gap-2"> 412 + <Dropdown 413 + selectedItem={ 414 + modifierOptions.find( 415 + (opt) => opt.id === editingModifier, 416 + ) || modifierOptions[0] 417 + } 418 + setSelectedItem={(item) => 419 + setEditingModifier( 420 + item.id as KeyboardModifier | "", 421 + ) 422 + } 423 + options={modifierOptions} 424 + className="w-32 !my-1" 425 + /> 426 + <KeyBadge 427 + config={ 428 + editingKey 429 + ? { 430 + modifier: 431 + editingModifier || undefined, 432 + key: editingKey, 433 + } 434 + : undefined 435 + } 436 + editing 437 + > 438 + {isCapturingKey 439 + ? t("global.keyboardShortcuts.pressKey") 440 + : editingKey 441 + ? getKeyDisplayName(editingKey) 442 + : t("global.keyboardShortcuts.none")} 443 + </KeyBadge> 444 + </div> 445 + <div className="flex items-center gap-2"> 446 + <Button 447 + theme="secondary" 448 + onClick={handleSaveEdit} 449 + className="px-2 py-1 text-xs" 450 + > 451 + {t("global.keyboardShortcuts.save")} 452 + </Button> 453 + <Button 454 + theme="secondary" 455 + onClick={handleCancelEdit} 456 + className="px-2 py-1 text-xs" 457 + > 458 + {t("global.keyboardShortcuts.cancel")} 459 + </Button> 460 + </div> 461 + </div> 462 + ) : ( 463 + <> 464 + <KeyBadge 465 + config={config} 466 + onClick={() => handleStartEdit(shortcut.id)} 467 + hasConflict={hasConflict} 468 + > 469 + {config?.key 470 + ? getKeyDisplayName(config.key) 471 + : t("global.keyboardShortcuts.none")} 472 + </KeyBadge> 473 + <span className="text-type-secondary"> 474 + {shortcut.description} 475 + </span> 476 + </> 477 + )} 478 + </div> 479 + <div className="flex items-center gap-2"> 480 + {shortcut.condition && !isEditing && ( 481 + <span className="text-xs text-gray-400 italic"> 482 + {shortcut.condition} 483 + </span> 484 + )} 485 + {!isEditing && ( 486 + <button 487 + type="button" 488 + onClick={() => handleResetShortcut(shortcut.id)} 489 + className="text-type-secondary hover:text-white transition-colors" 490 + title={t( 491 + "global.keyboardShortcuts.resetToDefault", 492 + )} 493 + > 494 + <Icon icon={Icons.RELOAD} /> 495 + </button> 496 + )} 497 + </div> 498 + </div> 499 + ); 500 + })} 501 + </div> 502 + </div> 503 + ))} 504 + </div> 505 + 506 + <div className="flex justify-end gap-3 pt-4 border-t border-gray-700"> 507 + <Button theme="secondary" onClick={handleCancel}> 508 + {t("global.keyboardShortcuts.cancel")} 509 + </Button> 510 + <Button theme="purple" onClick={handleSave}> 511 + {t("global.keyboardShortcuts.saveChanges")} 512 + </Button> 513 + </div> 514 + </div> 515 + </ModalCard> 516 + </Modal> 517 + ); 518 + }
+222 -152
src/components/overlays/KeyboardCommandsModal.tsx
··· 1 1 import { ReactNode } from "react"; 2 2 import { useTranslation } from "react-i18next"; 3 + import { useNavigate } from "react-router-dom"; 3 4 4 5 import { Modal, ModalCard } from "@/components/overlays/Modal"; 5 6 import { Heading2 } from "@/components/utils/Text"; 7 + import { usePreferencesStore } from "@/stores/preferences"; 8 + import { 9 + DEFAULT_KEYBOARD_SHORTCUTS, 10 + KeyboardShortcutConfig, 11 + ShortcutId, 12 + getKeyDisplayName, 13 + getModifierSymbol, 14 + } from "@/utils/keyboardShortcuts"; 6 15 7 16 interface KeyboardShortcut { 8 17 key: string; 9 18 description: string; 10 19 condition?: string; 20 + config?: KeyboardShortcutConfig; 11 21 } 12 22 13 23 interface ShortcutGroup { ··· 15 25 shortcuts: KeyboardShortcut[]; 16 26 } 17 27 18 - const getShortcutGroups = (t: (key: string) => string): ShortcutGroup[] => [ 19 - { 20 - title: t("global.keyboardShortcuts.groups.videoPlayback"), 21 - shortcuts: [ 22 - { 23 - key: "Space", 24 - description: t("global.keyboardShortcuts.shortcuts.playPause"), 25 - }, 26 - { 27 - key: "K", 28 - description: t("global.keyboardShortcuts.shortcuts.playPauseAlt"), 29 - }, 30 - { 31 - key: "→", 32 - description: t("global.keyboardShortcuts.shortcuts.skipForward5"), 33 - }, 34 - { 35 - key: "←", 36 - description: t("global.keyboardShortcuts.shortcuts.skipBackward5"), 37 - }, 38 - { 39 - key: "J", 40 - description: t("global.keyboardShortcuts.shortcuts.skipBackward10"), 41 - }, 42 - { 43 - key: "L", 44 - description: t("global.keyboardShortcuts.shortcuts.skipForward10"), 45 - }, 46 - { 47 - key: ".", 48 - description: t("global.keyboardShortcuts.shortcuts.skipForward1"), 49 - condition: t("global.keyboardShortcuts.conditions.whenPaused"), 50 - }, 51 - { 52 - key: ",", 53 - description: t("global.keyboardShortcuts.shortcuts.skipBackward1"), 54 - condition: t("global.keyboardShortcuts.conditions.whenPaused"), 55 - }, 56 - { 57 - key: "P", 58 - description: t("global.keyboardShortcuts.shortcuts.nextEpisode"), 59 - condition: t("global.keyboardShortcuts.conditions.showsOnly"), 60 - }, 61 - { 62 - key: "O", 63 - description: t("global.keyboardShortcuts.shortcuts.previousEpisode"), 64 - condition: t("global.keyboardShortcuts.conditions.showsOnly"), 65 - }, 66 - ], 67 - }, 68 - { 69 - title: t("global.keyboardShortcuts.groups.jumpToPosition"), 70 - shortcuts: [ 71 - { 72 - key: "0", 73 - description: t("global.keyboardShortcuts.shortcuts.jumpTo0"), 74 - }, 75 - { 76 - key: "9", 77 - description: t("global.keyboardShortcuts.shortcuts.jumpTo9"), 78 - }, 79 - ], 80 - }, 81 - { 82 - title: t("global.keyboardShortcuts.groups.audioVideo"), 83 - shortcuts: [ 84 - { 85 - key: "↑", 86 - description: t("global.keyboardShortcuts.shortcuts.increaseVolume"), 87 - }, 88 - { 89 - key: "↓", 90 - description: t("global.keyboardShortcuts.shortcuts.decreaseVolume"), 91 - }, 92 - { key: "M", description: t("global.keyboardShortcuts.shortcuts.mute") }, 93 - { 94 - key: ">/", 95 - description: t("global.keyboardShortcuts.shortcuts.changeSpeed"), 96 - condition: t("global.keyboardShortcuts.conditions.notInWatchParty"), 97 - }, 98 - { 99 - key: "F", 100 - description: t("global.keyboardShortcuts.shortcuts.toggleFullscreen"), 101 - }, 102 - ], 103 - }, 104 - { 105 - title: t("global.keyboardShortcuts.groups.subtitlesAccessibility"), 106 - shortcuts: [ 107 - { 108 - key: "C", 109 - description: t("global.keyboardShortcuts.shortcuts.toggleCaptions"), 110 - }, 111 - { 112 - key: "Shift+C", 113 - description: t("global.keyboardShortcuts.shortcuts.randomCaption"), 114 - }, 115 - { 116 - key: "[", 117 - description: t( 118 - "global.keyboardShortcuts.shortcuts.syncSubtitlesEarlier", 119 - ), 120 - }, 121 - { 122 - key: "]", 123 - description: t("global.keyboardShortcuts.shortcuts.syncSubtitlesLater"), 124 - }, 125 - ], 126 - }, 127 - { 128 - title: t("global.keyboardShortcuts.groups.interface"), 129 - shortcuts: [ 130 - { 131 - key: "R", 132 - description: t("global.keyboardShortcuts.shortcuts.barrelRoll"), 133 - }, 134 - { 135 - key: "Escape", 136 - description: t("global.keyboardShortcuts.shortcuts.closeOverlay"), 137 - }, 138 - { 139 - key: "Shift", 140 - description: t("global.keyboardShortcuts.shortcuts.copyLinkWithTime"), 141 - }, 142 - { 143 - key: "Shift", 144 - description: t("global.keyboardShortcuts.shortcuts.widescreenMode"), 145 - }, 146 - ], 147 - }, 148 - ]; 28 + function KeyBadge({ 29 + config, 30 + children, 31 + }: { 32 + config?: KeyboardShortcutConfig; 33 + children: ReactNode; 34 + }) { 35 + const modifier = config?.modifier; 149 36 150 - function KeyBadge({ children }: { children: ReactNode }) { 151 37 return ( 152 - <kbd className="inline-flex items-center justify-center min-w-[2rem] h-8 px-2 text-sm font-mono bg-gray-800 text-gray-200 rounded border border-gray-600 shadow-sm"> 38 + <kbd className="relative inline-flex items-center justify-center min-w-[2rem] h-8 px-2 text-sm font-mono bg-gray-800 text-gray-200 rounded border border-gray-600 shadow-sm"> 153 39 {children} 40 + {modifier && ( 41 + <span className="absolute -top-1 -right-1 text-xs bg-blue-600 text-white rounded-full w-4 h-4 flex items-center justify-center"> 42 + {getModifierSymbol(modifier)} 43 + </span> 44 + )} 154 45 </kbd> 155 46 ); 156 47 } 157 48 49 + const getShortcutGroups = ( 50 + t: (key: string) => string, 51 + shortcuts: Record<string, KeyboardShortcutConfig>, 52 + ): ShortcutGroup[] => { 53 + // Merge user shortcuts with defaults (user shortcuts take precedence) 54 + const mergedShortcuts = { 55 + ...DEFAULT_KEYBOARD_SHORTCUTS, 56 + ...shortcuts, 57 + }; 58 + 59 + const getDisplayKey = (shortcutId: ShortcutId): string => { 60 + const config = mergedShortcuts[shortcutId]; 61 + if (!config?.key) return ""; 62 + return getKeyDisplayName(config.key); 63 + }; 64 + 65 + const getConfig = ( 66 + shortcutId: ShortcutId, 67 + ): KeyboardShortcutConfig | undefined => { 68 + return mergedShortcuts[shortcutId]; 69 + }; 70 + 71 + return [ 72 + { 73 + title: t("global.keyboardShortcuts.groups.videoPlayback"), 74 + shortcuts: [ 75 + { 76 + key: "Space", 77 + description: t("global.keyboardShortcuts.shortcuts.playPause"), 78 + }, 79 + { 80 + key: "K", 81 + description: t("global.keyboardShortcuts.shortcuts.playPauseAlt"), 82 + }, 83 + { 84 + key: getDisplayKey(ShortcutId.SKIP_FORWARD_5) || "→", 85 + description: t("global.keyboardShortcuts.shortcuts.skipForward5"), 86 + config: getConfig(ShortcutId.SKIP_FORWARD_5), 87 + }, 88 + { 89 + key: getDisplayKey(ShortcutId.SKIP_BACKWARD_5) || "←", 90 + description: t("global.keyboardShortcuts.shortcuts.skipBackward5"), 91 + config: getConfig(ShortcutId.SKIP_BACKWARD_5), 92 + }, 93 + { 94 + key: getDisplayKey(ShortcutId.SKIP_BACKWARD_10) || "J", 95 + description: t("global.keyboardShortcuts.shortcuts.skipBackward10"), 96 + config: getConfig(ShortcutId.SKIP_BACKWARD_10), 97 + }, 98 + { 99 + key: getDisplayKey(ShortcutId.SKIP_FORWARD_10) || "L", 100 + description: t("global.keyboardShortcuts.shortcuts.skipForward10"), 101 + config: getConfig(ShortcutId.SKIP_FORWARD_10), 102 + }, 103 + { 104 + key: getDisplayKey(ShortcutId.SKIP_FORWARD_1) || ".", 105 + description: t("global.keyboardShortcuts.shortcuts.skipForward1"), 106 + config: getConfig(ShortcutId.SKIP_FORWARD_1), 107 + }, 108 + { 109 + key: getDisplayKey(ShortcutId.SKIP_BACKWARD_1) || ",", 110 + description: t("global.keyboardShortcuts.shortcuts.skipBackward1"), 111 + config: getConfig(ShortcutId.SKIP_BACKWARD_1), 112 + }, 113 + { 114 + key: getDisplayKey(ShortcutId.NEXT_EPISODE) || "P", 115 + description: t("global.keyboardShortcuts.shortcuts.nextEpisode"), 116 + condition: t("global.keyboardShortcuts.conditions.showsOnly"), 117 + config: getConfig(ShortcutId.NEXT_EPISODE), 118 + }, 119 + { 120 + key: getDisplayKey(ShortcutId.PREVIOUS_EPISODE) || "O", 121 + description: t("global.keyboardShortcuts.shortcuts.previousEpisode"), 122 + condition: t("global.keyboardShortcuts.conditions.showsOnly"), 123 + config: getConfig(ShortcutId.PREVIOUS_EPISODE), 124 + }, 125 + ], 126 + }, 127 + { 128 + title: t("global.keyboardShortcuts.groups.jumpToPosition"), 129 + shortcuts: [ 130 + { 131 + key: getDisplayKey(ShortcutId.JUMP_TO_0) || "0", 132 + description: t("global.keyboardShortcuts.shortcuts.jumpTo0"), 133 + config: getConfig(ShortcutId.JUMP_TO_0), 134 + }, 135 + { 136 + key: getDisplayKey(ShortcutId.JUMP_TO_9) || "9", 137 + description: t("global.keyboardShortcuts.shortcuts.jumpTo9"), 138 + config: getConfig(ShortcutId.JUMP_TO_9), 139 + }, 140 + ], 141 + }, 142 + { 143 + title: t("global.keyboardShortcuts.groups.audioVideo"), 144 + shortcuts: [ 145 + { 146 + key: "↑", 147 + description: t("global.keyboardShortcuts.shortcuts.increaseVolume"), 148 + }, 149 + { 150 + key: "↓", 151 + description: t("global.keyboardShortcuts.shortcuts.decreaseVolume"), 152 + }, 153 + { 154 + key: getDisplayKey(ShortcutId.MUTE) || "M", 155 + description: t("global.keyboardShortcuts.shortcuts.mute"), 156 + config: getConfig(ShortcutId.MUTE), 157 + }, 158 + { 159 + key: getDisplayKey(ShortcutId.TOGGLE_FULLSCREEN) || "F", 160 + description: t("global.keyboardShortcuts.shortcuts.toggleFullscreen"), 161 + config: getConfig(ShortcutId.TOGGLE_FULLSCREEN), 162 + }, 163 + ], 164 + }, 165 + { 166 + title: t("global.keyboardShortcuts.groups.subtitlesAccessibility"), 167 + shortcuts: [ 168 + { 169 + key: getDisplayKey(ShortcutId.TOGGLE_CAPTIONS) || "C", 170 + description: t("global.keyboardShortcuts.shortcuts.toggleCaptions"), 171 + config: getConfig(ShortcutId.TOGGLE_CAPTIONS), 172 + }, 173 + { 174 + key: getDisplayKey(ShortcutId.RANDOM_CAPTION) || "Shift+C", 175 + description: t("global.keyboardShortcuts.shortcuts.randomCaption"), 176 + config: getConfig(ShortcutId.RANDOM_CAPTION), 177 + }, 178 + { 179 + key: getDisplayKey(ShortcutId.SYNC_SUBTITLES_EARLIER) || "[", 180 + description: t( 181 + "global.keyboardShortcuts.shortcuts.syncSubtitlesEarlier", 182 + ), 183 + config: getConfig(ShortcutId.SYNC_SUBTITLES_EARLIER), 184 + }, 185 + { 186 + key: getDisplayKey(ShortcutId.SYNC_SUBTITLES_LATER) || "]", 187 + description: t( 188 + "global.keyboardShortcuts.shortcuts.syncSubtitlesLater", 189 + ), 190 + config: getConfig(ShortcutId.SYNC_SUBTITLES_LATER), 191 + }, 192 + ], 193 + }, 194 + { 195 + title: t("global.keyboardShortcuts.groups.interface"), 196 + shortcuts: [ 197 + { 198 + key: getDisplayKey(ShortcutId.BARREL_ROLL) || "R", 199 + description: t("global.keyboardShortcuts.shortcuts.barrelRoll"), 200 + config: getConfig(ShortcutId.BARREL_ROLL), 201 + }, 202 + { 203 + key: "Escape", 204 + description: t("global.keyboardShortcuts.shortcuts.closeOverlay"), 205 + }, 206 + ], 207 + }, 208 + ]; 209 + }; 210 + 158 211 interface KeyboardCommandsModalProps { 159 212 id: string; 160 213 } 161 214 162 215 export function KeyboardCommandsModal({ id }: KeyboardCommandsModalProps) { 163 216 const { t } = useTranslation(); 164 - const shortcutGroups = getShortcutGroups(t); 217 + const navigate = useNavigate(); 218 + const keyboardShortcuts = usePreferencesStore((s) => s.keyboardShortcuts); 219 + const shortcutGroups = getShortcutGroups(t, keyboardShortcuts); 165 220 166 221 return ( 167 222 <Modal id={id}> ··· 178 233 return ( 179 234 <> 180 235 {before} 181 - <KeyBadge>`</KeyBadge> 236 + <KeyBadge config={undefined}>`</KeyBadge> 182 237 {after} 183 238 </> 184 239 ); 185 240 })()} 186 241 </p> 242 + <p className="text-type-secondary text-sm mt-2"> 243 + <button 244 + type="button" 245 + onClick={() => { 246 + navigate("/settings?category=settings-preferences"); 247 + }} 248 + className="text-type-link hover:text-type-linkHover" 249 + > 250 + {t("global.keyboardShortcuts.editInSettings")} 251 + </button> 252 + </p> 187 253 </div> 188 254 189 255 <div className="space-y-6 max-h-[60vh] overflow-y-auto"> ··· 193 259 {group.title} 194 260 </h3> 195 261 <div className="space-y-2"> 196 - {group.shortcuts.map((shortcut) => ( 197 - <div 198 - key={shortcut.key} 199 - className="flex items-center justify-between py-1" 200 - > 201 - <div className="flex items-center gap-3"> 202 - <KeyBadge>{shortcut.key}</KeyBadge> 203 - <span className="text-type-secondary"> 204 - {shortcut.description} 205 - </span> 262 + {group.shortcuts 263 + .filter((shortcut) => shortcut.key) // Only show shortcuts that have a key configured 264 + .map((shortcut) => ( 265 + <div 266 + key={shortcut.key} 267 + className="flex items-center justify-between py-1" 268 + > 269 + <div className="flex items-center gap-3"> 270 + <KeyBadge config={shortcut.config}> 271 + {shortcut.key} 272 + </KeyBadge> 273 + <span className="text-type-secondary"> 274 + {shortcut.description} 275 + </span> 276 + </div> 277 + {shortcut.condition && ( 278 + <span className="text-xs text-gray-400 italic"> 279 + {shortcut.condition} 280 + </span> 281 + )} 206 282 </div> 207 - {shortcut.condition && ( 208 - <span className="text-xs text-gray-400 italic"> 209 - {shortcut.condition} 210 - </span> 211 - )} 212 - </div> 213 - ))} 283 + ))} 214 284 </div> 215 285 </div> 216 286 ))}
+5 -2
src/components/overlays/Modal.tsx
··· 21 21 }; 22 22 } 23 23 24 - export function ModalCard(props: { children?: ReactNode }) { 24 + export function ModalCard(props: { 25 + children?: ReactNode; 26 + className?: ReactNode; 27 + }) { 25 28 return ( 26 - <div className="w-full max-w-[30rem] m-4"> 29 + <div className={classNames("w-full max-w-[30rem] m-4", props.className)}> 27 30 <div className="w-full bg-modal-background rounded-xl p-8 pointer-events-auto"> 28 31 {props.children} 29 32 </div>
+198 -39
src/components/player/internals/KeyboardEvents.tsx
··· 13 13 import { useSubtitleStore } from "@/stores/subtitles"; 14 14 import { useEmpheralVolumeStore } from "@/stores/volume"; 15 15 import { useWatchPartyStore } from "@/stores/watchParty"; 16 + import { 17 + LOCKED_SHORTCUTS, 18 + ShortcutId, 19 + matchesShortcut, 20 + } from "@/utils/keyboardShortcuts"; 16 21 17 22 export function KeyboardEvents() { 18 23 const router = useOverlayRouter(""); ··· 44 49 (s) => s.setShowDelayIndicator, 45 50 ); 46 51 const enableHoldToBoost = usePreferencesStore((s) => s.enableHoldToBoost); 52 + const keyboardShortcuts = usePreferencesStore((s) => s.keyboardShortcuts); 47 53 48 54 const [isRolling, setIsRolling] = useState(false); 49 55 const volumeDebounce = useRef<ReturnType<typeof setTimeout> | undefined>(); ··· 288 294 enableHoldToBoost, 289 295 navigateToNextEpisode, 290 296 navigateToPreviousEpisode, 297 + keyboardShortcuts, 291 298 }); 292 299 293 300 useEffect(() => { ··· 321 328 enableHoldToBoost, 322 329 navigateToNextEpisode, 323 330 navigateToPreviousEpisode, 331 + keyboardShortcuts, 324 332 }; 325 333 }, [ 326 334 setShowVolume, ··· 347 355 enableHoldToBoost, 348 356 navigateToNextEpisode, 349 357 navigateToPreviousEpisode, 358 + keyboardShortcuts, 350 359 ]); 351 360 352 361 useEffect(() => { ··· 357 366 const k = evt.key; 358 367 const keyL = evt.key.toLowerCase(); 359 368 360 - // Volume 369 + // Volume (locked shortcuts - ArrowUp/ArrowDown always work) 361 370 if (["ArrowUp", "ArrowDown", "m", "M"].includes(k)) { 362 371 dataRef.current.setShowVolume(true); 363 372 dataRef.current.setCurrentOverlay("volume"); ··· 368 377 dataRef.current.setCurrentOverlay(null); 369 378 }, 3e3); 370 379 } 371 - if (k === "ArrowUp") 380 + if (k === LOCKED_SHORTCUTS.ARROW_UP) 372 381 dataRef.current.setVolume( 373 382 (dataRef.current.mediaPlaying?.volume || 0) + 0.15, 374 383 ); 375 - if (k === "ArrowDown") 384 + if (k === LOCKED_SHORTCUTS.ARROW_DOWN) 376 385 dataRef.current.setVolume( 377 386 (dataRef.current.mediaPlaying?.volume || 0) - 0.15, 378 387 ); 379 - if (keyL === "m") dataRef.current.toggleMute(); 388 + // Mute - check customizable shortcut 389 + if ( 390 + matchesShortcut(evt, dataRef.current.keyboardShortcuts[ShortcutId.MUTE]) 391 + ) { 392 + dataRef.current.toggleMute(); 393 + } 380 394 381 - // Video playback speed - disabled in watch party 395 + // Video playback speed - disabled in watch party (hardcoded, not customizable) 382 396 if ((k === ">" || k === "<") && !dataRef.current.isInWatchParty) { 383 397 const options = [0.25, 0.5, 1, 1.5, 2]; 384 398 let idx = options.indexOf(dataRef.current.mediaPlaying?.playbackRate); ··· 389 403 } 390 404 391 405 // Handle spacebar press for play/pause and hold for 2x speed - disabled in watch party or when hold to boost is disabled 406 + // Space is locked, always check it 392 407 if ( 393 - k === " " && 408 + k === LOCKED_SHORTCUTS.PLAY_PAUSE_SPACE && 394 409 !dataRef.current.isInWatchParty && 395 410 dataRef.current.enableHoldToBoost 396 411 ) { ··· 455 470 } 456 471 457 472 // Handle spacebar press for simple play/pause when hold to boost is disabled or in watch party mode 473 + // Space is locked, always check it 458 474 if ( 459 - k === " " && 475 + k === LOCKED_SHORTCUTS.PLAY_PAUSE_SPACE && 460 476 (!dataRef.current.enableHoldToBoost || dataRef.current.isInWatchParty) 461 477 ) { 462 478 // Skip if it's a repeated event ··· 480 496 dataRef.current.display?.[action](); 481 497 } 482 498 483 - // Video progress 484 - if (k === "ArrowRight") 499 + // Video progress - handle skip shortcuts 500 + // Skip repeated key events to prevent multiple skips 501 + if (evt.repeat) return; 502 + 503 + // Arrow keys are locked (always 5 seconds) - handle first and return 504 + if (k === LOCKED_SHORTCUTS.ARROW_RIGHT) { 505 + evt.preventDefault(); 485 506 dataRef.current.display?.setTime(dataRef.current.time + 5); 486 - if (k === "ArrowLeft") 507 + return; 508 + } 509 + if (k === LOCKED_SHORTCUTS.ARROW_LEFT) { 510 + evt.preventDefault(); 511 + dataRef.current.display?.setTime(dataRef.current.time - 5); 512 + return; 513 + } 514 + 515 + // Skip forward/backward 5 seconds - customizable (skip if set to arrow keys) 516 + const skipForward5 = 517 + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_FORWARD_5]; 518 + if ( 519 + skipForward5?.key && 520 + skipForward5.key !== LOCKED_SHORTCUTS.ARROW_RIGHT && 521 + matchesShortcut(evt, skipForward5) 522 + ) { 523 + evt.preventDefault(); 524 + dataRef.current.display?.setTime(dataRef.current.time + 5); 525 + return; 526 + } 527 + const skipBackward5 = 528 + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_BACKWARD_5]; 529 + if ( 530 + skipBackward5?.key && 531 + skipBackward5.key !== LOCKED_SHORTCUTS.ARROW_LEFT && 532 + matchesShortcut(evt, skipBackward5) 533 + ) { 534 + evt.preventDefault(); 487 535 dataRef.current.display?.setTime(dataRef.current.time - 5); 488 - if (keyL === "j") 536 + return; 537 + } 538 + 539 + // Skip forward/backward 10 seconds - customizable 540 + if ( 541 + matchesShortcut( 542 + evt, 543 + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_FORWARD_10], 544 + ) 545 + ) { 546 + evt.preventDefault(); 547 + dataRef.current.display?.setTime(dataRef.current.time + 10); 548 + return; 549 + } 550 + if ( 551 + matchesShortcut( 552 + evt, 553 + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_BACKWARD_10], 554 + ) 555 + ) { 556 + evt.preventDefault(); 489 557 dataRef.current.display?.setTime(dataRef.current.time - 10); 490 - if (keyL === "l") 491 - dataRef.current.display?.setTime(dataRef.current.time + 10); 492 - if (k === "." && dataRef.current.mediaPlaying?.isPaused) 558 + return; 559 + } 560 + 561 + // Skip forward/backward 1 second - customizable 562 + if ( 563 + matchesShortcut( 564 + evt, 565 + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_FORWARD_1], 566 + ) 567 + ) { 568 + evt.preventDefault(); 493 569 dataRef.current.display?.setTime(dataRef.current.time + 1); 494 - if (k === "," && dataRef.current.mediaPlaying?.isPaused) 570 + return; 571 + } 572 + if ( 573 + matchesShortcut( 574 + evt, 575 + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_BACKWARD_1], 576 + ) 577 + ) { 578 + evt.preventDefault(); 495 579 dataRef.current.display?.setTime(dataRef.current.time - 1); 580 + return; 581 + } 496 582 497 - // Skip to percentage with number keys (0-9) 583 + // Skip to percentage with number keys (0-9) - locked, always use number keys 584 + // Number keys are reserved for progress skipping, so handle them before customizable shortcuts 498 585 if ( 499 586 /^[0-9]$/.test(k) && 500 587 dataRef.current.duration > 0 && 501 588 !evt.ctrlKey && 502 - !evt.metaKey 589 + !evt.metaKey && 590 + !evt.shiftKey && 591 + !evt.altKey 503 592 ) { 504 - const percentage = parseInt(k, 10) * 10; // 0 = 0%, 1 = 10%, 2 = 20%, ..., 9 = 90% 505 - const targetTime = (dataRef.current.duration * percentage) / 100; 506 - dataRef.current.display?.setTime(targetTime); 593 + evt.preventDefault(); 594 + if (k === "0") { 595 + dataRef.current.display?.setTime(0); 596 + } else if (k === "9") { 597 + const targetTime = (dataRef.current.duration * 90) / 100; 598 + dataRef.current.display?.setTime(targetTime); 599 + } else { 600 + // 1-8 for 10%-80% 601 + const percentage = parseInt(k, 10) * 10; 602 + const targetTime = (dataRef.current.duration * percentage) / 100; 603 + dataRef.current.display?.setTime(targetTime); 604 + } 605 + return; 507 606 } 508 607 509 - // Utils 510 - if (keyL === "f") dataRef.current.display?.toggleFullscreen(); 608 + // Utils - Fullscreen is customizable 609 + if ( 610 + matchesShortcut( 611 + evt, 612 + dataRef.current.keyboardShortcuts[ShortcutId.TOGGLE_FULLSCREEN], 613 + ) 614 + ) { 615 + dataRef.current.display?.toggleFullscreen(); 616 + } 511 617 512 - // Remove duplicate spacebar handler that was conflicting 513 - // with our improved implementation 514 - if (keyL === "k" && !dataRef.current.isSpaceHeldRef.current) { 618 + // K key for play/pause - locked shortcut 619 + if ( 620 + keyL === LOCKED_SHORTCUTS.PLAY_PAUSE_K.toLowerCase() && 621 + !dataRef.current.isSpaceHeldRef.current 622 + ) { 515 623 if ( 516 624 evt.target && 517 625 (evt.target as HTMLInputElement).nodeName === "BUTTON" ··· 522 630 const action = dataRef.current.mediaPlaying.isPaused ? "play" : "pause"; 523 631 dataRef.current.display?.[action](); 524 632 } 525 - if (k === "Escape") dataRef.current.router.close(); 633 + // Escape is locked 634 + if (k === LOCKED_SHORTCUTS.ESCAPE) dataRef.current.router.close(); 526 635 527 - // Episode navigation (shows only) 528 - if (keyL === "p") dataRef.current.navigateToNextEpisode(); 529 - if (keyL === "o") dataRef.current.navigateToPreviousEpisode(); 636 + // Episode navigation (shows only) - customizable 637 + if ( 638 + matchesShortcut( 639 + evt, 640 + dataRef.current.keyboardShortcuts[ShortcutId.NEXT_EPISODE], 641 + ) 642 + ) { 643 + dataRef.current.navigateToNextEpisode(); 644 + } 645 + if ( 646 + matchesShortcut( 647 + evt, 648 + dataRef.current.keyboardShortcuts[ShortcutId.PREVIOUS_EPISODE], 649 + ) 650 + ) { 651 + dataRef.current.navigateToPreviousEpisode(); 652 + } 530 653 531 - // captions 532 - if (keyL === "c" && !evt.shiftKey) 654 + // Captions - customizable 655 + if ( 656 + matchesShortcut( 657 + evt, 658 + dataRef.current.keyboardShortcuts[ShortcutId.TOGGLE_CAPTIONS], 659 + ) 660 + ) { 533 661 dataRef.current.toggleLastUsed().catch(() => {}); // ignore errors 534 - // Random caption selection (Shift+C) 535 - if (k === "C" && evt.shiftKey) { 662 + } 663 + // Random caption selection - customizable 664 + if ( 665 + matchesShortcut( 666 + evt, 667 + dataRef.current.keyboardShortcuts[ShortcutId.RANDOM_CAPTION], 668 + ) 669 + ) { 536 670 dataRef.current 537 671 .selectRandomCaptionFromLastUsedLanguage() 538 672 .catch(() => {}); // ignore errors 539 673 } 540 674 541 - // Do a barrell roll! 542 - if (keyL === "r") { 675 + // Barrel roll - customizable 676 + if ( 677 + matchesShortcut( 678 + evt, 679 + dataRef.current.keyboardShortcuts[ShortcutId.BARREL_ROLL], 680 + ) 681 + ) { 543 682 if (dataRef.current.isRolling || evt.ctrlKey || evt.metaKey) return; 544 683 545 684 dataRef.current.setIsRolling(true); ··· 553 692 }, 1e3); 554 693 } 555 694 556 - // Subtitle sync 557 - if (k === "[" || k === "]") { 558 - const change = k === "[" ? -0.5 : 0.5; 559 - dataRef.current.setDelay(dataRef.current.delay + change); 695 + // Subtitle sync - customizable 696 + if ( 697 + matchesShortcut( 698 + evt, 699 + dataRef.current.keyboardShortcuts[ShortcutId.SYNC_SUBTITLES_EARLIER], 700 + ) 701 + ) { 702 + dataRef.current.setDelay(dataRef.current.delay - 0.5); 703 + dataRef.current.setShowDelayIndicator(true); 704 + dataRef.current.setCurrentOverlay("subtitle"); 705 + 706 + if (subtitleDebounce.current) clearTimeout(subtitleDebounce.current); 707 + subtitleDebounce.current = setTimeout(() => { 708 + dataRef.current.setShowDelayIndicator(false); 709 + dataRef.current.setCurrentOverlay(null); 710 + }, 3000); 711 + } 712 + if ( 713 + matchesShortcut( 714 + evt, 715 + dataRef.current.keyboardShortcuts[ShortcutId.SYNC_SUBTITLES_LATER], 716 + ) 717 + ) { 718 + dataRef.current.setDelay(dataRef.current.delay + 0.5); 560 719 dataRef.current.setShowDelayIndicator(true); 561 720 dataRef.current.setCurrentOverlay("subtitle"); 562 721
+8
src/hooks/auth/useAuthData.ts
··· 91 91 const setEnableAutoResumeOnPlaybackError = usePreferencesStore( 92 92 (s) => s.setEnableAutoResumeOnPlaybackError, 93 93 ); 94 + const setKeyboardShortcuts = usePreferencesStore( 95 + (s) => s.setKeyboardShortcuts, 96 + ); 94 97 95 98 const login = useCallback( 96 99 async ( ··· 275 278 settings.enableAutoResumeOnPlaybackError, 276 279 ); 277 280 } 281 + 282 + if (settings.keyboardShortcuts !== undefined) { 283 + setKeyboardShortcuts(settings.keyboardShortcuts); 284 + } 278 285 }, 279 286 [ 280 287 replaceBookmarks, ··· 311 318 setManualSourceSelection, 312 319 setEnableDoubleClickToSeek, 313 320 setEnableAutoResumeOnPlaybackError, 321 + setKeyboardShortcuts, 314 322 ], 315 323 ); 316 324
+18
src/pages/parts/settings/PreferencesPart.tsx
··· 11 11 import { SortableListWithToggles } from "@/components/form/SortableListWithToggles"; 12 12 import { Heading1 } from "@/components/utils/Text"; 13 13 import { appLanguageOptions } from "@/setup/i18n"; 14 + import { useOverlayStack } from "@/stores/interface/overlayStack"; 14 15 import { isAutoplayAllowed } from "@/utils/autoplay"; 15 16 import { getLocaleInfo, sortLangCodes } from "@/utils/language"; 16 17 ··· 43 44 setEnableAutoResumeOnPlaybackError: (v: boolean) => void; 44 45 }) { 45 46 const { t } = useTranslation(); 47 + const { showModal } = useOverlayStack(); 46 48 const sorted = sortLangCodes(appLanguageOptions.map((item) => item.code)); 47 49 48 50 const allowAutoplay = isAutoplayAllowed(); ··· 246 248 </p> 247 249 </div> 248 250 </div> 251 + 252 + {/* Keyboard Shortcuts Preference */} 253 + <div> 254 + <p className="text-white font-bold mb-3"> 255 + {t("settings.preferences.keyboardShortcuts")} 256 + </p> 257 + <p className="max-w-[25rem] font-medium"> 258 + {t("settings.preferences.keyboardShortcutsDescription")} 259 + </p> 260 + </div> 261 + <Button 262 + theme="secondary" 263 + onClick={() => showModal("keyboard-commands-edit")} 264 + > 265 + {t("settings.preferences.keyboardShortcutsLabel")} 266 + </Button> 249 267 </div> 250 268 251 269 {/* Column */}
+2
src/setup/App.tsx
··· 12 12 import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta"; 13 13 import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb"; 14 14 import { DetailsModal } from "@/components/overlays/detailsModal"; 15 + import { KeyboardCommandsEditModal } from "@/components/overlays/KeyboardCommandsEditModal"; 15 16 import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal"; 16 17 import { NotificationModal } from "@/components/overlays/notificationsModal"; 17 18 import { SupportInfoModal } from "@/components/overlays/SupportInfoModal"; ··· 127 128 <LanguageProvider /> 128 129 <NotificationModal id="notifications" /> 129 130 <KeyboardCommandsModal id="keyboard-commands" /> 131 + <KeyboardCommandsEditModal id="keyboard-commands-edit" /> 130 132 <SupportInfoModal id="support-info" /> 131 133 <DetailsModal id="details" /> 132 134 <DetailsModal id="discover-details" />
+13
src/stores/preferences/index.tsx
··· 2 2 import { persist } from "zustand/middleware"; 3 3 import { immer } from "zustand/middleware/immer"; 4 4 5 + import { 6 + DEFAULT_KEYBOARD_SHORTCUTS, 7 + KeyboardShortcuts, 8 + } from "@/utils/keyboardShortcuts"; 9 + 5 10 export interface PreferencesStore { 6 11 enableThumbnails: boolean; 7 12 enableAutoplay: boolean; ··· 31 36 manualSourceSelection: boolean; 32 37 enableDoubleClickToSeek: boolean; 33 38 enableAutoResumeOnPlaybackError: boolean; 39 + keyboardShortcuts: KeyboardShortcuts; 34 40 35 41 setEnableThumbnails(v: boolean): void; 36 42 setEnableAutoplay(v: boolean): void; ··· 60 66 setManualSourceSelection(v: boolean): void; 61 67 setEnableDoubleClickToSeek(v: boolean): void; 62 68 setEnableAutoResumeOnPlaybackError(v: boolean): void; 69 + setKeyboardShortcuts(v: KeyboardShortcuts): void; 63 70 } 64 71 65 72 export const usePreferencesStore = create( ··· 93 100 manualSourceSelection: false, 94 101 enableDoubleClickToSeek: false, 95 102 enableAutoResumeOnPlaybackError: true, 103 + keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, 96 104 setEnableThumbnails(v) { 97 105 set((s) => { 98 106 s.enableThumbnails = v; ··· 236 244 setEnableAutoResumeOnPlaybackError(v) { 237 245 set((s) => { 238 246 s.enableAutoResumeOnPlaybackError = v; 247 + }); 248 + }, 249 + setKeyboardShortcuts(v) { 250 + set((s) => { 251 + s.keyboardShortcuts = v; 239 252 }); 240 253 }, 241 254 })),
+298
src/utils/keyboardShortcuts.ts
··· 1 + /** 2 + * Keyboard shortcuts configuration and utilities 3 + */ 4 + 5 + export type KeyboardModifier = "Shift" | "Alt"; 6 + 7 + export interface KeyboardShortcutConfig { 8 + modifier?: KeyboardModifier; 9 + key?: string; 10 + } 11 + 12 + export type KeyboardShortcuts = Record<string, KeyboardShortcutConfig>; 13 + 14 + /** 15 + * Shortcut IDs for customizable shortcuts 16 + */ 17 + export enum ShortcutId { 18 + // Video playback 19 + SKIP_FORWARD_5 = "skipForward5", 20 + SKIP_BACKWARD_5 = "skipBackward5", 21 + SKIP_FORWARD_10 = "skipForward10", 22 + SKIP_BACKWARD_10 = "skipBackward10", 23 + SKIP_FORWARD_1 = "skipForward1", 24 + SKIP_BACKWARD_1 = "skipBackward1", 25 + NEXT_EPISODE = "nextEpisode", 26 + PREVIOUS_EPISODE = "previousEpisode", 27 + 28 + // Jump to position 29 + JUMP_TO_0 = "jumpTo0", 30 + JUMP_TO_9 = "jumpTo9", 31 + 32 + // Audio/Video 33 + INCREASE_VOLUME = "increaseVolume", 34 + DECREASE_VOLUME = "decreaseVolume", 35 + MUTE = "mute", 36 + TOGGLE_FULLSCREEN = "toggleFullscreen", 37 + 38 + // Subtitles/Accessibility 39 + TOGGLE_CAPTIONS = "toggleCaptions", 40 + RANDOM_CAPTION = "randomCaption", 41 + SYNC_SUBTITLES_EARLIER = "syncSubtitlesEarlier", 42 + SYNC_SUBTITLES_LATER = "syncSubtitlesLater", 43 + 44 + // Interface 45 + BARREL_ROLL = "barrelRoll", 46 + } 47 + 48 + /** 49 + * Default keyboard shortcuts configuration 50 + */ 51 + export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { 52 + [ShortcutId.SKIP_FORWARD_5]: { key: "ArrowRight" }, 53 + [ShortcutId.SKIP_BACKWARD_5]: { key: "ArrowLeft" }, 54 + [ShortcutId.SKIP_FORWARD_10]: { key: "L" }, 55 + [ShortcutId.SKIP_BACKWARD_10]: { key: "J" }, 56 + [ShortcutId.SKIP_FORWARD_1]: { key: "." }, 57 + [ShortcutId.SKIP_BACKWARD_1]: { key: "," }, 58 + [ShortcutId.NEXT_EPISODE]: { key: "P" }, 59 + [ShortcutId.PREVIOUS_EPISODE]: { key: "O" }, 60 + [ShortcutId.JUMP_TO_0]: { key: "0" }, 61 + [ShortcutId.JUMP_TO_9]: { key: "9" }, 62 + [ShortcutId.INCREASE_VOLUME]: { key: "ArrowUp" }, 63 + [ShortcutId.DECREASE_VOLUME]: { key: "ArrowDown" }, 64 + [ShortcutId.MUTE]: { key: "M" }, 65 + [ShortcutId.TOGGLE_FULLSCREEN]: { key: "F" }, 66 + [ShortcutId.TOGGLE_CAPTIONS]: { key: "C" }, 67 + [ShortcutId.RANDOM_CAPTION]: { modifier: "Shift", key: "C" }, 68 + [ShortcutId.SYNC_SUBTITLES_EARLIER]: { key: "[" }, 69 + [ShortcutId.SYNC_SUBTITLES_LATER]: { key: "]" }, 70 + [ShortcutId.BARREL_ROLL]: { key: "R" }, 71 + }; 72 + 73 + /** 74 + * Locked shortcuts that cannot be customized 75 + */ 76 + export const LOCKED_SHORTCUTS = { 77 + PLAY_PAUSE_SPACE: " ", 78 + PLAY_PAUSE_K: "K", 79 + MODAL_HOTKEY: "`", 80 + ARROW_UP: "ArrowUp", 81 + ARROW_DOWN: "ArrowDown", 82 + ARROW_LEFT: "ArrowLeft", 83 + ARROW_RIGHT: "ArrowRight", 84 + ESCAPE: "Escape", 85 + JUMP_TO_0: "0", 86 + JUMP_TO_9: "9", 87 + } as const; 88 + 89 + /** 90 + * Locked shortcut IDs that cannot be customized 91 + */ 92 + export const LOCKED_SHORTCUT_IDS: string[] = [ 93 + "playPause", 94 + "playPauseAlt", 95 + "skipForward5", 96 + "skipBackward5", 97 + "increaseVolume", 98 + "decreaseVolume", 99 + "modalHotkey", 100 + "closeOverlay", 101 + "jumpTo0", 102 + "jumpTo9", 103 + ]; 104 + 105 + /** 106 + * Check if a key is a number key (0-9) 107 + */ 108 + export function isNumberKey(key: string): boolean { 109 + return /^[0-9]$/.test(key); 110 + } 111 + 112 + /** 113 + * Key equivalence map for bidirectional mapping 114 + * Maps keys that should be treated as equivalent (e.g., 1 and !) 115 + */ 116 + export const KEY_EQUIVALENCE_MAP: Record<string, string> = { 117 + // Number keys and their shift equivalents 118 + "1": "!", 119 + "!": "1", 120 + "2": "@", 121 + "@": "2", 122 + "3": "#", 123 + "#": "3", 124 + "4": "$", 125 + $: "4", 126 + "5": "%", 127 + "%": "5", 128 + "6": "^", 129 + "^": "6", 130 + "7": "&", 131 + "&": "7", 132 + "8": "*", 133 + "*": "8", 134 + "9": "(", 135 + "(": "9", 136 + "0": ")", 137 + ")": "0", 138 + 139 + // Other symbol pairs 140 + "-": "_", 141 + _: "-", 142 + "=": "+", 143 + "+": "=", 144 + "[": "{", 145 + "{": "[", 146 + "]": "}", 147 + "}": "]", 148 + "\\": "|", 149 + "|": "\\", 150 + ";": ":", 151 + ":": ";", 152 + "'": '"', 153 + '"': "'", 154 + ",": "<", 155 + "<": ",", 156 + ".": ">", 157 + ">": ".", 158 + "/": "?", 159 + "?": "/", 160 + "`": "~", 161 + "~": "`", 162 + }; 163 + 164 + /** 165 + * Get equivalent keys for a given key 166 + */ 167 + export function getEquivalentKeys(key: string): string[] { 168 + const equivalent = KEY_EQUIVALENCE_MAP[key]; 169 + if (equivalent) { 170 + return [key, equivalent]; 171 + } 172 + return [key]; 173 + } 174 + 175 + /** 176 + * Normalize a key for comparison (handles case-insensitive matching) 177 + */ 178 + export function normalizeKey(key: string): string { 179 + // For letter keys, use uppercase for consistency 180 + if (/^[a-z]$/i.test(key)) { 181 + return key.toUpperCase(); 182 + } 183 + return key; 184 + } 185 + 186 + /** 187 + * Check if two shortcut configs conflict 188 + */ 189 + export function checkShortcutConflict( 190 + config1: KeyboardShortcutConfig | undefined, 191 + config2: KeyboardShortcutConfig | undefined, 192 + ): boolean { 193 + if (!config1 || !config2 || !config1.key || !config2.key) { 194 + return false; 195 + } 196 + 197 + // Check if modifiers match 198 + if (config1.modifier !== config2.modifier) { 199 + return false; 200 + } 201 + 202 + // Check if keys match directly or are equivalent 203 + const key1 = normalizeKey(config1.key); 204 + const key2 = normalizeKey(config2.key); 205 + 206 + if (key1 === key2) { 207 + return true; 208 + } 209 + 210 + // Check equivalence 211 + const equiv1 = getEquivalentKeys(key1); 212 + const equiv2 = getEquivalentKeys(key2); 213 + 214 + return equiv1.some((k1) => equiv2.includes(k1)); 215 + } 216 + 217 + /** 218 + * Find all conflicts in a shortcuts configuration 219 + */ 220 + export function findConflicts( 221 + shortcuts: KeyboardShortcuts, 222 + ): Array<{ id1: string; id2: string }> { 223 + const conflicts: Array<{ id1: string; id2: string }> = []; 224 + const ids = Object.keys(shortcuts); 225 + 226 + for (let i = 0; i < ids.length; i += 1) { 227 + for (let j = i + 1; j < ids.length; j += 1) { 228 + const id1 = ids[i]; 229 + const id2 = ids[j]; 230 + const config1 = shortcuts[id1]; 231 + const config2 = shortcuts[id2]; 232 + 233 + if (checkShortcutConflict(config1, config2)) { 234 + conflicts.push({ id1, id2 }); 235 + } 236 + } 237 + } 238 + 239 + return conflicts; 240 + } 241 + 242 + /** 243 + * Check if a keyboard event matches a shortcut configuration 244 + */ 245 + export function matchesShortcut( 246 + event: KeyboardEvent, 247 + config: KeyboardShortcutConfig | undefined, 248 + ): boolean { 249 + if (!config || !config.key) { 250 + return false; 251 + } 252 + 253 + const eventKey = normalizeKey(event.key); 254 + const configKey = normalizeKey(config.key); 255 + 256 + // Check modifier match 257 + if (config.modifier === "Shift" && !event.shiftKey) { 258 + return false; 259 + } 260 + if (config.modifier === "Alt" && !event.altKey) { 261 + return false; 262 + } 263 + // If no modifier specified, ensure no modifier is pressed (except ctrl/meta which we ignore) 264 + if (!config.modifier && (event.shiftKey || event.altKey)) { 265 + return false; 266 + } 267 + 268 + // Check key match (direct or equivalent) 269 + if (eventKey === configKey) { 270 + return true; 271 + } 272 + 273 + // Check equivalence 274 + const equivKeys = getEquivalentKeys(configKey); 275 + return equivKeys.includes(eventKey); 276 + } 277 + 278 + /** 279 + * Get display name for a key 280 + */ 281 + export function getKeyDisplayName(key: string): string { 282 + const displayNames: Record<string, string> = { 283 + ArrowUp: "↑", 284 + ArrowDown: "↓", 285 + ArrowLeft: "←", 286 + ArrowRight: "→", 287 + " ": "Space", 288 + }; 289 + 290 + return displayNames[key] || key; 291 + } 292 + 293 + /** 294 + * Get display symbol for a modifier 295 + */ 296 + export function getModifierSymbol(modifier: KeyboardModifier): string { 297 + return modifier === "Shift" ? "⇧" : "⌥"; 298 + }