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_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",
@@ -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,
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 (
<motion.div
@@ -32,6 +32,7 @@ import {
useItemListStateSubscription,
} from '/@/renderer/components/item-list/helpers/item-list-state';
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 { useStickyTableHeader } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header';
import {
@@ -42,7 +43,6 @@ import {
import { PlayerContext, usePlayer } from '/@/renderer/features/player/context/player-context';
import { animationProps } from '/@/shared/components/animations/animation-props';
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';
import { TableColumn } from '/@/shared/types/types';
@@ -2262,20 +2262,12 @@ const BaseItemTableList = ({
stickyGroupTop,
]);
useHotkeys([
[
'mod+a',
() => {
if (focused) {
if (internalState.isAllSelected()) {
internalState.deselectAll();
} else {
internalState.selectAll();
}
}
},
],
]);
useListHotkeys({
controls,
focused,
internalState,
itemType,
});
return (
<motion.div
@@ -15,8 +15,8 @@ import { useSettingSearchContext } from '/@/renderer/features/settings/context/s
import { BindingActions, useHotkeySettings, useSettingsStoreActions } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Table } from '/@/shared/components/table/table';
import { TextInput } from '/@/shared/components/text-input/text-input';
const ipc = isElectron() ? window.api.ipc : null;
@@ -55,6 +55,23 @@ const BINDINGS_MAP: Record<BindingActions, string> = {
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,20 +268,21 @@ export const HotkeyManagerSettings = () => {
title={t('setting.applicationHotkeys', { postProcess: 'sentenceCase' })}
/>
<div className={styles.container}>
<Table withColumnBorders withRowBorders>
{filteredBindings.map((binding) => (
<Group key={`hotkey-${binding}`} wrap="nowrap">
<TextInput
readOnly
style={{ userSelect: 'none' }}
value={BINDINGS_MAP[binding as keyof typeof BINDINGS_MAP]}
/>
<Table.Tr key={`hotkey-${binding}`}>
<Table.Td style={{ userSelect: 'none' }}>
{BINDINGS_MAP[binding as keyof typeof BINDINGS_MAP]}
</Table.Td>
<Table.Td>
<TextInput
id={`hotkey-${binding}`}
leftSection={<Icon icon="keyboard" />}
onBlur={() => setSelected(null)}
onChange={() => {}}
onKeyDownCapture={(e) => {
if (selected !== (binding as BindingActions)) return;
if (selected !== (binding as BindingActions))
return;
handleSetHotkey(binding as BindingActions, e);
}}
readOnly
@@ -281,48 +299,68 @@ export const HotkeyManagerSettings = () => {
/>
}
style={{
opacity: selected === (binding as BindingActions) ? 0.8 : 1,
opacity:
selected === (binding as BindingActions)
? 0.8
: 1,
outline: duplicateHotkeyMap.includes(
bindings[binding as keyof typeof BINDINGS_MAP].hotkey!,
bindings[binding as keyof typeof BINDINGS_MAP]
.hotkey!,
)
? '1px dashed red'
: undefined,
}}
value={bindings[binding as keyof typeof BINDINGS_MAP].hotkey}
value={
bindings[binding as keyof typeof BINDINGS_MAP]
.hotkey
}
/>
</Table.Td>
{isElectron() && (
<Table.Td>
<Checkbox
checked={
bindings[binding as keyof typeof BINDINGS_MAP].isGlobal
bindings[binding as keyof typeof BINDINGS_MAP]
.isGlobal
}
disabled={
bindings[binding as keyof typeof BINDINGS_MAP]
.hotkey === ''
}
onChange={(e) =>
handleSetGlobalHotkey(binding as BindingActions, e)
handleSetGlobalHotkey(
binding as BindingActions,
e,
)
}
size="md"
style={{
opacity: bindings[binding as keyof typeof BINDINGS_MAP]
.allowGlobal
opacity: bindings[
binding as keyof typeof BINDINGS_MAP
].allowGlobal
? 1
: 0,
}}
/>
</Table.Td>
)}
{bindings[binding as keyof typeof BINDINGS_MAP].hotkey && (
<Table.Td>
<ActionIcon
icon="x"
iconProps={{
color: 'error',
}}
onClick={() => handleClearHotkey(binding as BindingActions)}
onClick={() =>
handleClearHotkey(binding as BindingActions)
}
variant="transparent"
/>
</Table.Td>
)}
</Group>
</Table.Tr>
))}
</Table>
</div>
</>
}
+15
View File
@@ -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 },