diff --git a/src/remote/store/index.ts b/src/remote/store/index.ts index 6844ea23..be0ad642 100644 --- a/src/remote/store/index.ts +++ b/src/remote/store/index.ts @@ -175,7 +175,6 @@ export const useRemoteStore = create()( } case 'song': { set((state) => { - console.log(data); state.info.song = data; }); break; diff --git a/src/renderer/components/virtual-table/hooks/use-rating.ts b/src/renderer/components/virtual-table/hooks/use-rating.ts deleted file mode 100644 index 8a2508a7..00000000 --- a/src/renderer/components/virtual-table/hooks/use-rating.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { useQueryClient, useMutation } from '@tanstack/react-query'; -import { AxiosError } from 'axios'; -import { api } from '/@/renderer/api'; -import { NDAlbumDetail, NDAlbumArtistDetail } from '/@/renderer/api/navidrome.types'; -import { queryKeys } from '/@/renderer/api/query-keys'; -import { SSAlbumDetail, SSAlbumArtistDetail } from '/@/renderer/api/subsonic.types'; -import { - SetRatingArgs, - Album, - AlbumArtist, - LibraryItem, - AnyLibraryItems, - RatingResponse, - ServerType, -} from '/@/renderer/api/types'; -import { useSetAlbumListItemDataById, useSetQueueRating, getServerById } from '/@/renderer/store'; - -export const useUpdateRating = () => { - const queryClient = useQueryClient(); - const setAlbumListData = useSetAlbumListItemDataById(); - const setQueueRating = useSetQueueRating(); - - return useMutation< - RatingResponse, - AxiosError, - Omit, - { previous: { items: AnyLibraryItems } | undefined } - >({ - mutationFn: (args) => { - const server = getServerById(args.serverId); - if (!server) throw new Error('Server not found'); - return api.controller.updateRating({ ...args, apiClientProps: { server } }); - }, - onError: (_error, _variables, context) => { - for (const item of context?.previous?.items || []) { - switch (item.itemType) { - case LibraryItem.ALBUM: - setAlbumListData(item.id, { userRating: item.userRating }); - break; - case LibraryItem.SONG: - setQueueRating([item.id], item.userRating); - break; - } - } - }, - onMutate: (variables) => { - for (const item of variables.query.item) { - switch (item.itemType) { - case LibraryItem.ALBUM: - setAlbumListData(item.id, { userRating: variables.query.rating }); - break; - case LibraryItem.SONG: - setQueueRating([item.id], variables.query.rating); - break; - } - } - - return { previous: { items: variables.query.item } }; - }, - onSuccess: (_data, variables) => { - // We only need to set if we're already on the album detail page - const isAlbumDetailPage = - variables.query.item.length === 1 && - variables.query.item[0].itemType === LibraryItem.ALBUM; - - if (isAlbumDetailPage) { - const { serverType, id: albumId, serverId } = variables.query.item[0] as Album; - - const queryKey = queryKeys.albums.detail(serverId || '', { id: albumId }); - const previous = queryClient.getQueryData(queryKey); - if (previous) { - switch (serverType) { - case ServerType.NAVIDROME: - queryClient.setQueryData(queryKey, { - ...previous, - userRating: variables.query.rating, - }); - break; - case ServerType.SUBSONIC: - queryClient.setQueryData(queryKey, { - ...previous, - userRating: variables.query.rating, - }); - break; - case ServerType.JELLYFIN: - // Jellyfin does not support ratings - break; - } - } - } - - // We only need to set if we're already on the album detail page - const isAlbumArtistDetailPage = - variables.query.item.length === 1 && - variables.query.item[0].itemType === LibraryItem.ALBUM_ARTIST; - - if (isAlbumArtistDetailPage) { - const { - serverType, - id: albumArtistId, - serverId, - } = variables.query.item[0] as AlbumArtist; - - const queryKey = queryKeys.albumArtists.detail(serverId || '', { - id: albumArtistId, - }); - const previous = queryClient.getQueryData(queryKey); - if (previous) { - switch (serverType) { - case ServerType.NAVIDROME: - queryClient.setQueryData(queryKey, { - ...previous, - userRating: variables.query.rating, - }); - break; - case ServerType.SUBSONIC: - queryClient.setQueryData(queryKey, { - ...previous, - userRating: variables.query.rating, - }); - break; - case ServerType.JELLYFIN: - // Jellyfin does not support ratings - break; - } - } - } - }, - }); -}; diff --git a/src/renderer/components/virtual-table/index.tsx b/src/renderer/components/virtual-table/index.tsx index 95a1bdeb..c21bbe0c 100644 --- a/src/renderer/components/virtual-table/index.tsx +++ b/src/renderer/components/virtual-table/index.tsx @@ -42,6 +42,7 @@ import { NoteCell } from '/@/renderer/components/virtual-table/cells/note-cell'; import { RowIndexCell } from '/@/renderer/components/virtual-table/cells/row-index-cell'; import i18n from '/@/i18n/i18n'; import { formatDateAbsolute, formatDateRelative, formatSizeString } from '/@/renderer/utils/format'; +import { useTableChange } from '/@/renderer/hooks/use-song-change'; export * from './table-config-dropdown'; export * from './table-pagination'; @@ -475,6 +476,7 @@ export interface VirtualTableProps extends AgGridReactProps { pagination: TablePaginationType; setPagination: any; }; + shouldUpdateSong?: boolean; stickyHeader?: boolean; transparentHeader?: boolean; } @@ -492,6 +494,7 @@ export const VirtualTable = forwardRef( onGridReady, onGridSizeChanged, paginationProps, + shouldUpdateSong, ...rest }: VirtualTableProps, ref: Ref, @@ -506,6 +509,8 @@ export const VirtualTable = forwardRef( } }); + useTableChange(tableRef, shouldUpdateSong === true); + const defaultColumnDefs: ColDef = useMemo(() => { return { lockPinned: true, diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx index 73834a21..ab6ca368 100644 --- a/src/renderer/features/albums/components/album-detail-content.tsx +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -456,6 +456,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP key={`table-${tableConfig.rowHeight}`} ref={tableRef} autoHeight + shouldUpdateSong stickyHeader suppressCellFocus suppressLoadingOverlay diff --git a/src/renderer/features/artists/components/album-artist-detail-content.tsx b/src/renderer/features/artists/components/album-artist-detail-content.tsx index ef0da400..8ad0cad3 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -560,6 +560,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten autoFitColumns autoHeight deselectOnClickOutside + shouldUpdateSong stickyHeader suppressCellFocus suppressHorizontalScroll @@ -567,7 +568,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten suppressRowDrag columnDefs={topSongsColumnDefs} enableCellChangeFlash={false} - getRowId={(data) => data.data.uniqueId} + getRowId={(data) => data.data.id} rowData={topSongs} rowHeight={60} rowSelection="multiple" diff --git a/src/renderer/features/artists/components/album-artist-detail-top-songs-list-content.tsx b/src/renderer/features/artists/components/album-artist-detail-top-songs-list-content.tsx index a85be57a..63ade6d4 100644 --- a/src/renderer/features/artists/components/album-artist-detail-top-songs-list-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-top-songs-list-content.tsx @@ -64,8 +64,9 @@ export const AlbumArtistDetailTopSongsListContent = ({ data.data.uniqueId} + getRowId={(data) => data.data.id} rowClassRules={rowClassRules} rowData={data} rowModelType="clientSide" diff --git a/src/renderer/features/player/mutations/scrobble-mutation.ts b/src/renderer/features/player/mutations/scrobble-mutation.ts index 77dc7251..714c937b 100644 --- a/src/renderer/features/player/mutations/scrobble-mutation.ts +++ b/src/renderer/features/player/mutations/scrobble-mutation.ts @@ -4,9 +4,11 @@ import { api } from '/@/renderer/api'; import { ScrobbleResponse, ScrobbleArgs } from '/@/renderer/api/types'; import { MutationOptions } from '/@/renderer/lib/react-query'; import { getServerById, useIncrementQueuePlayCount } from '/@/renderer/store'; +import { usePlayEvent } from '/@/renderer/store/event.store'; export const useSendScrobble = (options?: MutationOptions) => { const incrementPlayCount = useIncrementQueuePlayCount(); + const sendPlayEvent = usePlayEvent(); return useMutation< ScrobbleResponse, @@ -23,6 +25,7 @@ export const useSendScrobble = (options?: MutationOptions) => { // Manually increment the play count for the song in the queue if scrobble was submitted if (variables.query.submission) { incrementPlayCount([variables.query.id]); + sendPlayEvent([variables.query.id]); } }, ...options, diff --git a/src/renderer/features/playlists/components/playlist-detail-content.tsx b/src/renderer/features/playlists/components/playlist-detail-content.tsx index 12dc2192..9f51c987 100644 --- a/src/renderer/features/playlists/components/playlist-detail-content.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-content.tsx @@ -215,16 +215,14 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) autoFitColumns autoHeight deselectOnClickOutside + shouldUpdateSong stickyHeader suppressCellFocus suppressHorizontalScroll suppressLoadingOverlay suppressRowDrag columnDefs={columnDefs} - getRowId={(data) => { - // It's possible that there are duplicate song ids in a playlist - return `${data.data.id}-${data.data.pageIndex}`; - }} + getRowId={(data) => `${data.data.id}-${data.data.pageIndex}`} rowClassRules={rowClassRules} rowData={playlistSongData} rowHeight={60} diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx index 5954f6b0..c2b6d2d4 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx @@ -279,6 +279,7 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`} ref={tableRef} alwaysShowHorizontalScroll + shouldUpdateSong autoFitColumns={page.table.autoFit} columnDefs={columnDefs} context={{ diff --git a/src/renderer/features/search/components/search-content.tsx b/src/renderer/features/search/components/search-content.tsx index 38ffc5c9..56c2f5ba 100644 --- a/src/renderer/features/search/components/search-content.tsx +++ b/src/renderer/features/search/components/search-content.tsx @@ -101,6 +101,7 @@ export const SearchContent = ({ tableRef }: SearchContentProps) => { getRowId={(data) => data.data.id} infiniteInitialRowCount={25} rowClassRules={rowClassRules} + shouldUpdateSong={itemType === LibraryItem.SONG} onRowDoubleClicked={handleRowDoubleClick} /> diff --git a/src/renderer/features/shared/mutations/create-favorite-mutation.ts b/src/renderer/features/shared/mutations/create-favorite-mutation.ts index 7364c630..8a811ff1 100644 --- a/src/renderer/features/shared/mutations/create-favorite-mutation.ts +++ b/src/renderer/features/shared/mutations/create-favorite-mutation.ts @@ -12,6 +12,7 @@ import { import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { getServerById, useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store'; import isElectron from 'is-electron'; +import { useFavoriteEvent } from '/@/renderer/store/event.store'; const remote = isElectron() ? window.electron.remote : null; @@ -20,6 +21,7 @@ export const useCreateFavorite = (args: MutationHookArgs) => { const queryClient = useQueryClient(); const setAlbumListData = useSetAlbumListItemDataById(); const setQueueFavorite = useSetQueueFavorite(); + const setFavoriteEvent = useFavoriteEvent(); return useMutation< FavoriteResponse, @@ -47,6 +49,7 @@ export const useCreateFavorite = (args: MutationHookArgs) => { if (variables.query.type === LibraryItem.SONG) { remote?.updateFavorite(true, serverId, variables.query.id); setQueueFavorite(variables.query.id, true); + setFavoriteEvent(variables.query.id, true); } // We only need to set if we're already on the album detail page diff --git a/src/renderer/features/shared/mutations/delete-favorite-mutation.ts b/src/renderer/features/shared/mutations/delete-favorite-mutation.ts index bb80abb8..a0275704 100644 --- a/src/renderer/features/shared/mutations/delete-favorite-mutation.ts +++ b/src/renderer/features/shared/mutations/delete-favorite-mutation.ts @@ -12,6 +12,7 @@ import { import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { getServerById, useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store'; import isElectron from 'is-electron'; +import { useFavoriteEvent } from '/@/renderer/store/event.store'; const remote = isElectron() ? window.electron.remote : null; @@ -20,6 +21,7 @@ export const useDeleteFavorite = (args: MutationHookArgs) => { const queryClient = useQueryClient(); const setAlbumListData = useSetAlbumListItemDataById(); const setQueueFavorite = useSetQueueFavorite(); + const setFavoriteEvent = useFavoriteEvent(); return useMutation< FavoriteResponse, @@ -47,6 +49,7 @@ export const useDeleteFavorite = (args: MutationHookArgs) => { if (variables.query.type === LibraryItem.SONG) { remote?.updateFavorite(false, serverId, variables.query.id); setQueueFavorite(variables.query.id, false); + setFavoriteEvent(variables.query.id, false); } // We only need to set if we're already on the album detail page diff --git a/src/renderer/features/shared/mutations/set-rating-mutation.ts b/src/renderer/features/shared/mutations/set-rating-mutation.ts index 62423089..db309432 100644 --- a/src/renderer/features/shared/mutations/set-rating-mutation.ts +++ b/src/renderer/features/shared/mutations/set-rating-mutation.ts @@ -15,6 +15,7 @@ import { import { MutationHookArgs } from '/@/renderer/lib/react-query'; import { getServerById, useSetAlbumListItemDataById, useSetQueueRating } from '/@/renderer/store'; import isElectron from 'is-electron'; +import { useRatingEvent } from '/@/renderer/store/event.store'; const remote = isElectron() ? window.electron.remote : null; @@ -23,6 +24,7 @@ export const useSetRating = (args: MutationHookArgs) => { const queryClient = useQueryClient(); const setAlbumListData = useSetAlbumListItemDataById(); const setQueueRating = useSetQueueRating(); + const setRatingEvent = useRatingEvent(); return useMutation< RatingResponse, @@ -43,25 +45,36 @@ export const useSetRating = (args: MutationHookArgs) => { break; case LibraryItem.SONG: setQueueRating([item.id], item.userRating); + setRatingEvent([item.id], item.userRating); break; } } }, onMutate: (variables) => { + const songIds: string[] = []; for (const item of variables.query.item) { switch (item.itemType) { case LibraryItem.ALBUM: setAlbumListData(item.id, { userRating: variables.query.rating }); break; case LibraryItem.SONG: - setQueueRating([item.id], variables.query.rating); + songIds.push(item.id); + break; } } + if (songIds.length > 0) { + setQueueRating(songIds, variables.query.rating); + setRatingEvent(songIds, variables.query.rating); + } + if (remote) { - const ids = variables.query.item.map((item) => item.id); - remote.updateRating(variables.query.rating, variables.query.item[0].serverId, ids); + remote.updateRating( + variables.query.rating, + variables.query.item[0].serverId, + songIds, + ); } return { previous: { items: variables.query.item } }; diff --git a/src/renderer/features/similar-songs/components/similar-songs-list.tsx b/src/renderer/features/similar-songs/components/similar-songs-list.tsx index fc96dd90..5089120d 100644 --- a/src/renderer/features/similar-songs/components/similar-songs-list.tsx +++ b/src/renderer/features/similar-songs/components/similar-songs-list.tsx @@ -61,6 +61,7 @@ export const SimilarSongsList = ({ count, fullScreen, song }: SimilarSongsListPr data.data.uniqueId} + getRowId={(data) => data.data.id} rowBuffer={50} rowData={songQuery.data ?? []} rowHeight={tableConfig.rowHeight || 40} diff --git a/src/renderer/features/songs/components/song-list-grid-view.tsx b/src/renderer/features/songs/components/song-list-grid-view.tsx index 30928a4d..8adfaddf 100644 --- a/src/renderer/features/songs/components/song-list-grid-view.tsx +++ b/src/renderer/features/songs/components/song-list-grid-view.tsx @@ -1,5 +1,5 @@ import { QueryKey, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useMemo } from 'react'; +import { MutableRefObject, useCallback, useEffect, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; import AutoSizer, { Size } from 'react-virtualized-auto-sizer'; import { ListOnScrollProps } from 'react-window'; @@ -16,6 +16,7 @@ import { SONG_CARD_ROWS } from '/@/renderer/components'; import { VirtualGridAutoSizerContainer, VirtualInfiniteGrid, + VirtualInfiniteGridRef, } from '/@/renderer/components/virtual-grid'; import { useListContext } from '/@/renderer/context/list-context'; import { usePlayQueueAdd } from '/@/renderer/features/player'; @@ -23,8 +24,14 @@ import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer, useListStoreActions, useListStoreByKey } from '/@/renderer/store'; import { CardRow, ListDisplayType } from '/@/renderer/types'; import { useHandleFavorite } from '/@/renderer/features/shared/hooks/use-handle-favorite'; +import { useEventStore } from '/@/renderer/store/event.store'; -export const SongListGridView = ({ gridRef, itemCount }: any) => { +interface SongListGridViewProps { + gridRef: MutableRefObject; + itemCount?: number; +} + +export const SongListGridView = ({ gridRef, itemCount }: SongListGridViewProps) => { const queryClient = useQueryClient(); const server = useCurrentServer(); const handlePlayQueueAdd = usePlayQueueAdd(); @@ -38,6 +45,30 @@ export const SongListGridView = ({ gridRef, itemCount }: any) => { const handleFavorite = useHandleFavorite({ gridRef, server }); + useEffect(() => { + const unSub = useEventStore.subscribe((state) => { + const event = state.event; + if (event && event.event === 'favorite') { + const idSet = new Set(state.ids); + const userFavorite = event.favorite; + + gridRef.current?.updateItemData((data) => { + if (idSet.has(data.id)) { + return { + ...data, + userFavorite, + }; + } + return data; + }); + } + }); + + return () => { + unSub(); + }; + }, [gridRef]); + const cardRows = useMemo(() => { const rows: CardRow[] = [ SONG_CARD_ROWS.name, diff --git a/src/renderer/features/songs/components/song-list-table-view.tsx b/src/renderer/features/songs/components/song-list-table-view.tsx index 701bc38a..ff498dd3 100644 --- a/src/renderer/features/songs/components/song-list-table-view.tsx +++ b/src/renderer/features/songs/components/song-list-table-view.tsx @@ -56,6 +56,7 @@ export const SongListTableView = ({ tableRef, itemCount }: SongListTableViewProp key={`table-${tableProps.rowHeight}-${server?.id}`} ref={tableRef} {...tableProps} + shouldUpdateSong context={{ ...tableProps.context, currentSong, diff --git a/src/renderer/hooks/use-song-change.ts b/src/renderer/hooks/use-song-change.ts new file mode 100644 index 00000000..a6c008bb --- /dev/null +++ b/src/renderer/hooks/use-song-change.ts @@ -0,0 +1,77 @@ +import { MutableRefObject, useCallback, useEffect } from 'react'; +import { useEventStore, UserEvent } from '/@/renderer/store/event.store'; +import { RowNode } from '@ag-grid-community/core'; +import { AgGridReact } from '@ag-grid-community/react'; +import { Song } from '/@/renderer/api/types'; + +export const useSongChange = ( + handler: (ids: string[], event: UserEvent) => void, + enabled: boolean, +) => { + useEffect(() => { + if (!enabled) return () => {}; + + const unSub = useEventStore.subscribe((state) => { + if (state.event) { + handler(state.ids, state.event); + } + }); + + return () => { + unSub(); + }; + }, [handler, enabled]); +}; + +export const useTableChange = ( + tableRef: MutableRefObject, + enabled: boolean, +) => { + const handler = useCallback( + (ids: string[], event: UserEvent) => { + const api = tableRef.current?.api; + if (!api) return; + + const rowNodes: RowNode[] = []; + const idSet = new Set(ids); + + api.forEachNode((node: RowNode) => { + if (!node.data || !idSet.has(node.data.id)) return; + + switch (event.event) { + case 'favorite': { + if (node.data.userFavorite !== event.favorite) { + node.setDataValue('userFavorite', event.favorite); + } + break; + } + case 'play': + if (node.data.lastPlayedAt !== event.timestamp) { + node.setData({ + ...node.data, + lastPlayedAt: event.timestamp, + playCount: node.data.playCount + 1, + }); + } + node.data.lastPlayedAt = event.timestamp; + break; + case 'rating': { + if (node.data.userRating !== event.rating) { + node.setDataValue('userRating', event.rating); + rowNodes.push(node); + } + break; + } + } + }); + + // This is required to redraw star rows + if (rowNodes.length > 0) { + api.redrawRows({ rowNodes }); + } + }, + [tableRef], + ); + + useSongChange(handler, enabled); +}; diff --git a/src/renderer/store/event.store.ts b/src/renderer/store/event.store.ts new file mode 100644 index 00000000..8d69bd3e --- /dev/null +++ b/src/renderer/store/event.store.ts @@ -0,0 +1,71 @@ +import { create } from 'zustand'; +import { devtools, subscribeWithSelector } from 'zustand/middleware'; +import { immer } from 'zustand/middleware/immer'; + +export type FavoriteEvent = { + event: 'favorite'; + favorite: boolean; +}; + +export type PlayEvent = { + event: 'play'; + timestamp: string; +}; + +export type RatingEvent = { + event: 'rating'; + rating: number | null; +}; + +export type UserEvent = FavoriteEvent | PlayEvent | RatingEvent; + +export interface EventState { + event: UserEvent | null; + ids: string[]; +} + +export interface EventSlice extends EventState { + actions: { + favorite: (ids: string[], favorite: boolean) => void; + play: (ids: string[]) => void; + rate: (ids: string[], rating: number | null) => void; + }; +} + +export const useEventStore = create()( + subscribeWithSelector( + devtools( + immer((set) => ({ + actions: { + favorite(ids, favorite) { + set((state) => { + state.event = { event: 'favorite', favorite }; + state.ids = ids; + }); + }, + play(ids) { + set((state) => { + state.event = { event: 'play', timestamp: new Date().toISOString() }; + state.ids = ids; + }); + }, + rate(ids, rating) { + set((state) => { + state.event = { event: 'rating', rating }; + state.ids = ids; + }); + }, + }, + event: null, + ids: [], + })), + { name: 'event_store' }, + ), + ), +); + +export const useFavoriteEvent = () => useEventStore((state) => state.actions.favorite); + +export const usePlayEvent = () => useEventStore((state) => state.actions.play); + +export const useRatingEvent = () => useEventStore((state) => state.actions.rate);