feat: improve native macOS application menu (#2242)

This commit is contained in:
York
2026-07-18 10:20:17 +08:00
committed by GitHub
parent 75bdf60912
commit 7cff72910e
4 changed files with 215 additions and 132 deletions
+27 -18
View File
@@ -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();
});
+166 -114
View File
@@ -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);
}
}
}
+5
View File
@@ -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,
@@ -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;