diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index ef326b76..15ef8c87 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -213,6 +213,16 @@ const updatePlaylist = async (args: UpdatePlaylistArgs) => { return (apiController('updatePlaylist') as ControllerEndpoint['updatePlaylist'])?.(args); }; +const getPlaylistDetail = async (args: PlaylistDetailArgs) => { + return (apiController('getPlaylistDetail') as ControllerEndpoint['getPlaylistDetail'])?.(args); +}; + +const getPlaylistSongList = async (args: PlaylistSongListArgs) => { + return (apiController('getPlaylistSongList') as ControllerEndpoint['getPlaylistSongList'])?.( + args, + ); +}; + export const controller = { createPlaylist, getAlbumArtistList, @@ -221,7 +231,9 @@ export const controller = { getArtistList, getGenreList, getMusicFolderList, + getPlaylistDetail, getPlaylistList, + getPlaylistSongList, getSongList, updatePlaylist, }; diff --git a/src/renderer/api/navidrome.api.ts b/src/renderer/api/navidrome.api.ts index 87934bec..039dc668 100644 --- a/src/renderer/api/navidrome.api.ts +++ b/src/renderer/api/navidrome.api.ts @@ -313,19 +313,16 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise => { - const { query, server, signal } = args; - - const previous = query.previous as NDPlaylist; + const { query, body, server, signal } = args; const json: NDUpdatePlaylistParams = { - ...previous, - comment: query.comment || '', - name: query.name, - public: query.public || false, + comment: body.comment || '', + name: body.name, + public: body.public || false, }; const data = await api - .post(`api/playlist/${previous.id}`, { + .post(`api/playlist/${query.id}`, { headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` }, json, prefixUrl: server?.url, @@ -335,7 +332,6 @@ const updatePlaylist = async (args: UpdatePlaylistArgs): Promise(); + const res = await api.get(`api/playlist/${query.id}/tracks`, { + headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` }, + prefixUrl: server?.url, + searchParams: parseSearchParams(searchParams), + signal, + }); + + const data = await res.json(); + const itemCount = res.headers.get('x-total-count'); return { items: data, startIndex: query?.startIndex || 0, - totalRecordCount: data.length, + totalRecordCount: Number(itemCount), }; }; diff --git a/src/renderer/api/navidrome.types.ts b/src/renderer/api/navidrome.types.ts index fb712cdf..e792c96a 100644 --- a/src/renderer/api/navidrome.types.ts +++ b/src/renderer/api/navidrome.types.ts @@ -269,7 +269,7 @@ export type NDCreatePlaylistResponse = { export type NDCreatePlaylist = NDCreatePlaylistResponse; -export type NDUpdatePlaylistParams = NDPlaylist; +export type NDUpdatePlaylistParams = Partial; export type NDUpdatePlaylistResponse = NDPlaylist; diff --git a/src/renderer/api/normalize.ts b/src/renderer/api/normalize.ts index cb7859bd..2e9345a7 100644 --- a/src/renderer/api/normalize.ts +++ b/src/renderer/api/normalize.ts @@ -23,6 +23,7 @@ import type { RawAlbumListResponse, RawGenreListResponse, RawMusicFolderListResponse, + RawPlaylistDetailResponse, RawPlaylistListResponse, RawSongListResponse, } from '/@/renderer/api/types'; @@ -191,12 +192,32 @@ const playlistList = (data: RawPlaylistListResponse | undefined, server: ServerL }; }; +const playlistDetail = ( + data: RawPlaylistDetailResponse | undefined, + server: ServerListItem | null, +) => { + let playlist; + switch (server?.type) { + case 'jellyfin': + playlist = jfNormalize.playlist(data as JFPlaylist); + break; + case 'navidrome': + playlist = ndNormalize.playlist(data as NDPlaylist); + break; + case 'subsonic': + break; + } + + return playlist; +}; + export const normalize = { albumArtistList, albumDetail, albumList, genreList, musicFolderList, + playlistDetail, playlistList, songList, }; diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 7a8f8e0a..2e9dea15 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -6,6 +6,7 @@ import type { ArtistListQuery, PlaylistListQuery, PlaylistDetailQuery, + PlaylistSongListQuery, } from './types'; export const queryKeys = { @@ -48,8 +49,8 @@ export const queryKeys = { }, playlists: { detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => { - if (query) return [serverId, 'playlists', 'detail', id, query] as const; - if (id) return [serverId, 'playlists', 'detail', id] as const; + if (query) return [serverId, 'playlists', id, 'detail', query] as const; + if (id) return [serverId, 'playlists', id, 'detail'] as const; return [serverId, 'playlists', 'detail'] as const; }, list: (serverId: string, query?: PlaylistListQuery) => { @@ -57,6 +58,11 @@ export const queryKeys = { return [serverId, 'playlists', 'list'] as const; }, root: (serverId: string) => [serverId, 'playlists'] as const, + songList: (serverId: string, id: string, query?: PlaylistSongListQuery) => { + if (query) return [serverId, 'playlists', id, 'songList', query] as const; + if (id) return [serverId, 'playlists', id, 'songList'] as const; + return [serverId, 'playlists', 'songList'] as const; + }, }, server: { root: (serverId: string) => [serverId] as const, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index e5480521..bb90c143 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -742,17 +742,23 @@ export type CreatePlaylistArgs = { query: CreatePlaylistQuery } & BaseEndpointAr // Update Playlist export type RawUpdatePlaylistResponse = UpdatePlaylistResponse | undefined; -export type UpdatePlaylistResponse = { id: string; name: string }; +export type UpdatePlaylistResponse = { id: string }; export type UpdatePlaylistQuery = { + id: string; +}; + +export type UpdatePlaylistBody = { comment?: string; name: string; - previous: RawPlaylistDetailResponse; public?: boolean; rules?: Record; }; -export type UpdatePlaylistArgs = { query: UpdatePlaylistQuery } & BaseEndpointArgs; +export type UpdatePlaylistArgs = { + body: UpdatePlaylistBody; + query: UpdatePlaylistQuery; +} & BaseEndpointArgs; // Delete Playlist export type RawDeletePlaylistResponse = NDDeletePlaylist | undefined; diff --git a/src/renderer/features/playlists/components/playlist-detail-content.tsx b/src/renderer/features/playlists/components/playlist-detail-content.tsx new file mode 100644 index 00000000..3d862cb1 --- /dev/null +++ b/src/renderer/features/playlists/components/playlist-detail-content.tsx @@ -0,0 +1,254 @@ +import { MutableRefObject, useCallback, useMemo } from 'react'; +import type { + BodyScrollEvent, + CellContextMenuEvent, + ColDef, + RowDoubleClickedEvent, +} from '@ag-grid-community/core'; +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { getColumnDefs, VirtualTable } from '/@/renderer/components'; +import { useCurrentServer, useSetSongTable, useSongListStore } from '/@/renderer/store'; +import { LibraryItem } from '/@/renderer/types'; +import debounce from 'lodash/debounce'; +import { openContextMenu } from '/@/renderer/features/context-menu'; +import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; +import sortBy from 'lodash/sortBy'; +import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add'; +import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; +import { QueueSong } from '/@/renderer/api/types'; +import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query'; +import { useParams } from 'react-router'; +import styled from 'styled-components'; + +const ContentContainer = styled.div` + display: flex; + flex-direction: column; + max-width: 1920px; + padding: 1rem 2rem; + overflow: hidden; + + .ag-theme-alpine-dark { + --ag-header-background-color: rgba(0, 0, 0, 0%); + } + + .ag-header-container { + z-index: 1000; + } + + .ag-header-cell-resize { + top: 25%; + width: 7px; + height: 50%; + background-color: rgb(70, 70, 70, 20%); + } +`; + +interface PlaylistDetailContentProps { + tableRef: MutableRefObject; +} + +export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) => { + const { playlistId } = useParams() as { playlistId: string }; + // const queryClient = useQueryClient(); + const server = useCurrentServer(); + const page = useSongListStore(); + + // const pagination = useSongTablePagination(); + // const setPagination = useSetSongTablePagination(); + const setTable = useSetSongTable(); + const handlePlayQueueAdd = useHandlePlayQueueAdd(); + const playButtonBehavior = usePlayButtonBehavior(); + + // const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED; + + const playlistSongsQuery = usePlaylistSongList({ + id: playlistId, + limit: 50, + startIndex: 0, + }); + + console.log('checkPlaylistList.data', playlistSongsQuery.data); + + const columnDefs: ColDef[] = useMemo( + () => getColumnDefs(page.table.columns), + [page.table.columns], + ); + + const defaultColumnDefs: ColDef = useMemo(() => { + return { + lockPinned: true, + lockVisible: true, + resizable: true, + }; + }, []); + + // const onGridReady = useCallback( + // (params: GridReadyEvent) => { + // const dataSource: IDatasource = { + // getRows: async (params) => { + // const limit = params.endRow - params.startRow; + // const startIndex = params.startRow; + + // const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, { + // id: playlistId, + // limit, + // startIndex, + // }); + + // const songsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) => + // api.controller.getPlaylistSongList({ + // query: { + // id: playlistId, + // limit, + // startIndex, + // }, + // server, + // signal, + // }), + // ); + + // const songs = api.normalize.songList(songsRes, server); + // params.successCallback(songs?.items || [], songsRes?.totalRecordCount); + // }, + // rowCount: undefined, + // }; + // params.api.setDatasource(dataSource); + // params.api.ensureIndexVisible(page.table.scrollOffset, 'top'); + // }, + // [page.table.scrollOffset, playlistId, queryClient, server], + // ); + + // const onPaginationChanged = useCallback( + // (event: PaginationChangedEvent) => { + // if (!isPaginationEnabled || !event.api) return; + + // // Scroll to top of page on pagination change + // const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage; + // event.api?.ensureIndexVisible(currentPageStartIndex, 'top'); + + // setPagination({ + // itemsPerPage: event.api.paginationGetPageSize(), + // totalItems: event.api.paginationGetRowCount(), + // totalPages: event.api.paginationGetTotalPages() + 1, + // }); + // }, + // [isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination], + // ); + + const handleGridSizeChange = () => { + if (page.table.autoFit) { + tableRef?.current?.api.sizeColumnsToFit(); + } + }; + + const handleColumnChange = useCallback(() => { + const { columnApi } = tableRef?.current || {}; + const columnsOrder = columnApi?.getAllGridColumns(); + + if (!columnsOrder) return; + + const columnsInSettings = page.table.columns; + const updatedColumns = []; + for (const column of columnsOrder) { + const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId); + + if (columnInSettings) { + updatedColumns.push({ + ...columnInSettings, + ...(!page.table.autoFit && { + width: column.getActualWidth(), + }), + }); + } + } + + setTable({ columns: updatedColumns }); + }, [page.table.autoFit, page.table.columns, setTable, tableRef]); + + const debouncedColumnChange = debounce(handleColumnChange, 200); + + const handleScroll = (e: BodyScrollEvent) => { + const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0)); + setTable({ scrollOffset }); + }; + + const handleContextMenu = (e: CellContextMenuEvent) => { + if (!e.event) return; + const clickEvent = e.event as MouseEvent; + clickEvent.preventDefault(); + + const selectedNodes = e.api.getSelectedNodes(); + const selectedIds = selectedNodes.map((node) => node.data.id); + let selectedRows = sortBy(selectedNodes, ['rowIndex']).map((node) => node.data); + + if (!selectedIds.includes(e.data.id)) { + e.api.deselectAll(); + e.node.setSelected(true); + selectedRows = [e.data]; + } + + openContextMenu({ + data: selectedRows, + menuItems: SONG_CONTEXT_MENU_ITEMS, + type: LibraryItem.SONG, + xPos: clickEvent.clientX, + yPos: clickEvent.clientY, + }); + }; + + const handleRowDoubleClick = (e: RowDoubleClickedEvent) => { + if (!e.data) return; + handlePlayQueueAdd({ + byData: [e.data], + play: playButtonBehavior, + }); + }; + + return ( + + { + params.api.setDomLayout('autoHeight'); + params.api.sizeColumnsToFit(); + }} + onGridSizeChanged={handleGridSizeChange} + onRowDoubleClicked={handleRowDoubleClick} + /> + {/* + {page.display === ListDisplayType.TABLE_PAGINATED && ( + + )} + */} + + ); +}; diff --git a/src/renderer/features/playlists/components/playlist-detail-header.tsx b/src/renderer/features/playlists/components/playlist-detail-header.tsx new file mode 100644 index 00000000..bc98eba5 --- /dev/null +++ b/src/renderer/features/playlists/components/playlist-detail-header.tsx @@ -0,0 +1,156 @@ +import { Center, Group } from '@mantine/core'; +import { useMergedRef } from '@mantine/hooks'; +import { forwardRef } from 'react'; +import { RiAlbumFill } from 'react-icons/ri'; +import { useParams } from 'react-router'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; +import { Text, TextTitle } from '/@/renderer/components'; +import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query'; +import { PlayButton } from '/@/renderer/features/shared'; +import { useContainerQuery } from '/@/renderer/hooks'; +import { AppRoute } from '/@/renderer/router/routes'; + +const HeaderContainer = styled.div` + position: relative; + display: grid; + grid-auto-columns: 1fr; + grid-template-areas: 'image info'; + grid-template-rows: 1fr; + grid-template-columns: 250px minmax(0, 1fr); + gap: 0.5rem; + width: 100%; + max-width: 100%; + height: 30vh; + min-height: 340px; + max-height: 500px; + padding: 5rem 2rem 2rem; +`; + +const CoverImageWrapper = styled.div` + z-index: 15; + display: flex; + grid-area: image; + align-items: flex-end; + justify-content: center; + height: 100%; + filter: drop-shadow(0 0 8px rgb(0, 0, 0, 50%)); +`; + +const MetadataWrapper = styled.div` + z-index: 15; + display: flex; + flex-direction: column; + grid-area: info; + justify-content: flex-end; + width: 100%; +`; + +const StyledImage = styled.img` + object-fit: cover; +`; + +const BackgroundImage = styled.div<{ background: string }>` + position: absolute; + top: 0; + z-index: 0; + width: 100%; + height: 100%; + background: ${(props) => props.background}; +`; + +const BackgroundImageOverlay = styled.div` + position: absolute; + top: 0; + left: 0; + z-index: 0; + width: 100%; + height: 100%; + background: linear-gradient(180deg, rgba(25, 26, 28, 5%), var(--main-bg)), var(--background-noise); +`; + +interface PlaylistDetailHeaderProps { + background: string; + imageUrl?: string; +} + +export const PlaylistDetailHeader = forwardRef( + ({ background, imageUrl }: PlaylistDetailHeaderProps, ref) => { + const { playlistId } = useParams() as { playlistId: string }; + const detailQuery = usePlaylistDetail({ id: playlistId }); + const cq = useContainerQuery(); + + const mergedRef = useMergedRef(ref, cq.ref); + + const titleSize = cq.isXl + ? '6rem' + : cq.isLg + ? '5.5rem' + : cq.isMd + ? '4.5rem' + : cq.isSm + ? '3.5rem' + : '2rem'; + + return ( + <> + + + + + {imageUrl ? ( + + ) : ( +
+ +
+ )} +
+ + + + Playlist + + + + {detailQuery?.data?.name} + + + + + +
+ + ); + }, +); diff --git a/src/renderer/features/playlists/queries/playlist-detail-query.ts b/src/renderer/features/playlists/queries/playlist-detail-query.ts new file mode 100644 index 00000000..b0340ef7 --- /dev/null +++ b/src/renderer/features/playlists/queries/playlist-detail-query.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import type { PlaylistDetailQuery, RawPlaylistDetailResponse } from '/@/renderer/api/types'; +import type { QueryOptions } from '/@/renderer/lib/react-query'; +import { useCurrentServer } from '/@/renderer/store'; +import { api } from '/@/renderer/api'; + +export const usePlaylistDetail = (query: PlaylistDetailQuery, options?: QueryOptions) => { + const server = useCurrentServer(); + + return useQuery({ + enabled: !!server?.id, + queryFn: ({ signal }) => api.controller.getPlaylistDetail({ query, server, signal }), + queryKey: queryKeys.playlists.detail(server?.id || '', query.id, query), + select: useCallback( + (data: RawPlaylistDetailResponse | undefined) => api.normalize.playlistDetail(data, server), + [server], + ), + ...options, + }); +}; diff --git a/src/renderer/features/playlists/queries/playlist-song-list-query.ts b/src/renderer/features/playlists/queries/playlist-song-list-query.ts new file mode 100644 index 00000000..19c96c18 --- /dev/null +++ b/src/renderer/features/playlists/queries/playlist-song-list-query.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import type { PlaylistSongListQuery, RawSongListResponse } from '/@/renderer/api/types'; +import type { QueryOptions } from '/@/renderer/lib/react-query'; +import { useCurrentServer } from '/@/renderer/store'; +import { api } from '/@/renderer/api'; + +export const usePlaylistSongList = (query: PlaylistSongListQuery, options?: QueryOptions) => { + const server = useCurrentServer(); + + return useQuery({ + enabled: !!server?.id, + queryFn: ({ signal }) => api.controller.getPlaylistSongList({ query, server, signal }), + queryKey: queryKeys.playlists.songList(server?.id || '', query.id, query), + select: useCallback( + (data: RawSongListResponse | undefined) => api.normalize.songList(data, server), + [server], + ), + ...options, + }); +}; diff --git a/src/renderer/features/playlists/routes/playlist-detail-route.tsx b/src/renderer/features/playlists/routes/playlist-detail-route.tsx new file mode 100644 index 00000000..0ec603f0 --- /dev/null +++ b/src/renderer/features/playlists/routes/playlist-detail-route.tsx @@ -0,0 +1,76 @@ +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { Group } from '@mantine/core'; +import { useIntersection } from '@mantine/hooks'; +import { useRef } from 'react'; +import { useParams } from 'react-router'; +import { PageHeader, ScrollArea, TextTitle } from '/@/renderer/components'; +import { PlaylistDetailContent } from '/@/renderer/features/playlists/components/playlist-detail-content'; +import { PlaylistDetailHeader } from '/@/renderer/features/playlists/components/playlist-detail-header'; +import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query'; +import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query'; +import { AnimatedPage, PlayButton } from '/@/renderer/features/shared'; +import { useFastAverageColor } from '/@/renderer/hooks'; + +const PlaylistDetailRoute = () => { + const tableRef = useRef(null); + const { playlistId } = useParams() as { playlistId: string }; + + const detailsQuery = usePlaylistDetail({ + id: playlistId, + }); + + const playlistSongsQuery = usePlaylistSongList({ + id: playlistId, + limit: 50, + startIndex: 0, + }); + + const imageUrl = playlistSongsQuery.data?.items?.[0]?.imageUrl; + const background = useFastAverageColor(imageUrl); + const containerRef = useRef(); + + const { ref, entry } = useIntersection({ + root: containerRef.current, + threshold: 0.3, + }); + + return ( + + + + + + {detailsQuery?.data?.name} + + + + + {background && ( + <> + + + + )} + + + ); +}; + +export default PlaylistDetailRoute; diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index 30370f6f..b5a23d07 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -22,6 +22,10 @@ const AlbumListRoute = lazy(() => import('/@/renderer/features/albums/routes/alb const SongListRoute = lazy(() => import('/@/renderer/features/songs/routes/song-list-route')); +const PlaylistDetailRoute = lazy( + () => import('/@/renderer/features/playlists/routes/playlist-detail-route'), +); + const PlaylistListRoute = lazy( () => import('/@/renderer/features/playlists/routes/playlist-list-route'), ); @@ -72,6 +76,10 @@ export const AppRouter = () => { element={} path={AppRoute.PLAYLISTS} /> + } + path={AppRoute.PLAYLISTS_DETAIL} + /> } path={AppRoute.LIBRARY_ALBUMARTISTS}