add list playback and navigation hotkeys (#1469)

This commit is contained in:
jeffvli
2026-01-01 14:02:02 -08:00
parent 091d2efb2e
commit 588e0609fd
6 changed files with 266 additions and 101 deletions
+5
View File
@@ -802,6 +802,11 @@
"hotkey_favoritePreviousSong": "favorite $t(common.previousSong)", "hotkey_favoritePreviousSong": "favorite $t(common.previousSong)",
"hotkey_globalSearch": "global search", "hotkey_globalSearch": "global search",
"hotkey_localSearch": "in-page 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_navigateHome": "navigate to home",
"hotkey_playbackNext": "next track", "hotkey_playbackNext": "next track",
"hotkey_playbackPause": "pause", "hotkey_playbackPause": "pause",
@@ -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 } });
}
},
],
]);
};
@@ -41,11 +41,11 @@ import {
useItemListState, useItemListState,
useItemListStateSubscription, useItemListStateSubscription,
} from '/@/renderer/components/item-list/helpers/item-list-state'; } 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 { ItemControls, ItemListHandle } from '/@/renderer/components/item-list/types';
import { animationProps } from '/@/shared/components/animations/animation-props'; import { animationProps } from '/@/shared/components/animations/animation-props';
import { useElementSize } from '/@/shared/hooks/use-element-size'; import { useElementSize } from '/@/shared/hooks/use-element-size';
import { useFocusWithin } from '/@/shared/hooks/use-focus-within'; import { useFocusWithin } from '/@/shared/hooks/use-focus-within';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { useMergedRef } from '/@/shared/hooks/use-merged-ref'; import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
@@ -714,20 +714,12 @@ const BaseItemGridList = ({
useImperativeHandle(ref, () => imperativeHandle, [imperativeHandle]); useImperativeHandle(ref, () => imperativeHandle, [imperativeHandle]);
useHotkeys([ useListHotkeys({
[ controls,
'mod+a', focused,
() => { internalState,
if (focused) { itemType,
if (internalState.isAllSelected()) { });
internalState.deselectAll();
} else {
internalState.selectAll();
}
}
},
],
]);
return ( return (
<motion.div <motion.div
@@ -32,6 +32,7 @@ import {
useItemListStateSubscription, useItemListStateSubscription,
} from '/@/renderer/components/item-list/helpers/item-list-state'; } from '/@/renderer/components/item-list/helpers/item-list-state';
import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns'; import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns';
import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';
import { useStickyTableGroupRows } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows'; import { useStickyTableGroupRows } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows';
import { useStickyTableHeader } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header'; import { useStickyTableHeader } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header';
import { import {
@@ -42,7 +43,6 @@ import {
import { PlayerContext, usePlayer } from '/@/renderer/features/player/context/player-context'; import { PlayerContext, usePlayer } from '/@/renderer/features/player/context/player-context';
import { animationProps } from '/@/shared/components/animations/animation-props'; import { animationProps } from '/@/shared/components/animations/animation-props';
import { useFocusWithin } from '/@/shared/hooks/use-focus-within'; import { useFocusWithin } from '/@/shared/hooks/use-focus-within';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { useMergedRef } from '/@/shared/hooks/use-merged-ref'; import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
import { TableColumn } from '/@/shared/types/types'; import { TableColumn } from '/@/shared/types/types';
@@ -2262,20 +2262,12 @@ const BaseItemTableList = ({
stickyGroupTop, stickyGroupTop,
]); ]);
useHotkeys([ useListHotkeys({
[ controls,
'mod+a', focused,
() => { internalState,
if (focused) { itemType,
if (internalState.isAllSelected()) { });
internalState.deselectAll();
} else {
internalState.selectAll();
}
}
},
],
]);
return ( return (
<motion.div <motion.div
@@ -15,8 +15,8 @@ import { useSettingSearchContext } from '/@/renderer/features/settings/context/s
import { BindingActions, useHotkeySettings, useSettingsStoreActions } from '/@/renderer/store'; import { BindingActions, useHotkeySettings, useSettingsStoreActions } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Checkbox } from '/@/shared/components/checkbox/checkbox'; import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { Table } from '/@/shared/components/table/table';
import { TextInput } from '/@/shared/components/text-input/text-input'; import { TextInput } from '/@/shared/components/text-input/text-input';
const ipc = isElectron() ? window.api.ipc : null; const ipc = isElectron() ? window.api.ipc : null;
@@ -55,6 +55,23 @@ const BINDINGS_MAP: Record<BindingActions, string> = {
context: 'globalSearch', context: 'globalSearch',
postProcess: 'sentenceCase', 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' }), localSearch: i18n.t('setting.hotkey', { context: 'localSearch', postProcess: 'sentenceCase' }),
navigateHome: i18n.t('setting.hotkey', { navigateHome: i18n.t('setting.hotkey', {
context: 'navigateHome', context: 'navigateHome',
@@ -251,20 +268,21 @@ export const HotkeyManagerSettings = () => {
title={t('setting.applicationHotkeys', { postProcess: 'sentenceCase' })} title={t('setting.applicationHotkeys', { postProcess: 'sentenceCase' })}
/> />
<div className={styles.container}> <div className={styles.container}>
<Table withColumnBorders withRowBorders>
{filteredBindings.map((binding) => ( {filteredBindings.map((binding) => (
<Group key={`hotkey-${binding}`} wrap="nowrap"> <Table.Tr key={`hotkey-${binding}`}>
<TextInput <Table.Td style={{ userSelect: 'none' }}>
readOnly {BINDINGS_MAP[binding as keyof typeof BINDINGS_MAP]}
style={{ userSelect: 'none' }} </Table.Td>
value={BINDINGS_MAP[binding as keyof typeof BINDINGS_MAP]} <Table.Td>
/>
<TextInput <TextInput
id={`hotkey-${binding}`} id={`hotkey-${binding}`}
leftSection={<Icon icon="keyboard" />} leftSection={<Icon icon="keyboard" />}
onBlur={() => setSelected(null)} onBlur={() => setSelected(null)}
onChange={() => {}} onChange={() => {}}
onKeyDownCapture={(e) => { onKeyDownCapture={(e) => {
if (selected !== (binding as BindingActions)) return; if (selected !== (binding as BindingActions))
return;
handleSetHotkey(binding as BindingActions, e); handleSetHotkey(binding as BindingActions, e);
}} }}
readOnly readOnly
@@ -281,48 +299,68 @@ export const HotkeyManagerSettings = () => {
/> />
} }
style={{ style={{
opacity: selected === (binding as BindingActions) ? 0.8 : 1, opacity:
selected === (binding as BindingActions)
? 0.8
: 1,
outline: duplicateHotkeyMap.includes( outline: duplicateHotkeyMap.includes(
bindings[binding as keyof typeof BINDINGS_MAP].hotkey!, bindings[binding as keyof typeof BINDINGS_MAP]
.hotkey!,
) )
? '1px dashed red' ? '1px dashed red'
: undefined, : undefined,
}} }}
value={bindings[binding as keyof typeof BINDINGS_MAP].hotkey} value={
bindings[binding as keyof typeof BINDINGS_MAP]
.hotkey
}
/> />
</Table.Td>
{isElectron() && ( {isElectron() && (
<Table.Td>
<Checkbox <Checkbox
checked={ checked={
bindings[binding as keyof typeof BINDINGS_MAP].isGlobal bindings[binding as keyof typeof BINDINGS_MAP]
.isGlobal
} }
disabled={ disabled={
bindings[binding as keyof typeof BINDINGS_MAP] bindings[binding as keyof typeof BINDINGS_MAP]
.hotkey === '' .hotkey === ''
} }
onChange={(e) => onChange={(e) =>
handleSetGlobalHotkey(binding as BindingActions, e) handleSetGlobalHotkey(
binding as BindingActions,
e,
)
} }
size="md" size="md"
style={{ style={{
opacity: bindings[binding as keyof typeof BINDINGS_MAP] opacity: bindings[
.allowGlobal binding as keyof typeof BINDINGS_MAP
].allowGlobal
? 1 ? 1
: 0, : 0,
}} }}
/> />
</Table.Td>
)} )}
{bindings[binding as keyof typeof BINDINGS_MAP].hotkey && ( {bindings[binding as keyof typeof BINDINGS_MAP].hotkey && (
<Table.Td>
<ActionIcon <ActionIcon
icon="x" icon="x"
iconProps={{ iconProps={{
color: 'error', color: 'error',
}} }}
onClick={() => handleClearHotkey(binding as BindingActions)} onClick={() =>
handleClearHotkey(binding as BindingActions)
}
variant="transparent" variant="transparent"
/> />
</Table.Td>
)} )}
</Group> </Table.Tr>
))} ))}
</Table>
</div> </div>
</> </>
} }
+15
View File
@@ -115,6 +115,11 @@ const BindingActionsSchema = z.enum([
'volumeUp', 'volumeUp',
'zoomIn', 'zoomIn',
'zoomOut', 'zoomOut',
'listPlayDefault',
'listPlayNow',
'listPlayNext',
'listPlayLast',
'listNavigateToPage',
]); ]);
const DiscordDisplayTypeSchema = z.enum(['artist', 'feishin', 'song']); const DiscordDisplayTypeSchema = z.enum(['artist', 'feishin', 'song']);
@@ -647,6 +652,11 @@ export enum BindingActions {
FAVORITE_PREVIOUS_REMOVE = 'favoritePreviousRemove', FAVORITE_PREVIOUS_REMOVE = 'favoritePreviousRemove',
FAVORITE_PREVIOUS_TOGGLE = 'favoritePreviousToggle', FAVORITE_PREVIOUS_TOGGLE = 'favoritePreviousToggle',
GLOBAL_SEARCH = 'globalSearch', 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', LOCAL_SEARCH = 'localSearch',
MUTE = 'volumeMute', MUTE = 'volumeMute',
NAVIGATE_HOME = 'navigateHome', NAVIGATE_HOME = 'navigateHome',
@@ -986,6 +996,11 @@ const initialState: SettingsState = {
favoritePreviousRemove: { allowGlobal: true, hotkey: '', isGlobal: false }, favoritePreviousRemove: { allowGlobal: true, hotkey: '', isGlobal: false },
favoritePreviousToggle: { allowGlobal: true, hotkey: '', isGlobal: false }, favoritePreviousToggle: { allowGlobal: true, hotkey: '', isGlobal: false },
globalSearch: { allowGlobal: false, hotkey: 'mod+k', 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 }, localSearch: { allowGlobal: false, hotkey: 'mod+f', isGlobal: false },
navigateHome: { allowGlobal: false, hotkey: '', isGlobal: false }, navigateHome: { allowGlobal: false, hotkey: '', isGlobal: false },
next: { allowGlobal: true, hotkey: '', isGlobal: false }, next: { allowGlobal: true, hotkey: '', isGlobal: false },