From 60cdea6787be5ccc442097222f8f79906142cb97 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Wed, 26 Nov 2025 13:54:45 -0800 Subject: [PATCH] large performance refactor --- src/renderer/app.tsx | 2 - .../grid-carousel/grid-carousel-v2.tsx | 6 +- .../helpers/item-list-infinite-loader.ts | 14 +- .../item-grid-list/item-grid-list.tsx | 6 +- .../columns/row-index-column.tsx | 52 ++- .../item-table-list/item-table-list.tsx | 7 +- .../native-scroll-area/native-scroll-area.tsx | 8 +- .../components/page-header/page-header.tsx | 8 +- .../features/discord-rpc/use-discord-rpc.ts | 18 +- .../audio-player/hooks/use-player-events.ts | 6 + .../player/components/audio-players.tsx | 4 + .../player/components/center-controls.tsx | 370 +++++++++++------- .../player/components/left-controls.tsx | 13 +- .../player/components/playerbar-slider.tsx | 2 +- .../features/player/components/playerbar.tsx | 19 - .../player/components/right-controls.tsx | 364 ++++++++++------- .../features/player/hooks/use-scrobble.ts | 2 +- .../shared/components/library-header.tsx | 1 + .../mutations/favorite-optimistic-updates.ts | 144 ++++++- .../mutations/rating-optimistic-updates.ts | 149 ++++++- .../sidebar/components/server-selector.tsx | 9 +- .../features/sidebar/components/sidebar.tsx | 201 +++++----- .../features/titlebar/components/app-menu.tsx | 6 +- src/renderer/hooks/use-fast-average-color.tsx | 31 +- .../layouts/default-layout/left-sidebar.tsx | 4 +- .../layouts/default-layout/main-content.tsx | 13 +- .../layouts/default-layout/right-sidebar.tsx | 4 +- .../default-layout/side-drawer-queue.tsx | 4 +- src/renderer/layouts/window-bar.tsx | 9 +- src/renderer/store/player.store.ts | 29 +- src/shared/components/image/image.tsx | 18 +- src/shared/components/skeleton/skeleton.tsx | 9 +- 32 files changed, 1030 insertions(+), 502 deletions(-) diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index dfaf60e84..a34267207 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -10,7 +10,6 @@ import isElectron from 'is-electron'; import { useEffect, useMemo, useRef, useState } from 'react'; import i18n from '/@/i18n/i18n'; -import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc'; import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context'; import { useServerVersion } from '/@/renderer/hooks/use-server-version'; import { IsUpdatedDialog } from '/@/renderer/is-updated-dialog'; @@ -32,7 +31,6 @@ export const App = () => { const { content, enabled } = useCssSettings(); const { bindings } = useHotkeySettings(); const cssRef = useRef(null); - useDiscordRpc(); useServerVersion(); const [webAudio, setWebAudio] = useState(); diff --git a/src/renderer/components/grid-carousel/grid-carousel-v2.tsx b/src/renderer/components/grid-carousel/grid-carousel-v2.tsx index 7ea933c87..9165ed1be 100644 --- a/src/renderer/components/grid-carousel/grid-carousel-v2.tsx +++ b/src/renderer/components/grid-carousel/grid-carousel-v2.tsx @@ -42,7 +42,7 @@ const pageVariants: Variants = { initial: (custom: { isNext: boolean }) => ({ opacity: 0, x: custom.isNext ? 100 : -100 }), }; -export function GridCarousel(props: GridCarouselProps) { +function BaseGridCarousel(props: GridCarouselProps) { const { cards, hasNextPage, loadNextPage, onNextPage, onPrevPage, rowCount = 1, title } = props; const { ref, ...cq } = useContainerQuery({ '2xl': 1024, @@ -192,6 +192,10 @@ export function GridCarousel(props: GridCarouselProps) { ); } +export const GridCarousel = memo(BaseGridCarousel); + +GridCarousel.displayName = 'GridCarousel'; + function getCardsToShow(breakpoints: { isLargerThan2xl: boolean; isLargerThan3xl: boolean; diff --git a/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts b/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts index 461dbf9d9..95bad0feb 100644 --- a/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts +++ b/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts @@ -50,9 +50,21 @@ function getInitialData(itemCount: number) { }; } +export const infiniteLoaderDataQueryKey = ( + serverId: string, + itemType: LibraryItem, + query?: Record, +) => { + if (query) { + return [serverId, 'item-list-infinite-loader', itemType, query]; + } + + return [serverId, 'item-list-infinite-loader', itemType]; +}; + export const useItemListInfiniteLoader = ({ eventKey, - fetchThreshold = 0.2, + fetchThreshold = 0.5, itemsPerPage = 100, itemType, listCountQuery, diff --git a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx index 9bfbeb50a..19c56d9a2 100644 --- a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx +++ b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx @@ -266,7 +266,7 @@ export interface ItemGridListProps { rows?: ItemCardProps['rows']; } -export const ItemGridList = ({ +const BaseItemGridList = ({ data, enableDrag = true, enableExpansion = false, @@ -750,3 +750,7 @@ const ListComponent = memo((props: ListChildComponentProps) => { ); }); + +export const ItemGridList = memo(BaseItemGridList); + +ItemGridList.displayName = 'ItemGridList'; diff --git a/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx b/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx index b6d6aebbc..7ff822a53 100644 --- a/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx @@ -1,4 +1,5 @@ import clsx from 'clsx'; +import { memo } from 'react'; import styles from './row-index-column.module.css'; @@ -88,6 +89,8 @@ const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => { !!props.activeRowId && (props.activeRowId === song?.id || props.activeRowId === song?._uniqueId); + const isActiveAndPlaying = isActive && status === PlayerStatus.PLAYING; + let adjustedRowIndex = props.adjustedRowIndexMap?.get(props.rowIndex) ?? (props.enableHeader ? props.rowIndex : props.rowIndex + 1); @@ -97,20 +100,39 @@ const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => { } return ( - - {isActive ? ( - status === PlayerStatus.PLAYING ? ( - - - - ) : ( - - - - ) - ) : ( - adjustedRowIndex - )} - + ); }; + +const InnerQueueSongRowIndexColumn = memo( + ( + props: ItemTableListInnerColumn & { + adjustedRowIndex: number; + isActive: boolean; + isPlaying: boolean; + }, + ) => { + return ( + + {props.isActive ? ( + props.isPlaying ? ( + + + + ) : ( + + + + ) + ) : ( + props.adjustedRowIndex + )} + + ); + }, +); diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index 5f50b8d55..6d7df8332 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -6,6 +6,7 @@ import { AnimatePresence, motion } from 'motion/react'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; import React, { type JSXElementConstructor, + memo, ReactElement, Ref, useCallback, @@ -704,7 +705,7 @@ interface ItemTableListProps { startRowIndex?: number; } -export const ItemTableList = ({ +const BaseItemTableList = ({ activeRowId, autoFitColumns = false, CellComponent, @@ -2178,3 +2179,7 @@ export const ItemTableList = ({ ); }; + +export const ItemTableList = memo(BaseItemTableList); + +ItemTableList.displayName = 'ItemTableList'; diff --git a/src/renderer/components/native-scroll-area/native-scroll-area.tsx b/src/renderer/components/native-scroll-area/native-scroll-area.tsx index cfdcc8a8c..00c65661f 100644 --- a/src/renderer/components/native-scroll-area/native-scroll-area.tsx +++ b/src/renderer/components/native-scroll-area/native-scroll-area.tsx @@ -1,5 +1,5 @@ import { useOverlayScrollbars } from 'overlayscrollbars-react'; -import { CSSProperties, forwardRef, ReactNode, Ref, useEffect, useRef } from 'react'; +import { CSSProperties, forwardRef, memo, ReactNode, Ref, useEffect, useRef } from 'react'; import styles from './native-scroll-area.module.css'; @@ -18,7 +18,7 @@ interface NativeScrollAreaProps { style?: CSSProperties; } -export const NativeScrollArea = forwardRef( +const BaseNativeScrollArea = forwardRef( ( { children, noHeader, pageHeaderProps, scrollHideDelay, ...props }: NativeScrollAreaProps, ref: Ref, @@ -98,3 +98,7 @@ export const NativeScrollArea = forwardRef( ); }, ); + +export const NativeScrollArea = memo(BaseNativeScrollArea); + +NativeScrollArea.displayName = 'NativeScrollArea'; diff --git a/src/renderer/components/page-header/page-header.tsx b/src/renderer/components/page-header/page-header.tsx index 196cc4ddc..95d9884fb 100644 --- a/src/renderer/components/page-header/page-header.tsx +++ b/src/renderer/components/page-header/page-header.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx'; import { useInView } from 'motion/react'; import { AnimatePresence, motion, Variants } from 'motion/react'; -import { CSSProperties, ReactNode, RefObject, useEffect, useRef } from 'react'; +import { CSSProperties, memo, ReactNode, RefObject, useEffect, useRef } from 'react'; import styles from './page-header.module.css'; @@ -35,7 +35,7 @@ const variants: Variants = { initial: { opacity: 0 }, }; -export const PageHeader = ({ +const BasePageHeader = ({ animated, backgroundColor, children, @@ -144,3 +144,7 @@ export const PageHeader = ({ ); }; + +export const PageHeader = memo(BasePageHeader); + +PageHeader.displayName = 'PageHeader'; diff --git a/src/renderer/features/discord-rpc/use-discord-rpc.ts b/src/renderer/features/discord-rpc/use-discord-rpc.ts index c344f7aad..2a8f6b7c4 100644 --- a/src/renderer/features/discord-rpc/use-discord-rpc.ts +++ b/src/renderer/features/discord-rpc/use-discord-rpc.ts @@ -10,6 +10,7 @@ import { useDiscordSettings, useGeneralSettings, usePlayerStore, + useTimestampStoreBase, } from '/@/renderer/store'; import { sentenceCase } from '/@/renderer/utils'; import { QueueSong, ServerType } from '/@/shared/types/domain-types'; @@ -26,7 +27,7 @@ const truncate = (field: string) => export const useDiscordRpc = () => { const discordSettings = useDiscordSettings(); const generalSettings = useGeneralSettings(); - const { privateMode } = useAppStore(); + const privateMode = useAppStore((state) => state.privateMode); const [lastUniqueId, setlastUniqueId] = useState(''); const setActivity = useCallback( @@ -201,14 +202,13 @@ export const useDiscordRpc = () => { if (!discordSettings.enabled || privateMode) { return; } - const unsubSongChange = usePlayerStore.subscribe( - (state): ActivityState => [ - state.current.song, - state.current.time, - state.current.status, - ], - setActivity, - ); + const unsubSongChange = usePlayerStore.subscribe((state): ActivityState => { + const currentSong = state.getCurrentSong(); + const currentTime = useTimestampStoreBase.getState().timestamp; + const status = state.player.status; + + return [currentSong, currentTime, status]; + }, setActivity); return () => { unsubSongChange(); }; diff --git a/src/renderer/features/player/audio-player/hooks/use-player-events.ts b/src/renderer/features/player/audio-player/hooks/use-player-events.ts index 5fc6380cb..11179e740 100644 --- a/src/renderer/features/player/audio-player/hooks/use-player-events.ts +++ b/src/renderer/features/player/audio-player/hooks/use-player-events.ts @@ -140,6 +140,12 @@ function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents { return { cleanup: () => { unsubscribers.forEach((unsubscribe) => unsubscribe()); + if (callbacks.onUserRating) { + eventEmitter.off('USER_RATING', callbacks.onUserRating); + } + if (callbacks.onUserFavorite) { + eventEmitter.off('USER_FAVORITE', callbacks.onUserFavorite); + } }, }; } diff --git a/src/renderer/features/player/components/audio-players.tsx b/src/renderer/features/player/components/audio-players.tsx index 7ce26ed41..cc045b53c 100644 --- a/src/renderer/features/player/components/audio-players.tsx +++ b/src/renderer/features/player/components/audio-players.tsx @@ -2,8 +2,10 @@ import { useEffect } from 'react'; import { eventEmitter } from '/@/renderer/events/event-emitter'; import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events'; +import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc'; import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player'; import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player'; +import { usePowerSaveBlocker } from '/@/renderer/features/player/hooks/use-power-save-blocker'; import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble'; import { updateQueueFavorites, @@ -19,6 +21,8 @@ export const AudioPlayers = () => { const serverId = useCurrentServerId(); useScrobble(); + usePowerSaveBlocker(); + useDiscordRpc(); // Listen to favorite and rating events to update queue songs useEffect(() => { diff --git a/src/renderer/features/player/components/center-controls.tsx b/src/renderer/features/player/components/center-controls.tsx index 7a025cd4a..a859820d1 100644 --- a/src/renderer/features/player/components/center-controls.tsx +++ b/src/renderer/features/player/components/center-controls.tsx @@ -18,167 +18,233 @@ import { Icon } from '/@/shared/components/icon/icon'; import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types'; export const CenterControls = () => { - const { t } = useTranslation(); - const queryClient = useQueryClient(); - const currentSong = usePlayerSong(); const skip = useSettingsStore((state) => state.general.skipButtons); - const buttonSize = useSettingsStore((state) => state.general.buttonSize); - const status = usePlayerStatus(); - const repeat = usePlayerRepeat(); - const shuffle = usePlayerShuffle(); - - const { - mediaNext, - mediaPrevious, - mediaSkipBackward, - mediaSkipForward, - mediaStop, - mediaTogglePlayPause, - toggleRepeat, - toggleShuffle, - } = usePlayer(); return ( <>
- } - onClick={mediaStop} - tooltip={{ - label: t('player.stop', { postProcess: 'sentenceCase' }), - openDelay: 0, - }} - variant="tertiary" - /> - - } - isActive={shuffle !== PlayerShuffle.NONE} - onClick={toggleShuffle} - tooltip={{ - label: - shuffle === PlayerShuffle.NONE - ? t('player.shuffle', { - context: 'off', - postProcess: 'sentenceCase', - }) - : t('player.shuffle', { postProcess: 'sentenceCase' }), - openDelay: 0, - }} - variant="tertiary" - /> - } - onClick={mediaPrevious} - tooltip={{ - label: t('player.previous', { postProcess: 'sentenceCase' }), - openDelay: 0, - }} - variant="secondary" - /> - {skip?.enabled && ( - - } - onClick={mediaSkipBackward} - tooltip={{ - label: t('player.skip', { - context: 'back', - postProcess: 'sentenceCase', - }), - - openDelay: 0, - }} - variant="secondary" - /> - )} - - {skip?.enabled && ( - } - onClick={mediaSkipForward} - tooltip={{ - label: t('player.skip', { - context: 'forward', - postProcess: 'sentenceCase', - }), - - openDelay: 0, - }} - variant="secondary" - /> - )} - } - onClick={mediaNext} - tooltip={{ - label: t('player.next', { postProcess: 'sentenceCase' }), - openDelay: 0, - }} - variant="secondary" - /> - - ) : ( - - ) - } - isActive={repeat !== PlayerRepeat.NONE} - onClick={toggleRepeat} - tooltip={{ - label: `${ - repeat === PlayerRepeat.NONE - ? t('player.repeat', { - context: 'off', - postProcess: 'sentenceCase', - }) - : repeat === PlayerRepeat.ALL - ? t('player.repeat', { - context: 'all', - postProcess: 'sentenceCase', - }) - : t('player.repeat', { - context: 'one', - postProcess: 'sentenceCase', - }) - }`, - openDelay: 0, - }} - variant="tertiary" - /> - } - onClick={() => - openShuffleAllModal({ - queryClient, - }) - } - tooltip={{ - label: t('player.playRandom', { postProcess: 'sentenceCase' }), - openDelay: 0, - }} - variant="tertiary" - /> + + + + {skip?.enabled && } + + {skip?.enabled && } + + +
); }; + +const StopButton = () => { + const { t } = useTranslation(); + const buttonSize = useSettingsStore((state) => state.general.buttonSize); + const { mediaStop } = usePlayer(); + + return ( + } + onClick={mediaStop} + tooltip={{ + label: t('player.stop', { postProcess: 'sentenceCase' }), + openDelay: 0, + }} + variant="tertiary" + /> + ); +}; + +const ShuffleButton = () => { + const { t } = useTranslation(); + const buttonSize = useSettingsStore((state) => state.general.buttonSize); + const shuffle = usePlayerShuffle(); + const { toggleShuffle } = usePlayer(); + + return ( + + } + isActive={shuffle !== PlayerShuffle.NONE} + onClick={toggleShuffle} + tooltip={{ + label: + shuffle === PlayerShuffle.NONE + ? t('player.shuffle', { + context: 'off', + postProcess: 'sentenceCase', + }) + : t('player.shuffle', { postProcess: 'sentenceCase' }), + openDelay: 0, + }} + variant="tertiary" + /> + ); +}; + +const PreviousButton = () => { + const { t } = useTranslation(); + const buttonSize = useSettingsStore((state) => state.general.buttonSize); + const { mediaPrevious } = usePlayer(); + + return ( + } + onClick={mediaPrevious} + tooltip={{ + label: t('player.previous', { postProcess: 'sentenceCase' }), + openDelay: 0, + }} + variant="secondary" + /> + ); +}; + +const SkipBackwardButton = () => { + const { t } = useTranslation(); + const buttonSize = useSettingsStore((state) => state.general.buttonSize); + const { mediaSkipBackward } = usePlayer(); + + return ( + } + onClick={mediaSkipBackward} + tooltip={{ + label: t('player.skip', { + context: 'back', + postProcess: 'sentenceCase', + }), + openDelay: 0, + }} + variant="secondary" + /> + ); +}; + +const CenterPlayButton = () => { + const currentSong = usePlayerSong(); + const status = usePlayerStatus(); + const { mediaTogglePlayPause } = usePlayer(); + + return ( + + ); +}; + +const SkipForwardButton = () => { + const { t } = useTranslation(); + const buttonSize = useSettingsStore((state) => state.general.buttonSize); + const { mediaSkipForward } = usePlayer(); + + return ( + } + onClick={mediaSkipForward} + tooltip={{ + label: t('player.skip', { + context: 'forward', + postProcess: 'sentenceCase', + }), + openDelay: 0, + }} + variant="secondary" + /> + ); +}; + +const NextButton = () => { + const { t } = useTranslation(); + const buttonSize = useSettingsStore((state) => state.general.buttonSize); + const { mediaNext } = usePlayer(); + + return ( + } + onClick={mediaNext} + tooltip={{ + label: t('player.next', { postProcess: 'sentenceCase' }), + openDelay: 0, + }} + variant="secondary" + /> + ); +}; + +const RepeatButton = () => { + const { t } = useTranslation(); + const buttonSize = useSettingsStore((state) => state.general.buttonSize); + const repeat = usePlayerRepeat(); + const { toggleRepeat } = usePlayer(); + + return ( + + ) : ( + + ) + } + isActive={repeat !== PlayerRepeat.NONE} + onClick={toggleRepeat} + tooltip={{ + label: `${ + repeat === PlayerRepeat.NONE + ? t('player.repeat', { + context: 'off', + postProcess: 'sentenceCase', + }) + : repeat === PlayerRepeat.ALL + ? t('player.repeat', { + context: 'all', + postProcess: 'sentenceCase', + }) + : t('player.repeat', { + context: 'one', + postProcess: 'sentenceCase', + }) + }`, + openDelay: 0, + }} + variant="tertiary" + /> + ); +}; + +const ShuffleAllButton = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const buttonSize = useSettingsStore((state) => state.general.buttonSize); + + return ( + } + onClick={() => + openShuffleAllModal({ + queryClient, + }) + } + tooltip={{ + label: t('player.playRandom', { postProcess: 'sentenceCase' }), + openDelay: 0, + }} + variant="tertiary" + /> + ); +}; diff --git a/src/renderer/features/player/components/left-controls.tsx b/src/renderer/features/player/components/left-controls.tsx index 07d061030..1ec251fcc 100644 --- a/src/renderer/features/player/components/left-controls.tsx +++ b/src/renderer/features/player/components/left-controls.tsx @@ -3,18 +3,19 @@ import { AnimatePresence, LayoutGroup, motion } from 'motion/react'; import React, { MouseEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { generatePath, Link } from 'react-router'; +import { shallow } from 'zustand/shallow'; import styles from './left-controls.module.css'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { AppRoute } from '/@/renderer/router/routes'; import { + useAppStore, useAppStoreActions, useFullScreenPlayerStore, useHotkeySettings, usePlayerSong, useSetFullScreenPlayerStore, - useSidebarStore, } from '/@/renderer/store'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Group } from '/@/shared/components/group/group'; @@ -31,7 +32,15 @@ export const LeftControls = () => { const { setSideBar } = useAppStoreActions(); const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore(); const setFullScreenPlayerStore = useSetFullScreenPlayerStore(); - const { collapsed, image } = useSidebarStore(); + + const { collapsed, image } = useAppStore( + (state) => ({ + collapsed: state.sidebar.collapsed, + image: state.sidebar.image, + }), + shallow, + ); + const hideImage = image && !collapsed; const currentSong = usePlayerSong(); const title = currentSong?.name; diff --git a/src/renderer/features/player/components/playerbar-slider.tsx b/src/renderer/features/player/components/playerbar-slider.tsx index 8e9e8d3ee..8a36b84bc 100644 --- a/src/renderer/features/player/components/playerbar-slider.tsx +++ b/src/renderer/features/player/components/playerbar-slider.tsx @@ -27,7 +27,7 @@ export const PlayerbarSlider = () => { const formattedTimeRemaining = formatDuration((currentTime - songDuration) * 1000 || 0); const formattedTime = formatDuration(currentTime * 1000 || 0); - const { showTimeRemaining } = useAppStore(); + const showTimeRemaining = useAppStore((state) => state.showTimeRemaining); const { setShowTimeRemaining } = useAppStoreActions(); useRemote(); diff --git a/src/renderer/features/player/components/playerbar.tsx b/src/renderer/features/player/components/playerbar.tsx index d395e4fd4..7f868257f 100644 --- a/src/renderer/features/player/components/playerbar.tsx +++ b/src/renderer/features/player/components/playerbar.tsx @@ -7,7 +7,6 @@ import { CenterControls } from '/@/renderer/features/player/components/center-co import { LeftControls } from '/@/renderer/features/player/components/left-controls'; import { MobilePlayerbar } from '/@/renderer/features/player/components/mobile-playerbar'; import { RightControls } from '/@/renderer/features/player/components/right-controls'; -import { usePowerSaveBlocker } from '/@/renderer/features/player/hooks/use-power-save-blocker'; import { useIsMobile } from '/@/renderer/hooks/use-is-mobile'; import { useFullScreenPlayerStore, useSetFullScreenPlayerStore } from '/@/renderer/store'; import { useGeneralSettings } from '/@/renderer/store/settings.store'; @@ -19,8 +18,6 @@ export const Playerbar = () => { const setFullScreenPlayerStore = useSetFullScreenPlayerStore(); const isMobile = useIsMobile(); - usePowerSaveBlocker(); - const handleToggleFullScreenPlayer = (e?: KeyboardEvent | MouseEvent) => { e?.stopPropagation(); setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded }); @@ -46,22 +43,6 @@ export const Playerbar = () => { - {/* {playbackType === PlayerType.WEB && ( - - )} */} ); }; diff --git a/src/renderer/features/player/components/right-controls.tsx b/src/renderer/features/player/components/right-controls.tsx index 9bf6400d4..f2e838661 100644 --- a/src/renderer/features/player/components/right-controls.tsx +++ b/src/renderer/features/player/components/right-controls.tsx @@ -1,3 +1,4 @@ +import { t } from 'i18next'; import { useCallback, WheelEvent } from 'react'; import { useTranslation } from 'react-i18next'; @@ -14,9 +15,10 @@ import { useHotkeySettings, usePlayerData, usePlayerMuted, + usePlayerSong, usePlayerVolume, useSettingsStore, - useSidebarStore, + useSidebarRightExpanded, } from '/@/renderer/store'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Flex } from '/@/shared/components/flex/flex'; @@ -51,19 +53,61 @@ const calculateVolumeDown = (volume: number, volumeWheelStep: number) => { }; export const RightControls = () => { + return ( + + + + + + + + + + + + + ); +}; + +const QueueButton = () => { const { t } = useTranslation(); - const isMinWidth = useMediaQuery('(max-width: 480px)'); - const volume = usePlayerVolume(); - const muted = usePlayerMuted(); - const server = useCurrentServer(); - const { currentSong, previousSong } = usePlayerData(); + const isSidebarRightExpanded = useSidebarRightExpanded(); const { setSideBar } = useAppStoreActions(); - const { rightExpanded: isQueueExpanded } = useSidebarStore(); + const { bindings } = useHotkeySettings(); - const { volumeWheelStep } = useGeneralSettings(); - const volumeWidth = useSettingsStore((state) => state.general.volumeWidth); - const { mediaToggleMute, setVolume } = usePlayer(); - const updateRatingMutation = useSetRating({}); + + const handleToggleQueue = () => { + setSideBar({ rightExpanded: !isSidebarRightExpanded }); + }; + + useHotkeys([ + [bindings.toggleQueue.isGlobal ? '' : bindings.toggleQueue.hotkey, handleToggleQueue], + ]); + + return ( + { + e.stopPropagation(); + handleToggleQueue(); + }} + size="sm" + tooltip={{ + label: t('player.viewQueue', { postProcess: 'titleCase' }), + openDelay: 0, + }} + variant="subtle" + /> + ); +}; + +const FavoriteButton = () => { + const currentSong = usePlayerSong(); + const { bindings } = useHotkeySettings(); + const addToFavoritesMutation = useCreateFavorite({}); const removeFromFavoritesMutation = useDeleteFavorite({}); @@ -79,19 +123,6 @@ export const RightControls = () => { }); }; - const handleUpdateRating = (rating: number) => { - if (!currentSong) return; - - updateRatingMutation.mutate({ - apiClientProps: { serverId: currentSong?._serverId || '' }, - query: { - id: [currentSong.id], - rating, - type: LibraryItem.SONG, - }, - }); - }; - const handleRemoveFromFavorites = (song: QueueSong | undefined) => { if (!song?.id) return; @@ -114,6 +145,136 @@ export const RightControls = () => { } }; + useFavoritePreviousSongHotkeys({ + handleAddToFavorites, + handleRemoveFromFavorites, + handleToggleFavorite, + }); + + useHotkeys([ + [ + bindings.favoriteCurrentAdd.isGlobal ? '' : bindings.favoriteCurrentAdd.hotkey, + () => handleAddToFavorites(currentSong), + ], + [ + bindings.favoriteCurrentRemove.isGlobal ? '' : bindings.favoriteCurrentRemove.hotkey, + () => handleRemoveFromFavorites(currentSong), + ], + [ + bindings.favoriteCurrentToggle.isGlobal ? '' : bindings.favoriteCurrentToggle.hotkey, + () => handleToggleFavorite(currentSong), + ], + ]); + + return ( + { + e.stopPropagation(); + handleToggleFavorite(currentSong); + }} + size="sm" + tooltip={{ + label: currentSong?.userFavorite + ? t('player.unfavorite', { postProcess: 'titleCase' }) + : t('player.favorite', { postProcess: 'titleCase' }), + openDelay: 0, + }} + variant="subtle" + /> + ); +}; + +const useFavoritePreviousSongHotkeys = ({ + handleAddToFavorites, + handleRemoveFromFavorites, + handleToggleFavorite, +}: { + handleAddToFavorites: (song: QueueSong | undefined) => void; + handleRemoveFromFavorites: (song: QueueSong | undefined) => void; + handleToggleFavorite: (song: QueueSong | undefined) => void; +}) => { + const { bindings } = useHotkeySettings(); + const { previousSong } = usePlayerData(); + + useHotkeys([ + [ + bindings.favoritePreviousAdd.isGlobal ? '' : bindings.favoritePreviousAdd.hotkey, + () => handleAddToFavorites(previousSong), + ], + [ + bindings.favoritePreviousRemove.isGlobal ? '' : bindings.favoritePreviousRemove.hotkey, + () => handleRemoveFromFavorites(previousSong), + ], + [ + bindings.favoritePreviousToggle.isGlobal ? '' : bindings.favoritePreviousToggle.hotkey, + () => handleToggleFavorite(previousSong), + ], + ]); + + return null; +}; + +const RatingButton = () => { + const server = useCurrentServer(); + const currentSong = usePlayerSong(); + const updateRatingMutation = useSetRating({}); + + const isSongDefined = Boolean(currentSong?.id); + const showRating = + isSongDefined && + (server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC); + + const handleUpdateRating = (rating: number) => { + if (!currentSong) return; + + updateRatingMutation.mutate({ + apiClientProps: { serverId: currentSong?._serverId || '' }, + query: { + id: [currentSong.id], + rating, + type: LibraryItem.SONG, + }, + }); + }; + + const { bindings } = useHotkeySettings(); + + useHotkeys([ + [bindings.rate0.isGlobal ? '' : bindings.rate0.hotkey, () => handleUpdateRating(0)], + [bindings.rate1.isGlobal ? '' : bindings.rate1.hotkey, () => handleUpdateRating(1)], + [bindings.rate2.isGlobal ? '' : bindings.rate2.hotkey, () => handleUpdateRating(2)], + [bindings.rate3.isGlobal ? '' : bindings.rate3.hotkey, () => handleUpdateRating(3)], + [bindings.rate4.isGlobal ? '' : bindings.rate4.hotkey, () => handleUpdateRating(4)], + [bindings.rate5.isGlobal ? '' : bindings.rate5.hotkey, () => handleUpdateRating(5)], + ]); + + return ( + <> + {showRating && ( + + )} + + ); +}; + +const VolumeButton = () => { + const { bindings } = useHotkeySettings(); + const volume = usePlayerVolume(); + const muted = usePlayerMuted(); + const { volumeWheelStep } = useGeneralSettings(); + const volumeWidth = useSettingsStore((state) => state.general.volumeWidth); + const { mediaToggleMute, setVolume } = usePlayer(); + const isMinWidth = useMediaQuery('(max-width: 480px)'); + const handleVolumeDown = useCallback(() => { setVolume(volume - 1); }, [setVolume, volume]); @@ -122,10 +283,6 @@ export const RightControls = () => { setVolume(volume + 1); }, [setVolume, volume]); - const handleMute = useCallback(() => { - mediaToggleMute(); - }, [mediaToggleMute]); - const handleVolumeSlider = useCallback( (e: number) => { setVolume(e); @@ -133,6 +290,10 @@ export const RightControls = () => { [setVolume], ); + const handleMute = useCallback(() => { + mediaToggleMute(); + }, [mediaToggleMute]); + const handleVolumeWheel = useCallback( (e: WheelEvent) => { let volumeToSet; @@ -146,132 +307,43 @@ export const RightControls = () => { }, [setVolume, volume, volumeWheelStep], ); - - const handleToggleQueue = () => { - setSideBar({ rightExpanded: !isQueueExpanded }); - }; - - const isSongDefined = Boolean(currentSong?.id); - const showRating = - isSongDefined && - (server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC); - useHotkeys([ [bindings.volumeDown.isGlobal ? '' : bindings.volumeDown.hotkey, handleVolumeDown], [bindings.volumeUp.isGlobal ? '' : bindings.volumeUp.hotkey, handleVolumeUp], [bindings.volumeMute.isGlobal ? '' : bindings.volumeMute.hotkey, handleMute], - [bindings.toggleQueue.isGlobal ? '' : bindings.toggleQueue.hotkey, handleToggleQueue], - [ - bindings.favoriteCurrentAdd.isGlobal ? '' : bindings.favoriteCurrentAdd.hotkey, - () => handleAddToFavorites(currentSong), - ], - [ - bindings.favoriteCurrentRemove.isGlobal ? '' : bindings.favoriteCurrentRemove.hotkey, - () => handleRemoveFromFavorites(currentSong), - ], - [ - bindings.favoriteCurrentToggle.isGlobal ? '' : bindings.favoriteCurrentToggle.hotkey, - () => handleToggleFavorite(currentSong), - ], - [ - bindings.favoritePreviousAdd.isGlobal ? '' : bindings.favoritePreviousAdd.hotkey, - () => handleAddToFavorites(previousSong), - ], - [ - bindings.favoritePreviousRemove.isGlobal ? '' : bindings.favoritePreviousRemove.hotkey, - () => handleRemoveFromFavorites(previousSong), - ], - [ - bindings.favoritePreviousToggle.isGlobal ? '' : bindings.favoritePreviousToggle.hotkey, - () => handleToggleFavorite(previousSong), - ], - [bindings.rate0.isGlobal ? '' : bindings.rate0.hotkey, () => handleUpdateRating(0)], - [bindings.rate1.isGlobal ? '' : bindings.rate1.hotkey, () => handleUpdateRating(1)], - [bindings.rate2.isGlobal ? '' : bindings.rate2.hotkey, () => handleUpdateRating(2)], - [bindings.rate3.isGlobal ? '' : bindings.rate3.hotkey, () => handleUpdateRating(3)], - [bindings.rate4.isGlobal ? '' : bindings.rate4.hotkey, () => handleUpdateRating(4)], - [bindings.rate5.isGlobal ? '' : bindings.rate5.hotkey, () => handleUpdateRating(5)], ]); return ( - - - {showRating && ( - - )} - - - - { - e.stopPropagation(); - handleToggleFavorite(currentSong); - }} - size="sm" - tooltip={{ - label: currentSong?.userFavorite - ? t('player.unfavorite', { postProcess: 'titleCase' }) - : t('player.favorite', { postProcess: 'titleCase' }), - openDelay: 0, - }} - variant="subtle" - /> - { - e.stopPropagation(); - handleToggleQueue(); - }} - size="sm" - tooltip={{ - label: t('player.viewQueue', { postProcess: 'titleCase' }), - openDelay: 0, - }} - variant="subtle" - /> - 50 ? 'volumeMax' : 'volumeNormal'} - iconProps={{ - color: muted ? 'muted' : undefined, - size: 'xl', - }} - onClick={(e) => { - e.stopPropagation(); - handleMute(); - }} + <> + 50 ? 'volumeMax' : 'volumeNormal'} + iconProps={{ + color: muted ? 'muted' : undefined, + size: 'xl', + }} + onClick={(e) => { + e.stopPropagation(); + handleMute(); + }} + onWheel={handleVolumeWheel} + size="sm" + tooltip={{ + label: muted ? t('player.muted', { postProcess: 'titleCase' }) : volume, + openDelay: 0, + }} + variant="subtle" + /> + {!isMinWidth ? ( + - {!isMinWidth ? ( - - ) : null} - - - + ) : null} + ); }; diff --git a/src/renderer/features/player/hooks/use-scrobble.ts b/src/renderer/features/player/hooks/use-scrobble.ts index 14dbb9b30..a2c2f71a6 100644 --- a/src/renderer/features/player/hooks/use-scrobble.ts +++ b/src/renderer/features/player/hooks/use-scrobble.ts @@ -60,7 +60,7 @@ const checkScrobbleConditions = (args: { export const useScrobble = () => { const scrobbleSettings = usePlaybackSettings().scrobble; const isScrobbleEnabled = scrobbleSettings?.enabled; - const isPrivateModeEnabled = useAppStore().privateMode; + const isPrivateModeEnabled = useAppStore((state) => state.privateMode); const sendScrobble = useSendScrobble(); const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false); diff --git a/src/renderer/features/shared/components/library-header.tsx b/src/renderer/features/shared/components/library-header.tsx index 446b8c0cd..c25cdc97c 100644 --- a/src/renderer/features/shared/components/library-header.tsx +++ b/src/renderer/features/shared/components/library-header.tsx @@ -100,6 +100,7 @@ export const LibraryHeader = forwardRef( cover { + if (data) { + previousQueries.push({ data, queryKey }); + queryClient.setQueryData( + queryKey, + ( + prev: + | undefined + | { + data: unknown[]; + pagesLoaded: Record; + }, + ) => { + if (prev && prev.data) { + return { + ...prev, + data: prev.data.map((item: any) => { + if (!item || !item.id) { + return item; + } + + return itemIdSet.has(item.id) + ? { ...item, userFavorite: isFavorite } + : item; + }), + }; + } + + return prev; + }, + ); + } + }); + } + break; } case LibraryItem.ALBUM_ARTIST: { @@ -271,6 +317,52 @@ export const applyFavoriteOptimisticUpdates = ( }); } + const infiniteLoaderQueryKey = infiniteLoaderDataQueryKey( + variables.apiClientProps.serverId, + LibraryItem.ALBUM_ARTIST, + ); + + const infiniteLoaderQueries = queryClient.getQueriesData({ + exact: false, + queryKey: infiniteLoaderQueryKey, + }); + + if (infiniteLoaderQueries.length) { + infiniteLoaderQueries.forEach(([queryKey, data]) => { + if (data) { + previousQueries.push({ data, queryKey }); + queryClient.setQueryData( + queryKey, + ( + prev: + | undefined + | { + data: unknown[]; + pagesLoaded: Record; + }, + ) => { + if (prev && prev.data) { + return { + ...prev, + data: prev.data.map((item: any) => { + if (!item || !item.id) { + return item; + } + + return itemIdSet.has(item.id) + ? { ...item, userFavorite: isFavorite } + : item; + }), + }; + } + + return prev; + }, + ); + } + }); + } + break; } case LibraryItem.ARTIST: { @@ -291,7 +383,7 @@ export const applyFavoriteOptimisticUpdates = ( if (prev) { return { ...prev, - items: prev.items.map((item: Artist) => { + items: prev.items.map((item: AlbumArtist) => { return itemIdSet.has(item.id) ? { ...item, userFavorite: isFavorite } : item; @@ -332,7 +424,7 @@ export const applyFavoriteOptimisticUpdates = ( pages: prev.pages.map((page: ArtistListResponse) => { return { ...page, - items: page.items.map((item: Artist) => { + items: page.items.map((item: AlbumArtist) => { return itemIdSet.has(item.id) ? { ...item, userFavorite: isFavorite } : item; @@ -349,6 +441,52 @@ export const applyFavoriteOptimisticUpdates = ( }); } + const infiniteLoaderQueryKey = infiniteLoaderDataQueryKey( + variables.apiClientProps.serverId, + LibraryItem.ARTIST, + ); + + const infiniteLoaderQueries = queryClient.getQueriesData({ + exact: false, + queryKey: infiniteLoaderQueryKey, + }); + + if (infiniteLoaderQueries.length) { + infiniteLoaderQueries.forEach(([queryKey, data]) => { + if (data) { + previousQueries.push({ data, queryKey }); + queryClient.setQueryData( + queryKey, + ( + prev: + | undefined + | { + data: unknown[]; + pagesLoaded: Record; + }, + ) => { + if (prev && prev.data) { + return { + ...prev, + data: prev.data.map((item: any) => { + if (!item || !item.id) { + return item; + } + + return itemIdSet.has(item.id) + ? { ...item, userFavorite: isFavorite } + : item; + }), + }; + } + + return prev; + }, + ); + } + }); + } + break; } case LibraryItem.PLAYLIST_SONG: diff --git a/src/renderer/features/shared/mutations/rating-optimistic-updates.ts b/src/renderer/features/shared/mutations/rating-optimistic-updates.ts index fa12705d6..8e876d2a5 100644 --- a/src/renderer/features/shared/mutations/rating-optimistic-updates.ts +++ b/src/renderer/features/shared/mutations/rating-optimistic-updates.ts @@ -10,7 +10,6 @@ import { AlbumArtistListResponse, AlbumDetailResponse, AlbumListResponse, - Artist, ArtistListResponse, LibraryItem, SetRatingArgs, @@ -120,6 +119,54 @@ export const applyRatingOptimisticUpdates = ( }); } + // Update infinite loader custom query keys + const infiniteLoaderQueryKey = [ + variables.apiClientProps.serverId, + 'item-list-infinite-loader', + LibraryItem.ALBUM, + ]; + + const infiniteLoaderQueries = queryClient.getQueriesData({ + exact: false, + queryKey: infiniteLoaderQueryKey, + }); + + if (infiniteLoaderQueries.length) { + infiniteLoaderQueries.forEach(([queryKey, data]) => { + if (data) { + previousQueries.push({ data, queryKey }); + queryClient.setQueryData( + queryKey, + ( + prev: + | undefined + | { + data: unknown[]; + pagesLoaded: Record; + }, + ) => { + if (prev && prev.data) { + return { + ...prev, + data: prev.data.map((item: any) => { + if (!item || !item.id) { + return item; + } + + return itemIdSet.has(item.id) + ? { ...item, userRating: rating } + : item; + }), + }; + } + + return prev; + }, + ); + } + }); + } + break; } case LibraryItem.ALBUM_ARTIST: { @@ -206,6 +253,54 @@ export const applyRatingOptimisticUpdates = ( }); } + // Update infinite loader custom query keys + const infiniteLoaderQueryKey = [ + variables.apiClientProps.serverId, + 'item-list-infinite-loader', + LibraryItem.ALBUM_ARTIST, + ]; + + const infiniteLoaderQueries = queryClient.getQueriesData({ + exact: false, + queryKey: infiniteLoaderQueryKey, + }); + + if (infiniteLoaderQueries.length) { + infiniteLoaderQueries.forEach(([queryKey, data]) => { + if (data) { + previousQueries.push({ data, queryKey }); + queryClient.setQueryData( + queryKey, + ( + prev: + | undefined + | { + data: unknown[]; + pagesLoaded: Record; + }, + ) => { + if (prev && prev.data) { + return { + ...prev, + data: prev.data.map((item: any) => { + if (!item || !item.id) { + return item; + } + + return itemIdSet.has(item.id) + ? { ...item, userRating: rating } + : item; + }), + }; + } + + return prev; + }, + ); + } + }); + } + break; } case LibraryItem.ARTIST: { @@ -223,7 +318,7 @@ export const applyRatingOptimisticUpdates = ( queryClient.setQueryData(queryKey, (prev: ArtistListResponse) => { return { ...prev, - items: prev.items.map((item: Artist) => { + items: prev.items.map((item: AlbumArtist) => { return itemIdSet.has(item.id) ? { ...item, userRating: rating } : item; @@ -254,7 +349,7 @@ export const applyRatingOptimisticUpdates = ( pages: prev.pages.map((page: ArtistListResponse) => { return { ...page, - items: page.items.map((item: Artist) => { + items: page.items.map((item: AlbumArtist) => { return itemIdSet.has(item.id) ? { ...item, userRating: rating } : item; @@ -268,6 +363,54 @@ export const applyRatingOptimisticUpdates = ( }); } + // Update infinite loader custom query keys + const infiniteLoaderQueryKey = [ + variables.apiClientProps.serverId, + 'item-list-infinite-loader', + LibraryItem.ARTIST, + ]; + + const infiniteLoaderQueries = queryClient.getQueriesData({ + exact: false, + queryKey: infiniteLoaderQueryKey, + }); + + if (infiniteLoaderQueries.length) { + infiniteLoaderQueries.forEach(([queryKey, data]) => { + if (data) { + previousQueries.push({ data, queryKey }); + queryClient.setQueryData( + queryKey, + ( + prev: + | undefined + | { + data: unknown[]; + pagesLoaded: Record; + }, + ) => { + if (prev && prev.data) { + return { + ...prev, + data: prev.data.map((item: any) => { + if (!item || !item.id) { + return item; + } + + return itemIdSet.has(item.id) + ? { ...item, userRating: rating } + : item; + }), + }; + } + + return prev; + }, + ); + } + }); + } + break; } case LibraryItem.PLAYLIST_SONG: diff --git a/src/renderer/features/sidebar/components/server-selector.tsx b/src/renderer/features/sidebar/components/server-selector.tsx index 291bb484d..ec024f0d3 100644 --- a/src/renderer/features/sidebar/components/server-selector.tsx +++ b/src/renderer/features/sidebar/components/server-selector.tsx @@ -9,7 +9,7 @@ import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png'; import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png'; import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; import { ServerSelectorItems } from '/@/renderer/features/sidebar/components/server-selector-items'; -import { useCurrentServer } from '/@/renderer/store'; +import { useAppStore, useCurrentServer } from '/@/renderer/store'; import { hasFeature } from '/@/shared/api/utils'; import { Box } from '/@/shared/components/box/box'; import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; @@ -20,13 +20,10 @@ import { Text } from '/@/shared/components/text/text'; import { ServerType } from '/@/shared/types/domain-types'; import { ServerFeature } from '/@/shared/types/features-types'; -interface ServerSelectorProps { - showImage?: boolean; -} - -export const ServerSelector = ({ showImage = false }: ServerSelectorProps) => { +export const ServerSelector = () => { const { t } = useTranslation(); const currentServer = useCurrentServer(); + const showImage = useAppStore((state) => state.sidebar.image); const { data: musicFolders } = useQuery( currentServer diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index 1edb51ae5..3a4c4f589 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -15,11 +15,11 @@ import { SidebarSharedPlaylistList, } from '/@/renderer/features/sidebar/components/sidebar-playlist-list'; import { + useAppStore, useAppStoreActions, useFullScreenPlayerStore, usePlayerSong, useSetFullScreenPlayerStore, - useSidebarStore, } from '/@/renderer/store'; import { SidebarItemType, @@ -38,10 +38,8 @@ import { Platform } from '/@/shared/types/types'; export const Sidebar = () => { const { t } = useTranslation(); - const sidebar = useSidebarStore(); - const { setSideBar } = useAppStoreActions(); + const { sidebarPlaylistList } = useGeneralSettings(); - const currentSong = usePlayerSong(); const translatedSidebarItemMap = useMemo( () => ({ @@ -59,36 +57,6 @@ export const Sidebar = () => { [t], ); - const upsizedImageUrl = currentSong?.imageUrl - ?.replace(/size=\d+/, 'size=450') - .replace(/width=\d+/, 'width=450') - .replace(/height=\d+/, 'height=450'); - - const showImage = sidebar.image; - const isSongDefined = Boolean(currentSong?.id); - - const setFullScreenPlayerStore = useSetFullScreenPlayerStore(); - const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore(); - const expandFullScreenPlayer = () => { - setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded }); - }; - - const handleToggleContextMenu = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - if (!currentSong) { - return; - } - - if (isSongDefined && !isFullScreenPlayerExpanded) { - ContextMenuController.call({ - cmd: { items: [currentSong!], type: LibraryItem.SONG }, - event: e, - }); - } - }; - const { sidebarItems } = useGeneralSettings(); const { windowBarStyle } = useWindowSettings(); @@ -164,69 +132,110 @@ export const Sidebar = () => { - + - - {showImage && ( - - - {upsizedImageUrl ? ( - - ) : ( - - )} - - { - e.stopPropagation(); - setSideBar({ image: false }); - }} - opacity={0.8} - radius="md" - style={{ - cursor: 'default', - position: 'absolute', - right: '1rem', - top: '1rem', - }} - tooltip={{ - label: t('common.collapse', { - postProcess: 'titleCase', - }), - openDelay: 500, - }} - /> - - )} - + ); }; + +const SidebarImage = () => { + const { t } = useTranslation(); + const showImage = useAppStore((state) => state.sidebar.image); + const leftWidth = useAppStore((state) => state.sidebar.leftWidth); + const { setSideBar } = useAppStoreActions(); + const currentSong = usePlayerSong(); + + const upsizedImageUrl = currentSong?.imageUrl + ?.replace(/size=\d+/, 'size=450') + .replace(/width=\d+/, 'width=450') + .replace(/height=\d+/, 'height=450'); + + const isSongDefined = Boolean(currentSong?.id); + + const setFullScreenPlayerStore = useSetFullScreenPlayerStore(); + const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore(); + const expandFullScreenPlayer = () => { + setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded }); + }; + + const handleToggleContextMenu = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!currentSong) { + return; + } + + if (isSongDefined && !isFullScreenPlayerExpanded) { + ContextMenuController.call({ + cmd: { items: [currentSong!], type: LibraryItem.SONG }, + event: e, + }); + } + }; + + return ( + + {showImage && ( + + + {upsizedImageUrl ? ( + + ) : ( + + )} + + { + e.stopPropagation(); + setSideBar({ image: false }); + }} + opacity={0.8} + radius="md" + style={{ + cursor: 'default', + position: 'absolute', + right: '1rem', + top: '1rem', + }} + tooltip={{ + label: t('common.collapse', { + postProcess: 'titleCase', + }), + openDelay: 500, + }} + /> + + )} + + ); +}; diff --git a/src/renderer/features/titlebar/components/app-menu.tsx b/src/renderer/features/titlebar/components/app-menu.tsx index 2d4dc90c9..b32f6b2ed 100644 --- a/src/renderer/features/titlebar/components/app-menu.tsx +++ b/src/renderer/features/titlebar/components/app-menu.tsx @@ -6,7 +6,7 @@ import { Link, useNavigate } from 'react-router'; import packageJson from '../../../../../package.json'; import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal'; -import { useAppStore, useAppStoreActions, useSidebarStore } from '/@/renderer/store'; +import { useAppStore, useAppStoreActions } from '/@/renderer/store'; import { DropdownMenu, MenuItemProps } from '/@/shared/components/dropdown-menu/dropdown-menu'; import { Icon } from '/@/shared/components/icon/icon'; import { toast } from '/@/shared/components/toast/toast'; @@ -67,8 +67,8 @@ interface RegularMenuItem extends BaseMenuItem { export const AppMenu = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const { collapsed } = useSidebarStore(); - const { privateMode } = useAppStore(); + const collapsed = useAppStore((state) => state.sidebar.collapsed); + const privateMode = useAppStore((state) => state.privateMode); const { setPrivateMode, setSideBar } = useAppStoreActions(); const handleBrowserDevTools = () => { diff --git a/src/renderer/hooks/use-fast-average-color.tsx b/src/renderer/hooks/use-fast-average-color.tsx index d4995b8cd..3da6ef42a 100644 --- a/src/renderer/hooks/use-fast-average-color.tsx +++ b/src/renderer/hooks/use-fast-average-color.tsx @@ -43,6 +43,7 @@ export const useFastAverageColor = (args: { }) => { const { algorithm, default: defaultColor, id, src, srcLoaded } = args; const idRef = useRef(id); + const processingSrcRef = useRef(null); const [isLoading, setIsLoading] = useState(false); @@ -59,12 +60,23 @@ export const useFastAverageColor = (args: { useEffect(() => { let isMounted = true; let fac: FastAverageColor | null = null; + let timeoutId: NodeJS.Timeout | null = null; + + // Reset loading state when src changes or srcLoaded becomes false + if (!src || !srcLoaded) { + setIsLoading(false); + processingSrcRef.current = null; + } if (src && srcLoaded) { + processingSrcRef.current = src; setIsLoading(true); - setTimeout(() => { - if (!isMounted) return; + timeoutId = setTimeout(() => { + // Check if src has changed since we started processing + if (!isMounted || processingSrcRef.current !== src) { + return; + } fac = new FastAverageColor(); fac.getColorAsync(src, { @@ -73,7 +85,8 @@ export const useFastAverageColor = (args: { mode: 'speed', }) .then((color) => { - if (isMounted) { + // Only update if this is still the current src being processed + if (isMounted && processingSrcRef.current === src) { idRef.current = id; setBackground({ background: color.rgb, @@ -81,6 +94,7 @@ export const useFastAverageColor = (args: { isLight: color.isLight, }); setIsLoading(false); + processingSrcRef.current = null; } if (fac) { fac.destroy(); @@ -88,7 +102,8 @@ export const useFastAverageColor = (args: { } }) .catch((e) => { - if (isMounted) { + // Only update if this is still the current src being processed + if (isMounted && processingSrcRef.current === src) { console.error('Error fetching average color', e); idRef.current = id; setBackground({ @@ -97,6 +112,7 @@ export const useFastAverageColor = (args: { isLight: false, }); setIsLoading(false); + processingSrcRef.current = null; } if (fac) { fac.destroy(); @@ -112,11 +128,18 @@ export const useFastAverageColor = (args: { isDark: true, isLight: false, }); + setIsLoading(false); + processingSrcRef.current = null; } } return () => { isMounted = false; + processingSrcRef.current = null; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } if (fac) { fac.destroy(); fac = null; diff --git a/src/renderer/layouts/default-layout/left-sidebar.tsx b/src/renderer/layouts/default-layout/left-sidebar.tsx index 942f5f671..405905329 100644 --- a/src/renderer/layouts/default-layout/left-sidebar.tsx +++ b/src/renderer/layouts/default-layout/left-sidebar.tsx @@ -5,7 +5,7 @@ import styles from './left-sidebar.module.css'; import { ResizeHandle } from '/@/renderer/features/shared/components/resize-handle'; import { CollapsedSidebar } from '/@/renderer/features/sidebar/components/collapsed-sidebar'; import { Sidebar } from '/@/renderer/features/sidebar/components/sidebar'; -import { useSidebarStore } from '/@/renderer/store'; +import { useAppStore } from '/@/renderer/store'; interface LeftSidebarProps { isResizing: boolean; @@ -14,7 +14,7 @@ interface LeftSidebarProps { export const LeftSidebar = ({ isResizing, startResizing }: LeftSidebarProps) => { const sidebarRef = useRef(null); - const { collapsed } = useSidebarStore(); + const collapsed = useAppStore((state) => state.sidebar.collapsed); return (