diff --git a/src/main/index.ts b/src/main/index.ts index b6237294f..edee77e4d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -29,7 +29,7 @@ import packageJson from '../../package.json'; import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys'; import { shutdownServer } from './features/core/remote'; import { store } from './features/core/settings'; -import MenuBuilder from './menu'; +import MenuBuilder, { MenuPlaybackState } from './menu'; import { autoUpdaterLogInterface, createLog, @@ -41,7 +41,7 @@ import { } from './utils'; import './features'; -import { PlayerType, TitleTheme } from '/@/shared/types/types'; +import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types'; const ALPHA_UPDATER_CONFIG: { bucket: string; @@ -277,6 +277,13 @@ let tray: null | Tray = null; let exitFromTray = false; let forceQuit = false; let powerSaveBlockerId: null | number = null; +let menuBuilder: MenuBuilder | null = null; +let currentPlaybackStatus: PlayerStatus = PlayerStatus.PAUSED; +let currentPrivateMode = false; +let currentRepeatMode: PlayerRepeat = PlayerRepeat.NONE; +let currentSidebarCollapsed = false; +let currentShuffleEnabled = false; +let playbackMenuAccelerators: MenuPlaybackState['accelerators'] = {}; if (process.env.NODE_ENV === 'production') { import('source-map-support').then((sourceMapSupport) => { @@ -333,6 +340,23 @@ export const getMainWindow = () => { return mainWindow; }; +const rebuildMainMenu = () => { + if (!menuBuilder || !mainWindow) return; + + menuBuilder.buildMenu({ + accelerators: playbackMenuAccelerators, + playbackStatus: currentPlaybackStatus, + privateMode: currentPrivateMode, + repeatMode: currentRepeatMode, + shuffleEnabled: currentShuffleEnabled, + sidebarCollapsed: currentSidebarCollapsed, + }); + + if (process.platform !== 'darwin') { + Menu.setApplicationMenu(null); + } +}; + export const sendToastToRenderer = ({ message, type, @@ -699,12 +723,8 @@ async function createWindow(first = true): Promise { }); } - const menuBuilder = new MenuBuilder(mainWindow); - menuBuilder.buildMenu(); - - if (process.platform !== 'darwin') { - Menu.setApplicationMenu(null); - } + menuBuilder = new MenuBuilder(mainWindow); + rebuildMainMenu(); // Open URLs in the user's browser mainWindow.webContents.setWindowOpenHandler((edata) => { @@ -782,6 +802,17 @@ enum BindingActions { VOLUME_UP = 'volumeUp', } +const getMenuAccelerator = ( + data: Record, + action: BindingActions, +) => { + const hotkey = data[action]?.hotkey; + + if (!hotkey) return undefined; + + return hotkeyToElectronAccelerator(hotkey); +}; + const HOTKEY_ACTIONS: Record void> = { [BindingActions.GLOBAL_SEARCH]: () => {}, [BindingActions.LOCAL_SEARCH]: () => {}, @@ -835,6 +866,26 @@ ipcMain.on( } } + playbackMenuAccelerators = { + next: getMenuAccelerator(data, BindingActions.NEXT), + playPause: + getMenuAccelerator(data, BindingActions.PLAY_PAUSE) || + getMenuAccelerator(data, BindingActions.PLAY) || + getMenuAccelerator(data, BindingActions.PAUSE), + previous: getMenuAccelerator(data, BindingActions.PREVIOUS), + repeat: getMenuAccelerator(data, BindingActions.TOGGLE_REPEAT), + seekBackward: getMenuAccelerator(data, BindingActions.SKIP_BACKWARD), + seekForward: getMenuAccelerator(data, BindingActions.SKIP_FORWARD), + shuffle: getMenuAccelerator(data, BindingActions.SHUFFLE), + stop: getMenuAccelerator(data, BindingActions.STOP), + volumeDown: getMenuAccelerator(data, BindingActions.VOLUME_DOWN), + volumeUp: getMenuAccelerator(data, BindingActions.VOLUME_UP), + }; + + if (isMacOS()) { + rebuildMainMenu(); + } + const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean; if (globalMediaKeysEnabled) { @@ -975,3 +1026,43 @@ if (!ipcMain.eventNames().includes('open-application-directory')) { shell.openPath(userDataPath); }); } + +ipcMain.on('update-playback', (_event, status: PlayerStatus) => { + currentPlaybackStatus = status; + + if (!isMacOS()) return; + + rebuildMainMenu(); +}); + +ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => { + currentRepeatMode = repeat; + + if (!isMacOS()) return; + + rebuildMainMenu(); +}); + +ipcMain.on('update-shuffle', (_event, shuffle: boolean) => { + currentShuffleEnabled = shuffle; + + if (!isMacOS()) return; + + rebuildMainMenu(); +}); + +ipcMain.on('update-private-mode', (_event, privateMode: boolean) => { + currentPrivateMode = privateMode; + + if (!isMacOS()) return; + + rebuildMainMenu(); +}); + +ipcMain.on('update-sidebar-collapsed', (_event, collapsedSidebar: boolean) => { + currentSidebarCollapsed = collapsedSidebar; + + if (!isMacOS()) return; + + rebuildMainMenu(); +}); diff --git a/src/main/menu.ts b/src/main/menu.ts index 4fb486eea..bfd37927f 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,18 +1,53 @@ import { app, BrowserWindow, Menu, MenuItemConstructorOptions, shell } from 'electron'; +import packageJson from '../../package.json'; + +import { PlayerRepeat, PlayerStatus } from '/@/shared/types/types'; + +export type MenuPlaybackState = { + accelerators?: { + next?: string; + playPause?: string; + previous?: string; + repeat?: string; + seekBackward?: string; + seekForward?: string; + shuffle?: string; + stop?: string; + volumeDown?: string; + volumeUp?: string; + }; + playbackStatus?: PlayerStatus; + privateMode?: boolean; + repeatMode?: PlayerRepeat; + shuffleEnabled?: boolean; + sidebarCollapsed?: boolean; +}; + interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions { selector?: string; submenu?: DarwinMenuItemConstructorOptions[] | Menu; } export default class MenuBuilder { + developmentEnvironmentSetup = false; mainWindow: BrowserWindow; constructor(mainWindow: BrowserWindow) { this.mainWindow = mainWindow; } - buildDarwinTemplate(): MenuItemConstructorOptions[] { + buildDarwinTemplate({ + accelerators, + playbackStatus = PlayerStatus.PAUSED, + privateMode = false, + repeatMode = PlayerRepeat.NONE, + shuffleEnabled = false, + sidebarCollapsed = false, + }: MenuPlaybackState = {}): MenuItemConstructorOptions[] { + const isPlaying = playbackStatus === PlayerStatus.PLAYING; + const isRepeatEnabled = repeatMode !== PlayerRepeat.NONE; + const subMenuAbout: DarwinMenuItemConstructorOptions = { label: 'Electron', submenu: [ @@ -29,6 +64,21 @@ export default class MenuBuilder { label: 'Settings', }, { type: 'separator' }, + { + click: () => { + this.mainWindow.webContents.send('renderer-open-manage-servers'); + }, + label: 'Manage servers', + }, + { + checked: privateMode, + click: () => { + this.mainWindow.webContents.send('renderer-toggle-private-mode'); + }, + label: 'Private session', + type: 'checkbox', + }, + { type: 'separator' }, { label: 'Services', submenu: [] }, { type: 'separator' }, { @@ -71,6 +121,22 @@ export default class MenuBuilder { const subMenuViewDev: 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: 'Command+R', click: () => { @@ -97,6 +163,22 @@ export default class MenuBuilder { 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: () => { @@ -119,6 +201,89 @@ export default class MenuBuilder { { label: 'Bring All to Front', selector: 'arrangeInFront:' }, ], }; + const subMenuPlayback: MenuItemConstructorOptions = { + label: 'Playback', + submenu: [ + { + accelerator: accelerators?.playPause, + click: () => { + this.mainWindow.webContents.send('renderer-player-play-pause'); + }, + label: isPlaying ? 'Pause' : 'Play', + }, + { type: 'separator' }, + { + accelerator: accelerators?.next, + click: () => { + this.mainWindow.webContents.send('renderer-player-next'); + }, + label: 'Next', + }, + { + accelerator: accelerators?.previous, + click: () => { + this.mainWindow.webContents.send('renderer-player-previous'); + }, + label: 'Previous', + }, + { + accelerator: accelerators?.seekForward, + click: () => { + this.mainWindow.webContents.send('renderer-player-skip-forward'); + }, + label: 'Seek Forward', + }, + { + accelerator: accelerators?.seekBackward, + click: () => { + this.mainWindow.webContents.send('renderer-player-skip-backward'); + }, + label: 'Seek Backforward', + }, + { type: 'separator' }, + { + accelerator: accelerators?.shuffle, + checked: shuffleEnabled, + click: () => { + this.mainWindow.webContents.send('renderer-player-toggle-shuffle'); + }, + label: 'Shuffle', + type: 'checkbox', + }, + { + accelerator: accelerators?.repeat, + checked: isRepeatEnabled, + click: () => { + this.mainWindow.webContents.send('renderer-player-toggle-repeat'); + }, + label: 'Repeat', + type: 'checkbox', + }, + { type: 'separator' }, + { + accelerator: accelerators?.stop, + click: () => { + this.mainWindow.webContents.send('renderer-player-stop'); + }, + label: 'Stop', + }, + { type: 'separator' }, + { + accelerator: accelerators?.volumeUp, + click: () => { + this.mainWindow.webContents.send('renderer-player-volume-up'); + }, + label: 'Volume Up', + }, + { + accelerator: accelerators?.volumeDown, + click: () => { + this.mainWindow.webContents.send('renderer-player-volume-down'); + }, + label: 'Volume Down', + }, + ], + }; const subMenuHelp: MenuItemConstructorOptions = { label: 'Help', submenu: [ @@ -148,6 +313,13 @@ export default class MenuBuilder { }, label: 'Search Issues', }, + { type: 'separator' }, + { + click: () => { + this.mainWindow.webContents.send('renderer-open-release-notes'); + }, + label: 'Version ' + packageJson.version, + }, ], }; @@ -156,7 +328,14 @@ export default class MenuBuilder { ? subMenuViewDev : subMenuViewProd; - return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp]; + return [ + subMenuAbout, + subMenuEdit, + subMenuView, + subMenuPlayback, + subMenuWindow, + subMenuHelp, + ]; } buildDefaultTemplate(): MenuItemConstructorOptions[] { @@ -262,14 +441,14 @@ export default class MenuBuilder { return templateDefault; } - buildMenu(): Menu { + buildMenu(playbackState: MenuPlaybackState = {}): Menu { if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') { this.setupDevelopmentEnvironment(); } const template = process.platform === 'darwin' - ? this.buildDarwinTemplate() + ? this.buildDarwinTemplate(playbackState) : this.buildDefaultTemplate(); const menu = Menu.buildFromTemplate(template); @@ -279,6 +458,13 @@ export default class MenuBuilder { } setupDevelopmentEnvironment(): void { + // buildMenu can run multiple times as menu state updates; attach this once. + if (this.developmentEnvironmentSetup) { + return; + } + + this.developmentEnvironmentSetup = true; + this.mainWindow.webContents.on('context-menu', (_, props) => { const { x, y } = props; diff --git a/src/preload/utils.ts b/src/preload/utils.ts index 130bc285f..4ca9970af 100644 --- a/src/preload/utils.ts +++ b/src/preload/utils.ts @@ -65,6 +65,26 @@ const rendererOpenSettings = (cb: (event: IpcRendererEvent) => void) => { ipcRenderer.on('renderer-open-settings', cb); }; +const rendererOpenCommandPalette = (cb: (event: IpcRendererEvent) => void) => { + ipcRenderer.on('renderer-open-command-palette', cb); +}; + +const rendererOpenManageServers = (cb: (event: IpcRendererEvent) => void) => { + ipcRenderer.on('renderer-open-manage-servers', cb); +}; + +const rendererTogglePrivateMode = (cb: (event: IpcRendererEvent) => void) => { + ipcRenderer.on('renderer-toggle-private-mode', cb); +}; + +const rendererToggleSidebar = (cb: (event: IpcRendererEvent) => void) => { + ipcRenderer.on('renderer-toggle-sidebar', cb); +}; + +const rendererOpenReleaseNotes = (cb: (event: IpcRendererEvent) => void) => { + ipcRenderer.on('renderer-open-release-notes', cb); +}; + export const utils = { checkForUpdates, disableAutoUpdates, @@ -78,7 +98,12 @@ export const utils = { openApplicationDirectory, openItem, playerErrorListener, + rendererOpenCommandPalette, + rendererOpenManageServers, + rendererOpenReleaseNotes, rendererOpenSettings, + rendererTogglePrivateMode, + rendererToggleSidebar, }; export type Utils = typeof utils; diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 7e4547b60..d32580dc0 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -10,9 +10,9 @@ import isElectron from 'is-electron'; import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react'; import i18n from '/@/i18n/i18n'; -import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal'; import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context'; import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates'; +import { useNativeMenuSync } from '/@/renderer/hooks/use-native-menu-sync'; import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main'; import { AppRouter } from '/@/renderer/router/app-router'; import { useCssSettings, useHotkeySettings, useLanguage } from '/@/renderer/store'; @@ -97,7 +97,7 @@ const AppEffects = () => ( - + ); @@ -170,20 +170,8 @@ const LanguageEffect = () => { return null; }; -const OpenSettingsEffect = () => { - useEffect(() => { - if (isElectron()) { - window.api.utils.rendererOpenSettings(() => { - openSettingsModal(); - }); - - return () => { - ipc?.removeAllListeners('renderer-open-settings'); - }; - } - - return undefined; - }, []); +const NativeMenuSyncEffect = () => { + useNativeMenuSync(); return null; }; diff --git a/src/renderer/hooks/use-native-menu-sync.tsx b/src/renderer/hooks/use-native-menu-sync.tsx new file mode 100644 index 000000000..5a1818f0b --- /dev/null +++ b/src/renderer/hooks/use-native-menu-sync.tsx @@ -0,0 +1,156 @@ +import { openModal } from '@mantine/modals'; +import isElectron from 'is-electron'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import packageJson from '../../../package.json'; + +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'; +import { + useAppStore, + useAppStoreActions, + useCommandPalette, + usePlayerHydrated, + usePlayerRepeat, + usePlayerShuffle, + usePlayerStatus, +} from '/@/renderer/store'; +import { PlayerShuffle } from '/@/shared/types/types'; + +const ipc = isElectron() ? window.api.ipc : null; + +export const useNativeMenuSync = () => { + const { t } = useTranslation(); + const privateMode = useAppStore((state) => state.privateMode); + const sidebar = useAppStore((state) => state.sidebar); + const { setPrivateMode, setSideBar } = useAppStoreActions(); + const { open: openCommandPalette } = useCommandPalette(); + const playerHydrated = usePlayerHydrated(); + const playerRepeat = usePlayerRepeat(); + const playerShuffle = usePlayerShuffle(); + const playerStatus = usePlayerStatus(); + + useEffect(() => { + if (!isElectron()) { + return undefined; + } + + window.api.utils.rendererOpenSettings(() => { + openSettingsModal(); + }); + + return () => { + ipc?.removeAllListeners('renderer-open-settings'); + }; + }, []); + + useEffect(() => { + if (!isElectron()) { + return undefined; + } + + window.api.utils.rendererOpenCommandPalette(() => { + openCommandPalette(); + }); + + return () => { + ipc?.removeAllListeners('renderer-open-command-palette'); + }; + }, [openCommandPalette]); + + useEffect(() => { + if (!isElectron()) { + return undefined; + } + + window.api.utils.rendererOpenManageServers(() => { + openModal({ + children: , + title: t('page.manageServers.title', { postProcess: 'titleCase' }), + }); + }); + + return () => { + ipc?.removeAllListeners('renderer-open-manage-servers'); + }; + }, [t]); + + useEffect(() => { + if (!isElectron()) { + return undefined; + } + + window.api.utils.rendererTogglePrivateMode(() => { + setPrivateMode(!privateMode); + }); + + return () => { + ipc?.removeAllListeners('renderer-toggle-private-mode'); + }; + }, [privateMode, setPrivateMode]); + + useEffect(() => { + if (!isElectron()) { + return undefined; + } + + window.api.utils.rendererToggleSidebar(() => { + setSideBar({ collapsed: !sidebar.collapsed }); + }); + + return () => { + ipc?.removeAllListeners('renderer-toggle-sidebar'); + }; + }, [setSideBar, sidebar.collapsed]); + + useEffect(() => { + if (!isElectron()) { + return; + } + + if (!playerHydrated) { + return; + } + + ipc?.send('update-playback', playerStatus); + ipc?.send('update-repeat', playerRepeat); + ipc?.send('update-shuffle', playerShuffle !== PlayerShuffle.NONE); + }, [playerHydrated, playerRepeat, playerShuffle, playerStatus]); + + useEffect(() => { + if (!isElectron()) { + return; + } + + ipc?.send('update-private-mode', privateMode); + }, [privateMode]); + + useEffect(() => { + if (!isElectron()) { + return; + } + + ipc?.send('update-sidebar-collapsed', sidebar.collapsed); + }, [sidebar.collapsed]); + + useEffect(() => { + if (!isElectron()) { + return undefined; + } + + window.api.utils.rendererOpenReleaseNotes(() => { + openReleaseNotesModal( + t('common.newVersion', { + postProcess: 'sentenceCase', + version: packageJson.version, + }) as string, + ); + }); + + return () => { + ipc?.removeAllListeners('renderer-open-release-notes'); + }; + }, [t]); +}; diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index 6b73530a4..3b83ebf13 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -92,6 +92,7 @@ interface GroupedQueue { } interface State { + hydrated: boolean; player: { crossfadeDuration: number; crossfadeStyle: CrossfadeStyle; @@ -297,6 +298,7 @@ function regenerateShuffledIndexesIfNeeded(state: { } const initialState: State = { + hydrated: false, player: { crossfadeDuration: 5, crossfadeStyle: CrossfadeStyle.EQUAL_POWER, @@ -1564,6 +1566,9 @@ export const usePlayerStoreBase = createWithEqualityFn()( return persistedState as Partial; }, name: 'player-store', + onRehydrateStorage: () => () => { + usePlayerStoreBase.setState({ hydrated: true }); + }, partialize: (state) => { const shouldRestorePlayQueue = useSettingsStore.getState().general.resume; @@ -2025,6 +2030,10 @@ export const usePlayerStatus = () => { return usePlayerStoreBase((state) => state.player.status); }; +export const usePlayerHydrated = () => { + return usePlayerStoreBase((state) => state.hydrated); +}; + export const usePlayerVolume = () => { return usePlayerStoreBase((state) => state.player.volume); };