From 51425b5e8681717373c468ff3bdf13ce456a71f2 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Wed, 1 Apr 2026 21:27:28 -0700 Subject: [PATCH] various performance refactors --- src/renderer/app.tsx | 168 ++++++++++++------ src/renderer/hooks/use-garbage-collection.ts | 15 +- .../hooks/use-server-authenticated.ts | 41 +++-- src/renderer/layouts/default-layout.tsx | 4 +- .../layouts/default-layout/main-content.tsx | 7 +- .../layouts/mobile-layout/mobile-layout.tsx | 7 +- src/renderer/layouts/responsive-layout.tsx | 89 +++++++--- src/renderer/router/app-outlet.tsx | 48 ++--- src/renderer/router/app-router.tsx | 26 +-- src/renderer/router/titlebar-outlet.tsx | 4 +- src/renderer/store/app.store.ts | 12 ++ .../store/full-screen-player.store.ts | 10 ++ src/renderer/store/player.store.ts | 12 +- src/renderer/store/settings.store.ts | 18 ++ 14 files changed, 313 insertions(+), 148 deletions(-) diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 275bcbe76..7e4547b60 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -7,7 +7,7 @@ import '@mantine/core/styles.css'; import '@mantine/dates/styles.css'; import '@mantine/notifications/styles.css'; import isElectron from 'is-electron'; -import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'; +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'; @@ -38,67 +38,26 @@ const UpdateAvailableDialog = lazy(() => const ipc = isElectron() ? window.api.ipc : null; export const App = () => { + return ; +}; + +const ThemedApp = () => { const { mode, theme } = useAppTheme(); - const language = useLanguage(); - const { content, enabled } = useCssSettings(); - const { bindings } = useHotkeySettings(); - const cssRef = useRef(null); - - useSyncSettingsToMain(); - useCheckForUpdates(); + return ( + + + + ); +}; +const AppShell = memo(function AppShell() { const [webAudio, setWebAudio] = useState(); - useEffect(() => { - if (enabled && content) { - // Yes, CSS is sanitized here as well. Prevent a suer from changing the - // localStorage to bypass sanitizing. - const sanitized = sanitizeCss(content); - if (!cssRef.current) { - cssRef.current = document.createElement('style'); - document.body.appendChild(cssRef.current); - } - - cssRef.current.textContent = sanitized; - - return () => { - cssRef.current!.textContent = ''; - }; - } - - return () => {}; - }, [content, enabled]); - const webAudioProvider = useMemo(() => { return { setWebAudio, webAudio }; }, [webAudio]); - useEffect(() => { - if (isElectron()) { - ipc?.send('set-global-shortcuts', bindings); - } - }, [bindings]); - - useEffect(() => { - if (language) { - i18n.changeLanguage(language); - } - }, [language]); - - useEffect(() => { - if (isElectron()) { - window.api.utils.rendererOpenSettings(() => { - openSettingsModal(); - }); - - return () => { - ipc?.removeAllListeners('renderer-open-settings'); - }; - } - return undefined; - }, []); - const notificationStyles = useMemo( () => ({ root: { @@ -109,7 +68,8 @@ export const App = () => { ); return ( - + <> + { - + ); +}); + +const AppEffects = () => ( + <> + + + + + + + +); + +const SyncSettingsEffect = () => { + useSyncSettingsToMain(); + + return null; +}; + +const UpdateCheckEffect = () => { + useCheckForUpdates(); + + return null; +}; + +const CssSettingsEffect = () => { + const { content, enabled } = useCssSettings(); + const cssRef = useRef(null); + + useEffect(() => { + if (!enabled || !content) { + if (cssRef.current) { + cssRef.current.textContent = ''; + } + + return; + } + + // Yes, CSS is sanitized here as well. Prevent a user from changing the + // localStorage to bypass sanitizing. + const sanitized = sanitizeCss(content); + if (!cssRef.current) { + cssRef.current = document.createElement('style'); + document.body.appendChild(cssRef.current); + } + + cssRef.current.textContent = sanitized; + + return () => { + if (cssRef.current) { + cssRef.current.textContent = ''; + } + }; + }, [content, enabled]); + + return null; +}; + +const GlobalShortcutsEffect = () => { + const { bindings } = useHotkeySettings(); + + useEffect(() => { + if (isElectron()) { + ipc?.send('set-global-shortcuts', bindings); + } + }, [bindings]); + + return null; +}; + +const LanguageEffect = () => { + const language = useLanguage(); + + useEffect(() => { + if (language) { + i18n.changeLanguage(language); + } + }, [language]); + + return null; +}; + +const OpenSettingsEffect = () => { + useEffect(() => { + if (isElectron()) { + window.api.utils.rendererOpenSettings(() => { + openSettingsModal(); + }); + + return () => { + ipc?.removeAllListeners('renderer-open-settings'); + }; + } + + return undefined; + }, []); + + return null; }; diff --git a/src/renderer/hooks/use-garbage-collection.ts b/src/renderer/hooks/use-garbage-collection.ts index 291cbabae..41f484816 100644 --- a/src/renderer/hooks/use-garbage-collection.ts +++ b/src/renderer/hooks/use-garbage-collection.ts @@ -7,15 +7,23 @@ const GARBAGE_COLLECTION_INTERVAL = 1000 * 60 * 5; export const useGarbageCollection = () => { const intervalIdRef = useRef(null); + const startInterval = () => { + if (intervalIdRef.current) { + clearInterval(intervalIdRef.current); + } + + intervalIdRef.current = setInterval(() => { + window.api?.utils?.forceGarbageCollection?.(); + }, GARBAGE_COLLECTION_INTERVAL); + }; + // Clear the cache on an interval useEffect(() => { if (!isElectron()) { return; } - intervalIdRef.current = setInterval(() => { - window.api?.utils?.forceGarbageCollection?.(); - }, GARBAGE_COLLECTION_INTERVAL); + startInterval(); return () => { if (intervalIdRef.current) { @@ -38,5 +46,6 @@ export const useGarbageCollection = () => { } window.api?.utils?.forceGarbageCollection?.(); + startInterval(); }, [location]); }; diff --git a/src/renderer/hooks/use-server-authenticated.ts b/src/renderer/hooks/use-server-authenticated.ts index b7a005112..91d89f6d0 100644 --- a/src/renderer/hooks/use-server-authenticated.ts +++ b/src/renderer/hooks/use-server-authenticated.ts @@ -2,13 +2,13 @@ import { isAxiosError } from 'axios'; import isElectron from 'is-electron'; import debounce from 'lodash/debounce'; import isEqual from 'lodash/isEqual'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import { api } from '/@/renderer/api'; import { controller } from '/@/renderer/api/controller'; import { AppRoute } from '/@/renderer/router/routes'; -import { getServerById, useAuthStoreActions, useCurrentServer } from '/@/renderer/store'; +import { getServerById, useAuthStoreActions, useCurrentServerId } from '/@/renderer/store'; import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { logMsg } from '/@/renderer/utils/logger-message'; import { toast } from '/@/shared/components/toast/toast'; @@ -40,13 +40,18 @@ const isNetworkError = (error: any): boolean => { export const useServerAuthenticated = () => { const priorServerId = useRef(undefined); - const server = useCurrentServer(); + const serverId = useCurrentServerId(); const [ready, setReady] = useState(AuthState.LOADING); const navigate = useNavigate(); + const navigateRef = useRef(navigate); const retryCountRef = useRef(0); const { setCurrentServer, updateServer } = useAuthStoreActions(); + useEffect(() => { + navigateRef.current = navigate; + }, [navigate]); + const authenticateServer = useCallback( async (serverWithAuth: NonNullable>, retryAttempt = 0) => { const authStartTime = Date.now(); @@ -312,7 +317,7 @@ export const useServerAuthenticated = () => { // Don't clear credentials on network failure - preserve them for when network returns setReady(AuthState.INVALID); - navigate(AppRoute.NO_NETWORK, { replace: true }); + navigateRef.current(AppRoute.NO_NETWORK, { replace: true }); return; } @@ -341,18 +346,19 @@ export const useServerAuthenticated = () => { setReady(AuthState.INVALID); } }, - [updateServer, setCurrentServer, navigate], + [updateServer, setCurrentServer], ); - const debouncedAuth = debounce( - (serverWithAuth: NonNullable>) => { - authenticateServer(serverWithAuth).catch(console.error); - }, - 300, + const debouncedAuth = useMemo( + () => + debounce((serverWithAuth: NonNullable>) => { + authenticateServer(serverWithAuth).catch(console.error); + }, 300), + [authenticateServer], ); useEffect(() => { - if (!server) { + if (!serverId) { logFn.debug(logMsg[LogCategory.SYSTEM].serverAuthenticationInvalid, { category: LogCategory.SYSTEM, meta: { @@ -363,9 +369,9 @@ export const useServerAuthenticated = () => { return; } - if (priorServerId.current !== server.id) { - const serverWithAuth = getServerById(server.id); - priorServerId.current = server.id; + if (priorServerId.current !== serverId) { + const serverWithAuth = getServerById(serverId); + priorServerId.current = serverId; retryCountRef.current = 0; // Reset retry count when server changes if (!serverWithAuth) { @@ -373,7 +379,7 @@ export const useServerAuthenticated = () => { category: LogCategory.SYSTEM, meta: { reason: 'Server not found in store', - serverId: server.id, + serverId, }, }); setReady(AuthState.INVALID); @@ -383,7 +389,10 @@ export const useServerAuthenticated = () => { setReady(AuthState.LOADING); debouncedAuth(serverWithAuth); } - }, [debouncedAuth, server]); + return () => { + debouncedAuth.cancel(); + }; + }, [debouncedAuth, serverId]); return ready; }; diff --git a/src/renderer/layouts/default-layout.tsx b/src/renderer/layouts/default-layout.tsx index cf5e7687f..c3a5a20fa 100644 --- a/src/renderer/layouts/default-layout.tsx +++ b/src/renderer/layouts/default-layout.tsx @@ -7,7 +7,7 @@ import styles from './default-layout.module.css'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { MainContent } from '/@/renderer/layouts/default-layout/main-content'; import { PlayerBar } from '/@/renderer/layouts/default-layout/player-bar'; -import { useSettingsStore, useWindowSettings } from '/@/renderer/store/settings.store'; +import { useSettingsStore, useWindowBarStyle } from '/@/renderer/store/settings.store'; import { Platform, PlayerType } from '/@/shared/types/types'; if (!isElectron()) { @@ -29,7 +29,7 @@ interface DefaultLayoutProps { } export const DefaultLayout = ({ shell }: DefaultLayoutProps) => { - const { windowBarStyle } = useWindowSettings(); + const windowBarStyle = useWindowBarStyle(); return ( <> diff --git a/src/renderer/layouts/default-layout/main-content.tsx b/src/renderer/layouts/default-layout/main-content.tsx index 18ef1aa9d..48207b144 100644 --- a/src/renderer/layouts/default-layout/main-content.tsx +++ b/src/renderer/layouts/default-layout/main-content.tsx @@ -175,13 +175,18 @@ export const MainContent = ({ shell }: { shell?: boolean }) => { ); useEffect(() => { + if (!isResizing && !isResizingRight) { + return; + } + window.addEventListener('mousemove', resize); window.addEventListener('mouseup', stopResizing); + return () => { window.removeEventListener('mousemove', resize); window.removeEventListener('mouseup', stopResizing); }; - }, [resize, stopResizing]); + }, [isResizing, isResizingRight, resize, stopResizing]); return ( { const { expanded: isFullScreenPlayerExpanded, visualizerExpanded: isFullScreenVisualizerExpanded, - } = useFullScreenPlayerStore(); - const { windowBarStyle } = useWindowSettings(); + } = useFullScreenPlayerOverlayState(); + const windowBarStyle = useWindowBarStyle(); return ( <> diff --git a/src/renderer/layouts/responsive-layout.tsx b/src/renderer/layouts/responsive-layout.tsx index f4207a15d..d51aec746 100644 --- a/src/renderer/layouts/responsive-layout.tsx +++ b/src/renderer/layouts/responsive-layout.tsx @@ -1,4 +1,5 @@ import isElectron from 'is-electron'; +import { useCallback, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router'; import { useAppTracker } from '/@/renderer/features/analytics/hooks/use-app-tracker'; @@ -9,8 +10,8 @@ import { DefaultLayout } from '/@/renderer/layouts/default-layout'; import { MobileLayout } from '/@/renderer/layouts/mobile-layout/mobile-layout'; import { AppRoute } from '/@/renderer/router/routes'; import { - useCommandPalette, - useHotkeySettings, + useCommandPaletteState, + useLayoutHotkeyBindings, useSettingsStoreActions, useZoomFactor, } from '/@/renderer/store'; @@ -42,40 +43,72 @@ export const ResponsiveLayout = ({ shell }: ResponsiveLayoutProps) => { ); }; +const localSettings = isElectron() ? window.api.localSettings : null; + const LayoutHotkeys = () => { const navigate = useNavigate(); - const localSettings = isElectron() ? window.api.localSettings : null; const zoomFactor = useZoomFactor(); const { setSettings } = useSettingsStoreActions(); - const { bindings } = useHotkeySettings(); - const { opened, ...handlers } = useCommandPalette(); + const bindings = useLayoutHotkeyBindings(); + const { close, open, opened, toggle } = useCommandPaletteState(); - const updateZoom = (increase: number) => { - const newVal = zoomFactor + increase; - if (newVal > 300 || newVal < 50 || !isElectron()) return; - setSettings({ - general: { - zoomFactor: newVal, - }, - }); - localSettings?.setZoomFactor(zoomFactor); - }; - localSettings?.setZoomFactor(zoomFactor); + const handlers = useMemo( + () => ({ + close, + open, + toggle, + }), + [close, open, toggle], + ); - const zoomHotkeys: HotkeyItem[] = [ - [bindings.zoomIn.hotkey, () => updateZoom(5)], - [bindings.zoomOut.hotkey, () => updateZoom(-5)], - ]; + const updateZoom = useCallback( + (increase: number) => { + const newVal = zoomFactor + increase; + if (newVal > 300 || newVal < 50 || !localSettings) return; - useHotkeys([ - [bindings.globalSearch.hotkey, () => handlers.open()], - [bindings.browserBack.hotkey, () => navigate(-1)], - [bindings.browserForward.hotkey, () => navigate(1)], - [bindings.navigateHome.hotkey, () => navigate(AppRoute.HOME)], - ...(isElectron() ? zoomHotkeys : []), - ]); + setSettings({ + general: { + zoomFactor: newVal, + }, + }); + localSettings?.setZoomFactor(newVal); + }, + [setSettings, zoomFactor], + ); - return ; + useEffect(() => { + if (localSettings) { + localSettings?.setZoomFactor(zoomFactor); + } + }, [zoomFactor]); + + const hotkeys = useMemo( + () => [ + [bindings.globalSearch.hotkey, open], + [bindings.browserBack.hotkey, () => navigate(-1)], + [bindings.browserForward.hotkey, () => navigate(1)], + [bindings.navigateHome.hotkey, () => navigate(AppRoute.HOME)], + ...(localSettings + ? ([ + [bindings.zoomIn.hotkey, () => updateZoom(5)], + [bindings.zoomOut.hotkey, () => updateZoom(-5)], + ] as HotkeyItem[]) + : []), + ], + [bindings, navigate, open, updateZoom], + ); + + const modalProps = useMemo( + () => ({ + handlers, + opened, + }), + [handlers, opened], + ); + + useHotkeys(hotkeys); + + return ; }; const GarbageCollection = () => { diff --git a/src/renderer/router/app-outlet.tsx b/src/renderer/router/app-outlet.tsx index 5565173db..3cf1bb6f3 100644 --- a/src/renderer/router/app-outlet.tsx +++ b/src/renderer/router/app-outlet.tsx @@ -1,37 +1,45 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { Navigate, Outlet } from 'react-router'; +import { shallow } from 'zustand/shallow'; import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties'; import { AppRoute } from '/@/renderer/router/routes'; -import { useAuthStoreActions, useCurrentServer } from '/@/renderer/store'; +import { useAuthStore, useAuthStoreActions } from '/@/renderer/store'; const normalizeUrl = (url: string) => url.replace(/\/$/, ''); export const AppOutlet = () => { - const currentServer = useCurrentServer(); + const currentServer = useAuthStore( + (state) => + state.currentServer + ? { + id: state.currentServer.id, + url: state.currentServer.url, + } + : null, + shallow, + ); const { deleteServer, setCurrentServer } = useAuthStoreActions(); - const isActionsRequired = useMemo(() => { - // When SERVER_LOCK is enabled and the configured URL has changed, - // clear the stale session so the user re-authenticates against the new server. - if (isServerLock() && currentServer && window.SERVER_URL) { - const configuredUrl = normalizeUrl(window.SERVER_URL); - const persistedUrl = normalizeUrl(currentServer.url); - - if (configuredUrl !== persistedUrl) { - deleteServer(currentServer.id); - setCurrentServer(null); - return true; - } + const hasServerLockMismatch = useMemo(() => { + if (!isServerLock() || !currentServer || !window.SERVER_URL) { + return false; } - const isServerRequired = !currentServer; + const configuredUrl = normalizeUrl(window.SERVER_URL); + const persistedUrl = normalizeUrl(currentServer.url); - const actions = [isServerRequired]; - const isActionRequired = actions.some((c) => c); + return configuredUrl !== persistedUrl; + }, [currentServer]); - return isActionRequired; - }, [currentServer, deleteServer, setCurrentServer]); + useEffect(() => { + if (hasServerLockMismatch && currentServer) { + deleteServer(currentServer.id); + setCurrentServer(null); + } + }, [currentServer, deleteServer, hasServerLockMismatch, setCurrentServer]); + + const isActionsRequired = !currentServer || hasServerLockMismatch; if (isActionsRequired) { return ; diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index 78ac7aad9..ef3214b22 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -186,22 +186,22 @@ const VisualizerSettingsContextModal = (props: any) => ( ); +const appRouterModals = { + addToPlaylist: AddToPlaylistContextModal, + base: BaseContextModal, + lyricsSettings: LyricsSettingsContextModal, + saveAndReplace: SaveAndReplaceContextModal, + settings: SettingsContextModal, + shareItem: ShareItemContextModal, + shuffleAll: ShuffleAllContextModal, + updatePlaylist: UpdatePlaylistContextModal, + visualizerSettings: VisualizerSettingsContextModal, +}; + export const AppRouter = () => { const router = ( - + }> diff --git a/src/renderer/router/titlebar-outlet.tsx b/src/renderer/router/titlebar-outlet.tsx index 9a847ef03..1218d90c6 100644 --- a/src/renderer/router/titlebar-outlet.tsx +++ b/src/renderer/router/titlebar-outlet.tsx @@ -3,11 +3,11 @@ import { Outlet } from 'react-router'; import styles from './titlebar-outlet.module.css'; import { Titlebar } from '/@/renderer/features/titlebar/components/titlebar'; -import { useWindowSettings } from '/@/renderer/store/settings.store'; +import { useWindowBarStyle } from '/@/renderer/store/settings.store'; import { Platform } from '/@/shared/types/types'; export const TitlebarOutlet = () => { - const { windowBarStyle } = useWindowSettings(); + const windowBarStyle = useWindowBarStyle(); return ( <> diff --git a/src/renderer/store/app.store.ts b/src/renderer/store/app.store.ts index 0e53346c0..549ced89a 100644 --- a/src/renderer/store/app.store.ts +++ b/src/renderer/store/app.store.ts @@ -4,6 +4,7 @@ import type { LibraryItem } from '/@/shared/types/domain-types'; import merge from 'lodash/merge'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; +import { shallow } from 'zustand/shallow'; import { createWithEqualityFn } from 'zustand/traditional'; import { AlbumListSort, SongListSort, SortOrder } from '/@/shared/types/domain-types'; @@ -280,6 +281,17 @@ export const useTitlebarStore = () => useAppStore((state) => state.titlebar); export const useCommandPalette = () => useAppStore((state) => state.commandPalette); +export const useCommandPaletteState = () => + useAppStore( + (state) => ({ + close: state.commandPalette.close, + open: state.commandPalette.open, + opened: state.commandPalette.opened, + toggle: state.commandPalette.toggle, + }), + shallow, + ); + export const usePageSidebar = (key: string): [boolean, (value: boolean) => void] => { const isOpen = useAppStore((state) => state.pageSidebar[key] ?? false); const setPageSidebar = useAppStore((state) => state.actions.setPageSidebar); diff --git a/src/renderer/store/full-screen-player.store.ts b/src/renderer/store/full-screen-player.store.ts index 1a1e303b8..75bac522a 100644 --- a/src/renderer/store/full-screen-player.store.ts +++ b/src/renderer/store/full-screen-player.store.ts @@ -1,6 +1,7 @@ import merge from 'lodash/merge'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; +import { shallow } from 'zustand/shallow'; import { createWithEqualityFn } from 'zustand/traditional'; export interface FullScreenPlayerSlice extends FullScreenPlayerState { @@ -62,3 +63,12 @@ export const useFullScreenPlayerStoreActions = () => export const useSetFullScreenPlayerStore = () => useFullScreenPlayerStore((state) => state.actions.setStore); + +export const useFullScreenPlayerOverlayState = () => + useFullScreenPlayerStore( + (state) => ({ + expanded: state.expanded, + visualizerExpanded: state.visualizerExpanded, + }), + shallow, + ); diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index a8c68a536..4a073f222 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -1,5 +1,6 @@ import merge from 'lodash/merge'; import { nanoid } from 'nanoid'; +import { useMemo } from 'react'; import { persist, subscribeWithSelector } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import { useShallow } from 'zustand/react/shallow'; @@ -1636,10 +1637,13 @@ export const usePlayerActions = () => { })), ); - return { - ...actions, - setTimestamp: setTimestampStore, - }; + return useMemo( + () => ({ + ...actions, + setTimestamp: setTimestampStore, + }), + [actions], + ); }; export type AddToQueueByPlayType = Play; diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index ebf5be17d..c7a125f77 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -2421,8 +2421,26 @@ export const usePlayButtonBehavior = () => export const useWindowSettings = () => useSettingsStore((state) => state.window, shallow); +export const useWindowBarStyle = () => + useSettingsStore((state) => state.window.windowBarStyle, shallow); + export const useHotkeySettings = () => useSettingsStore((state) => state.hotkeys, shallow); +export const useHotkeyBindings = () => useSettingsStore((state) => state.hotkeys.bindings, shallow); + +export const useLayoutHotkeyBindings = () => + useSettingsStore( + (state) => ({ + browserBack: state.hotkeys.bindings.browserBack, + browserForward: state.hotkeys.bindings.browserForward, + globalSearch: state.hotkeys.bindings.globalSearch, + navigateHome: state.hotkeys.bindings.navigateHome, + zoomIn: state.hotkeys.bindings.zoomIn, + zoomOut: state.hotkeys.bindings.zoomOut, + }), + shallow, + ); + export const useMpvSettings = () => useSettingsStore((state) => state.playback.mpvProperties, shallow);