From 7cff72910e8850604d637c31eeaee8fa24413f47 Mon Sep 17 00:00:00 2001 From: York Date: Sat, 18 Jul 2026 10:20:17 +0800 Subject: [PATCH] feat: improve native macOS application menu (#2242) --- src/main/index.ts | 45 ++-- src/main/menu.ts | 280 ++++++++++++-------- src/preload/utils.ts | 5 + src/renderer/hooks/use-native-menu-sync.tsx | 17 ++ 4 files changed, 215 insertions(+), 132 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 749bf89b3..617ff142c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -328,7 +328,7 @@ ipcMain.on('input-focus-state', (_event, focused: boolean) => { if (inputFocused === next) return; inputFocused = next; if (isMacOS()) { - rebuildMainMenu(); + updateMainMenu(); } }); @@ -387,23 +387,32 @@ export const getMainWindow = () => { return mainWindow; }; +const getMainMenuState = (): MenuPlaybackState => ({ + accelerators: playbackMenuAccelerators, + inputFocused, + playbackStatus: currentPlaybackStatus, + privateMode: currentPrivateMode, + repeatMode: currentRepeatMode, + shuffleEnabled: currentShuffleEnabled, + sidebarCollapsed: currentSidebarCollapsed, +}); + const rebuildMainMenu = () => { if (!menuBuilder || !mainWindow) return; - menuBuilder.buildMenu({ - accelerators: inputFocused ? {} : playbackMenuAccelerators, - playbackStatus: currentPlaybackStatus, - privateMode: currentPrivateMode, - repeatMode: currentRepeatMode, - shuffleEnabled: currentShuffleEnabled, - sidebarCollapsed: currentSidebarCollapsed, - }); + menuBuilder.buildMenu(getMainMenuState()); if (process.platform !== 'darwin') { Menu.setApplicationMenu(null); } }; +const updateMainMenu = () => { + if (!menuBuilder || !mainWindow) return; + + menuBuilder.updateMenu(getMainMenuState()); +}; + export const sendToastToRenderer = ({ message, type, @@ -961,11 +970,11 @@ ipcMain.on( } playbackMenuAccelerators = { + globalSearch: getMenuAccelerator(data, BindingActions.GLOBAL_SEARCH), next: getMenuAccelerator(data, BindingActions.NEXT), - playPause: - getMenuAccelerator(data, BindingActions.PLAY_PAUSE) || - getMenuAccelerator(data, BindingActions.PLAY) || - getMenuAccelerator(data, BindingActions.PAUSE), + pause: getMenuAccelerator(data, BindingActions.PAUSE), + play: getMenuAccelerator(data, BindingActions.PLAY), + playPause: getMenuAccelerator(data, BindingActions.PLAY_PAUSE), previous: getMenuAccelerator(data, BindingActions.PREVIOUS), repeat: getMenuAccelerator(data, BindingActions.TOGGLE_REPEAT), seekBackward: getMenuAccelerator(data, BindingActions.SKIP_BACKWARD), @@ -1186,7 +1195,7 @@ ipcMain.on('update-playback', (_event, status: PlayerStatus) => { if (!isMacOS()) return; - rebuildMainMenu(); + updateMainMenu(); }); ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => { @@ -1194,7 +1203,7 @@ ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => { if (!isMacOS()) return; - rebuildMainMenu(); + updateMainMenu(); }); ipcMain.on('update-shuffle', (_event, shuffle: boolean) => { @@ -1202,7 +1211,7 @@ ipcMain.on('update-shuffle', (_event, shuffle: boolean) => { if (!isMacOS()) return; - rebuildMainMenu(); + updateMainMenu(); }); ipcMain.on('update-private-mode', (_event, privateMode: boolean) => { @@ -1210,7 +1219,7 @@ ipcMain.on('update-private-mode', (_event, privateMode: boolean) => { if (!isMacOS()) return; - rebuildMainMenu(); + updateMainMenu(); }); ipcMain.on('update-sidebar-collapsed', (_event, collapsedSidebar: boolean) => { @@ -1218,5 +1227,5 @@ ipcMain.on('update-sidebar-collapsed', (_event, collapsedSidebar: boolean) => { if (!isMacOS()) return; - rebuildMainMenu(); + updateMainMenu(); }); diff --git a/src/main/menu.ts b/src/main/menu.ts index bfd37927f..e5fd0f8b9 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, Menu, MenuItemConstructorOptions, shell } from 'electron'; +import { BrowserWindow, Menu, MenuItemConstructorOptions, shell } from 'electron'; import packageJson from '../../package.json'; @@ -6,7 +6,10 @@ import { PlayerRepeat, PlayerStatus } from '/@/shared/types/types'; export type MenuPlaybackState = { accelerators?: { + globalSearch?: string; next?: string; + pause?: string; + play?: string; playPause?: string; previous?: string; repeat?: string; @@ -17,6 +20,7 @@ export type MenuPlaybackState = { volumeDown?: string; volumeUp?: string; }; + inputFocused?: boolean; playbackStatus?: PlayerStatus; privateMode?: boolean; repeatMode?: PlayerRepeat; @@ -24,12 +28,53 @@ export type MenuPlaybackState = { sidebarCollapsed?: boolean; }; -interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions { - selector?: string; - submenu?: DarwinMenuItemConstructorOptions[] | Menu; -} +const MENU_ITEM_IDS = { + next: 'playback-next', + pause: 'playback-pause', + play: 'playback-play', + previous: 'playback-previous', + privateMode: 'app-private-mode', + repeat: 'playback-repeat', + seekBackward: 'playback-seek-backward', + seekForward: 'playback-seek-forward', + shuffle: 'playback-shuffle', + sidebarCollapsed: 'view-sidebar-collapsed', + stop: 'playback-stop', + volumeDown: 'playback-volume-down', + volumeUp: 'playback-volume-up', +} as const; + +const NON_TYPING_MODIFIERS = new Set([ + 'alt', + 'cmd', + 'command', + 'commandorcontrol', + 'control', + 'ctrl', + 'meta', + 'option', + 'super', +]); + +const hasTypingSensitiveAccelerator = (accelerator?: string): boolean => { + if (!accelerator) return false; + + const parts = accelerator.toLowerCase().split('+'); + const key = parts.at(-1) || ''; + + if (/^f(?:[1-9]|1\d|2[0-4])$/.test(key) || /^(?:media|volume)/.test(key)) { + return false; + } + + return !parts.some((part) => NON_TYPING_MODIFIERS.has(part)); +}; + +const isPlaybackItemEnabled = (inputFocused: boolean, accelerator?: string): boolean => { + return !inputFocused || !hasTypingSensitiveAccelerator(accelerator); +}; export default class MenuBuilder { + applicationMenu: Menu | null = null; developmentEnvironmentSetup = false; mainWindow: BrowserWindow; @@ -39,6 +84,7 @@ export default class MenuBuilder { buildDarwinTemplate({ accelerators, + inputFocused = false, playbackStatus = PlayerStatus.PAUSED, privateMode = false, repeatMode = PlayerRepeat.NONE, @@ -48,13 +94,10 @@ export default class MenuBuilder { const isPlaying = playbackStatus === PlayerStatus.PLAYING; const isRepeatEnabled = repeatMode !== PlayerRepeat.NONE; - const subMenuAbout: DarwinMenuItemConstructorOptions = { + const subMenuAbout: MenuItemConstructorOptions = { label: 'Electron', submenu: [ - { - label: 'About Feishin', - selector: 'orderFrontStandardAboutPanel:', - }, + { role: 'about' }, { type: 'separator' }, { accelerator: 'Command+,', @@ -68,61 +111,44 @@ export default class MenuBuilder { click: () => { this.mainWindow.webContents.send('renderer-open-manage-servers'); }, - label: 'Manage servers', + label: 'Manage Servers', }, { checked: privateMode, click: () => { this.mainWindow.webContents.send('renderer-toggle-private-mode'); }, - label: 'Private session', + id: MENU_ITEM_IDS.privateMode, + label: 'Private Session', type: 'checkbox', }, { type: 'separator' }, - { label: 'Services', submenu: [] }, + { role: 'services' }, { type: 'separator' }, - { - accelerator: 'Command+H', - label: 'Hide Feishin', - selector: 'hide:', - }, - { - accelerator: 'Command+Shift+H', - label: 'Hide Others', - selector: 'hideOtherApplications:', - }, - { label: 'Show All', selector: 'unhideAllApplications:' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, { type: 'separator' }, - { - accelerator: 'Command+Q', - click: () => { - app.quit(); - }, - label: 'Quit', - }, + { role: 'quit' }, ], }; - const subMenuEdit: DarwinMenuItemConstructorOptions = { - label: 'Edit', + const subMenuFile: MenuItemConstructorOptions = { + label: 'File', submenu: [ - { accelerator: 'Command+Z', label: 'Undo', selector: 'undo:' }, - { accelerator: 'Shift+Command+Z', label: 'Redo', selector: 'redo:' }, - { type: 'separator' }, - { accelerator: 'Command+X', label: 'Cut', selector: 'cut:' }, - { accelerator: 'Command+C', label: 'Copy', selector: 'copy:' }, - { accelerator: 'Command+V', label: 'Paste', selector: 'paste:' }, { - accelerator: 'Command+A', - label: 'Select All', - selector: 'selectAll:', + click: () => { + this.mainWindow.webContents.send('renderer-open-create-playlist'); + }, + label: 'Create Playlist...', }, ], }; - const subMenuViewDev: MenuItemConstructorOptions = { + const subMenuEdit: MenuItemConstructorOptions = { role: 'editMenu' }; + const subMenuView: MenuItemConstructorOptions = { label: 'View', submenu: [ { - accelerator: 'Command+K', + accelerator: accelerators?.globalSearch, click: () => { this.mainWindow.webContents.send('renderer-open-command-palette'); }, @@ -133,83 +159,47 @@ export default class MenuBuilder { click: () => { this.mainWindow.webContents.send('renderer-toggle-sidebar'); }, - label: 'Collapse sidebar', + id: MENU_ITEM_IDS.sidebarCollapsed, + label: 'Collapse Sidebar', type: 'checkbox', }, { type: 'separator' }, + { role: 'togglefullscreen' }, { - accelerator: 'Command+R', - click: () => { - this.mainWindow.webContents.reload(); - }, - label: 'Reload', - }, - { - accelerator: 'Ctrl+Command+F', - click: () => { - this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); - }, - label: 'Toggle Full Screen', - }, - { - accelerator: 'Alt+Command+I', - click: () => { - this.mainWindow.webContents.toggleDevTools(); - }, - label: 'Toggle Developer Tools', + label: 'Developer', + submenu: [{ role: 'reload' }, { role: 'toggleDevTools' }], }, ], }; - const subMenuViewProd: MenuItemConstructorOptions = { - label: 'View', - submenu: [ - { - accelerator: 'Command+K', - click: () => { - this.mainWindow.webContents.send('renderer-open-command-palette'); - }, - label: 'Command Palette...', - }, - { - checked: sidebarCollapsed, - click: () => { - this.mainWindow.webContents.send('renderer-toggle-sidebar'); - }, - label: 'Collapse sidebar', - type: 'checkbox', - }, - { type: 'separator' }, - { - accelerator: 'Ctrl+Command+F', - click: () => { - this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); - }, - label: 'Toggle Full Screen', - }, - ], - }; - const subMenuWindow: DarwinMenuItemConstructorOptions = { - label: 'Window', - submenu: [ - { - accelerator: 'Command+M', - label: 'Minimize', - selector: 'performMiniaturize:', - }, - { accelerator: 'Command+W', label: 'Close', selector: 'performClose:' }, - { type: 'separator' }, - { label: 'Bring All to Front', selector: 'arrangeInFront:' }, - ], - }; + const subMenuWindow: MenuItemConstructorOptions = { role: 'windowMenu' }; const subMenuPlayback: MenuItemConstructorOptions = { label: 'Playback', submenu: [ { - accelerator: accelerators?.playPause, + accelerator: accelerators?.play || accelerators?.playPause, click: () => { - this.mainWindow.webContents.send('renderer-player-play-pause'); + this.mainWindow.webContents.send('renderer-player-play'); }, - label: isPlaying ? 'Pause' : 'Play', + enabled: isPlaybackItemEnabled( + inputFocused, + accelerators?.play || accelerators?.playPause, + ), + id: MENU_ITEM_IDS.play, + label: 'Play', + visible: !isPlaying, + }, + { + accelerator: accelerators?.pause || accelerators?.playPause, + click: () => { + this.mainWindow.webContents.send('renderer-player-pause'); + }, + enabled: isPlaybackItemEnabled( + inputFocused, + accelerators?.pause || accelerators?.playPause, + ), + id: MENU_ITEM_IDS.pause, + label: 'Pause', + visible: isPlaying, }, { type: 'separator' }, { @@ -217,6 +207,8 @@ export default class MenuBuilder { click: () => { this.mainWindow.webContents.send('renderer-player-next'); }, + enabled: isPlaybackItemEnabled(inputFocused, accelerators?.next), + id: MENU_ITEM_IDS.next, label: 'Next', }, { @@ -224,6 +216,8 @@ export default class MenuBuilder { click: () => { this.mainWindow.webContents.send('renderer-player-previous'); }, + enabled: isPlaybackItemEnabled(inputFocused, accelerators?.previous), + id: MENU_ITEM_IDS.previous, label: 'Previous', }, { @@ -231,6 +225,8 @@ export default class MenuBuilder { click: () => { this.mainWindow.webContents.send('renderer-player-skip-forward'); }, + enabled: isPlaybackItemEnabled(inputFocused, accelerators?.seekForward), + id: MENU_ITEM_IDS.seekForward, label: 'Seek Forward', }, { @@ -238,6 +234,8 @@ export default class MenuBuilder { click: () => { this.mainWindow.webContents.send('renderer-player-skip-backward'); }, + enabled: isPlaybackItemEnabled(inputFocused, accelerators?.seekBackward), + id: MENU_ITEM_IDS.seekBackward, label: 'Seek Backforward', }, { type: 'separator' }, @@ -247,6 +245,8 @@ export default class MenuBuilder { click: () => { this.mainWindow.webContents.send('renderer-player-toggle-shuffle'); }, + enabled: isPlaybackItemEnabled(inputFocused, accelerators?.shuffle), + id: MENU_ITEM_IDS.shuffle, label: 'Shuffle', type: 'checkbox', }, @@ -256,6 +256,8 @@ export default class MenuBuilder { click: () => { this.mainWindow.webContents.send('renderer-player-toggle-repeat'); }, + enabled: isPlaybackItemEnabled(inputFocused, accelerators?.repeat), + id: MENU_ITEM_IDS.repeat, label: 'Repeat', type: 'checkbox', }, @@ -265,6 +267,8 @@ export default class MenuBuilder { click: () => { this.mainWindow.webContents.send('renderer-player-stop'); }, + enabled: isPlaybackItemEnabled(inputFocused, accelerators?.stop), + id: MENU_ITEM_IDS.stop, label: 'Stop', }, { type: 'separator' }, @@ -273,6 +277,8 @@ export default class MenuBuilder { click: () => { this.mainWindow.webContents.send('renderer-player-volume-up'); }, + enabled: isPlaybackItemEnabled(inputFocused, accelerators?.volumeUp), + id: MENU_ITEM_IDS.volumeUp, label: 'Volume Up', }, { @@ -280,12 +286,14 @@ export default class MenuBuilder { click: () => { this.mainWindow.webContents.send('renderer-player-volume-down'); }, + enabled: isPlaybackItemEnabled(inputFocused, accelerators?.volumeDown), + id: MENU_ITEM_IDS.volumeDown, label: 'Volume Down', }, ], }; const subMenuHelp: MenuItemConstructorOptions = { - label: 'Help', + role: 'help', submenu: [ { click() { @@ -323,13 +331,9 @@ export default class MenuBuilder { ], }; - const subMenuView = - process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true' - ? subMenuViewDev - : subMenuViewProd; - return [ subMenuAbout, + subMenuFile, subMenuEdit, subMenuView, subMenuPlayback, @@ -452,6 +456,7 @@ export default class MenuBuilder { : this.buildDefaultTemplate(); const menu = Menu.buildFromTemplate(template); + this.applicationMenu = menu; Menu.setApplicationMenu(menu); return menu; @@ -478,4 +483,51 @@ export default class MenuBuilder { ]).popup({ window: this.mainWindow }); }); } + + updateMenu({ + accelerators, + inputFocused = false, + playbackStatus = PlayerStatus.PAUSED, + privateMode = false, + repeatMode = PlayerRepeat.NONE, + shuffleEnabled = false, + sidebarCollapsed = false, + }: MenuPlaybackState = {}): void { + if (process.platform !== 'darwin' || !this.applicationMenu) { + return; + } + + const privateModeItem = this.applicationMenu.getMenuItemById(MENU_ITEM_IDS.privateMode); + const sidebarItem = this.applicationMenu.getMenuItemById(MENU_ITEM_IDS.sidebarCollapsed); + const pauseItem = this.applicationMenu.getMenuItemById(MENU_ITEM_IDS.pause); + const playItem = this.applicationMenu.getMenuItemById(MENU_ITEM_IDS.play); + const repeatItem = this.applicationMenu.getMenuItemById(MENU_ITEM_IDS.repeat); + const shuffleItem = this.applicationMenu.getMenuItemById(MENU_ITEM_IDS.shuffle); + + if (privateModeItem) privateModeItem.checked = privateMode; + if (sidebarItem) sidebarItem.checked = sidebarCollapsed; + if (pauseItem) pauseItem.visible = playbackStatus === PlayerStatus.PLAYING; + if (playItem) playItem.visible = playbackStatus !== PlayerStatus.PLAYING; + if (repeatItem) repeatItem.checked = repeatMode !== PlayerRepeat.NONE; + if (shuffleItem) shuffleItem.checked = shuffleEnabled; + + const playbackAccelerators = [ + [MENU_ITEM_IDS.play, accelerators?.play || accelerators?.playPause], + [MENU_ITEM_IDS.pause, accelerators?.pause || accelerators?.playPause], + [MENU_ITEM_IDS.next, accelerators?.next], + [MENU_ITEM_IDS.previous, accelerators?.previous], + [MENU_ITEM_IDS.seekForward, accelerators?.seekForward], + [MENU_ITEM_IDS.seekBackward, accelerators?.seekBackward], + [MENU_ITEM_IDS.shuffle, accelerators?.shuffle], + [MENU_ITEM_IDS.repeat, accelerators?.repeat], + [MENU_ITEM_IDS.stop, accelerators?.stop], + [MENU_ITEM_IDS.volumeUp, accelerators?.volumeUp], + [MENU_ITEM_IDS.volumeDown, accelerators?.volumeDown], + ] as const; + + for (const [id, accelerator] of playbackAccelerators) { + const item = this.applicationMenu.getMenuItemById(id); + if (item) item.enabled = isPlaybackItemEnabled(inputFocused, accelerator); + } + } } diff --git a/src/preload/utils.ts b/src/preload/utils.ts index a530e7e8d..9fa245a8c 100644 --- a/src/preload/utils.ts +++ b/src/preload/utils.ts @@ -138,6 +138,10 @@ const rendererOpenManageServers = (cb: () => void) => { ipcRenderer.on('renderer-open-manage-servers', () => cb()); }; +const rendererOpenCreatePlaylist = (cb: () => void) => { + ipcRenderer.on('renderer-open-create-playlist', () => cb()); +}; + const rendererTogglePrivateMode = (cb: () => void) => { ipcRenderer.on('renderer-toggle-private-mode', cb); }; @@ -175,6 +179,7 @@ export const utils = { readLocalImage, readSongMetadataBatch, rendererOpenCommandPalette, + rendererOpenCreatePlaylist, rendererOpenManageServers, rendererOpenReleaseNotes, rendererOpenSettings, diff --git a/src/renderer/hooks/use-native-menu-sync.tsx b/src/renderer/hooks/use-native-menu-sync.tsx index 0801cac17..cdbfab0ea 100644 --- a/src/renderer/hooks/use-native-menu-sync.tsx +++ b/src/renderer/hooks/use-native-menu-sync.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import packageJson from '../../../package.json'; +import { openCreatePlaylistModal } from '/@/renderer/features/playlists/components/create-playlist-form'; import { ServerList } from '/@/renderer/features/servers/components/server-list'; import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal'; import { openReleaseNotesModal } from '/@/renderer/release-notes-modal'; @@ -12,6 +13,7 @@ import { useAppStore, useAppStoreActions, useCommandPalette, + useCurrentServer, usePlayerHydrated, usePlayerRepeat, usePlayerShuffle, @@ -27,6 +29,7 @@ export const useNativeMenuSync = () => { const sidebar = useAppStore((state) => state.sidebar); const { setPrivateMode, setSideBar } = useAppStoreActions(); const { open: openCommandPalette } = useCommandPalette(); + const server = useCurrentServer(); const playerHydrated = usePlayerHydrated(); const playerRepeat = usePlayerRepeat(); const playerShuffle = usePlayerShuffle(); @@ -60,6 +63,20 @@ export const useNativeMenuSync = () => { }; }, [openCommandPalette]); + useEffect(() => { + if (!isElectron()) { + return undefined; + } + + window.api.utils.rendererOpenCreatePlaylist(() => { + openCreatePlaylistModal(server); + }); + + return () => { + ipc?.removeAllListeners('renderer-open-create-playlist'); + }; + }, [server]); + useEffect(() => { if (!isElectron()) { return undefined;