From 588e0609fd5d1b1d1e1c81669ceed5737caf715f Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 1 Jan 2026 14:02:02 -0800 Subject: [PATCH] add list playback and navigation hotkeys (#1469) --- src/i18n/locales/en.json | 5 + .../item-list/helpers/use-list-hotkeys.ts | 123 ++++++++++++ .../item-grid-list/item-grid-list.tsx | 22 +-- .../item-table-list/item-table-list.tsx | 22 +-- .../hotkeys/hotkey-manager-settings.tsx | 180 +++++++++++------- src/renderer/store/settings.store.ts | 15 ++ 6 files changed, 266 insertions(+), 101 deletions(-) create mode 100644 src/renderer/components/item-list/helpers/use-list-hotkeys.ts diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 57b455bc0..8e01270d5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -802,6 +802,11 @@ "hotkey_favoritePreviousSong": "favorite $t(common.previousSong)", "hotkey_globalSearch": "global search", "hotkey_localSearch": "in-page search", + "hotkey_listNavigateToPage": "list navigate to item page", + "hotkey_listPlayDefault": "list play", + "hotkey_listPlayLast": "list play last", + "hotkey_listPlayNext": "list play next", + "hotkey_listPlayNow": "list play now", "hotkey_navigateHome": "navigate to home", "hotkey_playbackNext": "next track", "hotkey_playbackPause": "pause", diff --git a/src/renderer/components/item-list/helpers/use-list-hotkeys.ts b/src/renderer/components/item-list/helpers/use-list-hotkeys.ts new file mode 100644 index 000000000..bc285474f --- /dev/null +++ b/src/renderer/components/item-list/helpers/use-list-hotkeys.ts @@ -0,0 +1,123 @@ +import { useNavigate } from 'react-router'; + +import { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path'; +import { + ItemListStateActions, + ItemListStateItemWithRequiredProperties, +} from '/@/renderer/components/item-list/helpers/item-list-state'; +import { ItemControls } from '/@/renderer/components/item-list/types'; +import { useHotkeySettings, usePlayButtonBehavior } from '/@/renderer/store'; +import { useHotkeys } from '/@/shared/hooks/use-hotkeys'; +import { LibraryItem } from '/@/shared/types/domain-types'; +import { Play } from '/@/shared/types/types'; + +export const useListHotkeys = ({ + controls, + focused, + internalState, + itemType, +}: { + controls: ItemControls; + focused: boolean; + internalState: ItemListStateActions; + itemType: LibraryItem; +}) => { + const { bindings } = useHotkeySettings(); + const playButtonBehavior = usePlayButtonBehavior(); + const navigate = useNavigate(); + + // Helper to check if item has required properties + const hasRequiredStateItemProperties = ( + item: unknown, + ): item is ItemListStateItemWithRequiredProperties => { + return ( + typeof item === 'object' && + item !== null && + 'id' in item && + typeof (item as any).id === 'string' && + '_serverId' in item && + typeof (item as any)._serverId === 'string' && + '_itemType' in item && + typeof (item as any)._itemType === 'string' + ); + }; + + useHotkeys([ + [ + 'mod+a', + () => { + if (focused) { + if (internalState.isAllSelected()) { + internalState.deselectAll(); + } else { + internalState.selectAll(); + } + } + }, + ], + [ + bindings.listPlayDefault.hotkey, + () => { + if (!focused) return; + const selected = internalState.getSelected(); + const validSelected = selected.filter(hasRequiredStateItemProperties); + if (validSelected.length === 0) return; + + const item = validSelected[0]; + const playType = playButtonBehavior; + controls.onPlay?.({ item, itemType, playType } as any); + }, + ], + [ + bindings.listPlayNow.hotkey, + () => { + if (!focused) return; + const selected = internalState.getSelected(); + const validSelected = selected.filter(hasRequiredStateItemProperties); + if (validSelected.length === 0) return; + + const item = validSelected[0]; + controls.onPlay?.({ item, itemType, playType: Play.NOW } as any); + }, + ], + [ + bindings.listPlayNext.hotkey, + () => { + if (!focused) return; + const selected = internalState.getSelected(); + const validSelected = selected.filter(hasRequiredStateItemProperties); + if (validSelected.length === 0) return; + + const item = validSelected[0]; + controls.onPlay?.({ item, itemType, playType: Play.NEXT } as any); + }, + ], + [ + bindings.listPlayLast.hotkey, + () => { + if (!focused) return; + const selected = internalState.getSelected(); + const validSelected = selected.filter(hasRequiredStateItemProperties); + if (validSelected.length === 0) return; + + const item = validSelected[0]; + controls.onPlay?.({ item, itemType, playType: Play.LAST } as any); + }, + ], + [ + bindings.listNavigateToPage.hotkey, + () => { + if (!focused) return; + const selected = internalState.getSelected(); + const validSelected = selected.filter(hasRequiredStateItemProperties); + if (validSelected.length === 0) return; + + const item = validSelected[0]; + const path = getTitlePath(itemType, item.id); + if (path) { + navigate(path, { state: { item } }); + } + }, + ], + ]); +}; diff --git a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx index a28d7001a..74a3e9add 100644 --- a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx +++ b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx @@ -41,11 +41,11 @@ import { useItemListState, useItemListStateSubscription, } from '/@/renderer/components/item-list/helpers/item-list-state'; +import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys'; import { ItemControls, ItemListHandle } from '/@/renderer/components/item-list/types'; import { animationProps } from '/@/shared/components/animations/animation-props'; import { useElementSize } from '/@/shared/hooks/use-element-size'; import { useFocusWithin } from '/@/shared/hooks/use-focus-within'; -import { useHotkeys } from '/@/shared/hooks/use-hotkeys'; import { useMergedRef } from '/@/shared/hooks/use-merged-ref'; import { LibraryItem } from '/@/shared/types/domain-types'; @@ -714,20 +714,12 @@ const BaseItemGridList = ({ useImperativeHandle(ref, () => imperativeHandle, [imperativeHandle]); - useHotkeys([ - [ - 'mod+a', - () => { - if (focused) { - if (internalState.isAllSelected()) { - internalState.deselectAll(); - } else { - internalState.selectAll(); - } - } - }, - ], - ]); + useListHotkeys({ + controls, + focused, + internalState, + itemType, + }); return ( { - if (focused) { - if (internalState.isAllSelected()) { - internalState.deselectAll(); - } else { - internalState.selectAll(); - } - } - }, - ], - ]); + useListHotkeys({ + controls, + focused, + internalState, + itemType, + }); return ( = { context: 'globalSearch', postProcess: 'sentenceCase', }), + listNavigateToPage: i18n.t('setting.hotkey', { + context: 'listNavigateToPage', + postProcess: 'sentenceCase', + }), + listPlayDefault: i18n.t('setting.hotkey', { + context: 'listPlayDefault', + postProcess: 'sentenceCase', + }), + listPlayLast: i18n.t('setting.hotkey', { + context: 'listPlayLast', + postProcess: 'sentenceCase', + }), + listPlayNext: i18n.t('setting.hotkey', { + context: 'listPlayNext', + postProcess: 'sentenceCase', + }), + listPlayNow: i18n.t('setting.hotkey', { context: 'listPlayNow', postProcess: 'sentenceCase' }), localSearch: i18n.t('setting.hotkey', { context: 'localSearch', postProcess: 'sentenceCase' }), navigateHome: i18n.t('setting.hotkey', { context: 'navigateHome', @@ -251,78 +268,99 @@ export const HotkeyManagerSettings = () => { title={t('setting.applicationHotkeys', { postProcess: 'sentenceCase' })} />
- {filteredBindings.map((binding) => ( - - - } - onBlur={() => setSelected(null)} - onChange={() => {}} - onKeyDownCapture={(e) => { - if (selected !== (binding as BindingActions)) return; - handleSetHotkey(binding as BindingActions, e); - }} - readOnly - rightSection={ - { - setSelected(binding as BindingActions); - document - .getElementById(`hotkey-${binding}`) - ?.focus(); + + {filteredBindings.map((binding) => ( + + + {BINDINGS_MAP[binding as keyof typeof BINDINGS_MAP]} + + + } + onBlur={() => setSelected(null)} + onChange={() => {}} + onKeyDownCapture={(e) => { + if (selected !== (binding as BindingActions)) + return; + handleSetHotkey(binding as BindingActions, e); }} - variant="transparent" + readOnly + rightSection={ + { + setSelected(binding as BindingActions); + document + .getElementById(`hotkey-${binding}`) + ?.focus(); + }} + variant="transparent" + /> + } + style={{ + opacity: + selected === (binding as BindingActions) + ? 0.8 + : 1, + outline: duplicateHotkeyMap.includes( + bindings[binding as keyof typeof BINDINGS_MAP] + .hotkey!, + ) + ? '1px dashed red' + : undefined, + }} + value={ + bindings[binding as keyof typeof BINDINGS_MAP] + .hotkey + } /> - } - style={{ - opacity: selected === (binding as BindingActions) ? 0.8 : 1, - outline: duplicateHotkeyMap.includes( - bindings[binding as keyof typeof BINDINGS_MAP].hotkey!, - ) - ? '1px dashed red' - : undefined, - }} - value={bindings[binding as keyof typeof BINDINGS_MAP].hotkey} - /> - {isElectron() && ( - - handleSetGlobalHotkey(binding as BindingActions, e) - } - size="md" - style={{ - opacity: bindings[binding as keyof typeof BINDINGS_MAP] - .allowGlobal - ? 1 - : 0, - }} - /> - )} - {bindings[binding as keyof typeof BINDINGS_MAP].hotkey && ( - handleClearHotkey(binding as BindingActions)} - variant="transparent" - /> - )} - - ))} + + {isElectron() && ( + + + handleSetGlobalHotkey( + binding as BindingActions, + e, + ) + } + size="md" + style={{ + opacity: bindings[ + binding as keyof typeof BINDINGS_MAP + ].allowGlobal + ? 1 + : 0, + }} + /> + + )} + {bindings[binding as keyof typeof BINDINGS_MAP].hotkey && ( + + + handleClearHotkey(binding as BindingActions) + } + variant="transparent" + /> + + )} + + ))} +
} diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index df26563e2..b7de0572a 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -115,6 +115,11 @@ const BindingActionsSchema = z.enum([ 'volumeUp', 'zoomIn', 'zoomOut', + 'listPlayDefault', + 'listPlayNow', + 'listPlayNext', + 'listPlayLast', + 'listNavigateToPage', ]); const DiscordDisplayTypeSchema = z.enum(['artist', 'feishin', 'song']); @@ -647,6 +652,11 @@ export enum BindingActions { FAVORITE_PREVIOUS_REMOVE = 'favoritePreviousRemove', FAVORITE_PREVIOUS_TOGGLE = 'favoritePreviousToggle', GLOBAL_SEARCH = 'globalSearch', + LIST_NAVIGATE_TO_PAGE = 'listNavigateToPage', + LIST_PLAY_DEFAULT = 'listPlayDefault', + LIST_PLAY_LAST = 'listPlayLast', + LIST_PLAY_NEXT = 'listPlayNext', + LIST_PLAY_NOW = 'listPlayNow', LOCAL_SEARCH = 'localSearch', MUTE = 'volumeMute', NAVIGATE_HOME = 'navigateHome', @@ -986,6 +996,11 @@ const initialState: SettingsState = { favoritePreviousRemove: { allowGlobal: true, hotkey: '', isGlobal: false }, favoritePreviousToggle: { allowGlobal: true, hotkey: '', isGlobal: false }, globalSearch: { allowGlobal: false, hotkey: 'mod+k', isGlobal: false }, + listNavigateToPage: { allowGlobal: false, hotkey: 'mod+g', isGlobal: false }, + listPlayDefault: { allowGlobal: false, hotkey: 'enter', isGlobal: false }, + listPlayLast: { allowGlobal: false, hotkey: '', isGlobal: false }, + listPlayNext: { allowGlobal: false, hotkey: '', isGlobal: false }, + listPlayNow: { allowGlobal: false, hotkey: '', isGlobal: false }, localSearch: { allowGlobal: false, hotkey: 'mod+f', isGlobal: false }, navigateHome: { allowGlobal: false, hotkey: '', isGlobal: false }, next: { allowGlobal: true, hotkey: '', isGlobal: false },