Add initial playlist detail page

This commit is contained in:
jeffvli 2022-12-31 18:03:26 -08:00
parent 11be5c811f
commit 0f364f7c5c
12 changed files with 604 additions and 24 deletions

View file

@ -213,6 +213,16 @@ const updatePlaylist = async (args: UpdatePlaylistArgs) => {
return (apiController('updatePlaylist') as ControllerEndpoint['updatePlaylist'])?.(args); 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 = { export const controller = {
createPlaylist, createPlaylist,
getAlbumArtistList, getAlbumArtistList,
@ -221,7 +231,9 @@ export const controller = {
getArtistList, getArtistList,
getGenreList, getGenreList,
getMusicFolderList, getMusicFolderList,
getPlaylistDetail,
getPlaylistList, getPlaylistList,
getPlaylistSongList,
getSongList, getSongList,
updatePlaylist, updatePlaylist,
}; };

View file

@ -313,19 +313,16 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistR
}; };
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => { const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
const { query, server, signal } = args; const { query, body, server, signal } = args;
const previous = query.previous as NDPlaylist;
const json: NDUpdatePlaylistParams = { const json: NDUpdatePlaylistParams = {
...previous, comment: body.comment || '',
comment: query.comment || '', name: body.name,
name: query.name, public: body.public || false,
public: query.public || false,
}; };
const data = await api const data = await api
.post(`api/playlist/${previous.id}`, { .post(`api/playlist/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` }, headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
json, json,
prefixUrl: server?.url, prefixUrl: server?.url,
@ -335,7 +332,6 @@ const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistR
return { return {
id: data.id, id: data.id,
name: query.name,
}; };
}; };
@ -406,19 +402,20 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<NDSongLi
playlist_id: query.id, playlist_id: query.id,
}; };
const data = await api const res = await api.get(`api/playlist/${query.id}/tracks`, {
.get(`api/playlist/${query.id}/tracks`, { headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` }, prefixUrl: server?.url,
prefixUrl: server?.url, searchParams: parseSearchParams(searchParams),
searchParams: parseSearchParams(searchParams), signal,
signal, });
})
.json<NDSongListResponse>(); const data = await res.json<NDSongListResponse>();
const itemCount = res.headers.get('x-total-count');
return { return {
items: data, items: data,
startIndex: query?.startIndex || 0, startIndex: query?.startIndex || 0,
totalRecordCount: data.length, totalRecordCount: Number(itemCount),
}; };
}; };

View file

@ -269,7 +269,7 @@ export type NDCreatePlaylistResponse = {
export type NDCreatePlaylist = NDCreatePlaylistResponse; export type NDCreatePlaylist = NDCreatePlaylistResponse;
export type NDUpdatePlaylistParams = NDPlaylist; export type NDUpdatePlaylistParams = Partial<NDPlaylist>;
export type NDUpdatePlaylistResponse = NDPlaylist; export type NDUpdatePlaylistResponse = NDPlaylist;

View file

@ -23,6 +23,7 @@ import type {
RawAlbumListResponse, RawAlbumListResponse,
RawGenreListResponse, RawGenreListResponse,
RawMusicFolderListResponse, RawMusicFolderListResponse,
RawPlaylistDetailResponse,
RawPlaylistListResponse, RawPlaylistListResponse,
RawSongListResponse, RawSongListResponse,
} from '/@/renderer/api/types'; } 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 = { export const normalize = {
albumArtistList, albumArtistList,
albumDetail, albumDetail,
albumList, albumList,
genreList, genreList,
musicFolderList, musicFolderList,
playlistDetail,
playlistList, playlistList,
songList, songList,
}; };

View file

@ -6,6 +6,7 @@ import type {
ArtistListQuery, ArtistListQuery,
PlaylistListQuery, PlaylistListQuery,
PlaylistDetailQuery, PlaylistDetailQuery,
PlaylistSongListQuery,
} from './types'; } from './types';
export const queryKeys = { export const queryKeys = {
@ -48,8 +49,8 @@ export const queryKeys = {
}, },
playlists: { playlists: {
detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => { detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => {
if (query) return [serverId, 'playlists', 'detail', id, query] as const; if (query) return [serverId, 'playlists', id, 'detail', query] as const;
if (id) return [serverId, 'playlists', 'detail', id] as const; if (id) return [serverId, 'playlists', id, 'detail'] as const;
return [serverId, 'playlists', 'detail'] as const; return [serverId, 'playlists', 'detail'] as const;
}, },
list: (serverId: string, query?: PlaylistListQuery) => { list: (serverId: string, query?: PlaylistListQuery) => {
@ -57,6 +58,11 @@ export const queryKeys = {
return [serverId, 'playlists', 'list'] as const; return [serverId, 'playlists', 'list'] as const;
}, },
root: (serverId: string) => [serverId, 'playlists'] 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: { server: {
root: (serverId: string) => [serverId] as const, root: (serverId: string) => [serverId] as const,

View file

@ -742,17 +742,23 @@ export type CreatePlaylistArgs = { query: CreatePlaylistQuery } & BaseEndpointAr
// Update Playlist // Update Playlist
export type RawUpdatePlaylistResponse = UpdatePlaylistResponse | undefined; export type RawUpdatePlaylistResponse = UpdatePlaylistResponse | undefined;
export type UpdatePlaylistResponse = { id: string; name: string }; export type UpdatePlaylistResponse = { id: string };
export type UpdatePlaylistQuery = { export type UpdatePlaylistQuery = {
id: string;
};
export type UpdatePlaylistBody = {
comment?: string; comment?: string;
name: string; name: string;
previous: RawPlaylistDetailResponse;
public?: boolean; public?: boolean;
rules?: Record<string, any>; rules?: Record<string, any>;
}; };
export type UpdatePlaylistArgs = { query: UpdatePlaylistQuery } & BaseEndpointArgs; export type UpdatePlaylistArgs = {
body: UpdatePlaylistBody;
query: UpdatePlaylistQuery;
} & BaseEndpointArgs;
// Delete Playlist // Delete Playlist
export type RawDeletePlaylistResponse = NDDeletePlaylist | undefined; export type RawDeletePlaylistResponse = NDDeletePlaylist | undefined;

View file

@ -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<AgGridReactType | null>;
}
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<QueueSong>) => {
if (!e.data) return;
handlePlayQueueAdd({
byData: [e.data],
play: playButtonBehavior,
});
};
return (
<ContentContainer>
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
ref={tableRef}
animateRows
detailRowAutoHeight
maintainColumnOrder
suppressCopyRowsToClipboard
suppressMoveWhenRowDragging
suppressPaginationPanel
suppressRowDrag
suppressScrollOnNewData
columnDefs={columnDefs}
defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false}
rowData={playlistSongsQuery.data?.items}
rowHeight={page.table.rowHeight || 60}
rowSelection="multiple"
onBodyScrollEnd={handleScroll}
onCellContextMenu={handleContextMenu}
onColumnMoved={handleColumnChange}
onColumnResized={debouncedColumnChange}
onGridReady={(params) => {
params.api.setDomLayout('autoHeight');
params.api.sizeColumnsToFit();
}}
onGridSizeChanged={handleGridSizeChange}
onRowDoubleClicked={handleRowDoubleClick}
/>
{/* <AnimatePresence
presenceAffectsLayout
initial={false}
mode="wait"
>
{page.display === ListDisplayType.TABLE_PAGINATED && (
<TablePagination
pagination={pagination}
setPagination={setPagination}
tableRef={tableRef}
/>
)}
</AnimatePresence> */}
</ContentContainer>
);
};

View file

@ -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 (
<>
<HeaderContainer ref={mergedRef}>
<BackgroundImage background={background} />
<BackgroundImageOverlay />
<CoverImageWrapper>
{imageUrl ? (
<StyledImage
alt="cover"
height={225}
src={imageUrl}
width={225}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: `${225}px`,
width: `${225}px`,
}}
>
<RiAlbumFill
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
</CoverImageWrapper>
<MetadataWrapper>
<Group>
<Text
$link
component={Link}
fw="600"
sx={{ textTransform: 'uppercase' }}
to={AppRoute.LIBRARY_ALBUMS}
>
Playlist
</Text>
</Group>
<TextTitle
fw="900"
lh="1"
mb="0.12em"
mt=".08em"
sx={{ fontSize: titleSize }}
>
{detailQuery?.data?.name}
</TextTitle>
<Group
py="1rem"
spacing="xs"
>
<PlayButton />
</Group>
</MetadataWrapper>
</HeaderContainer>
</>
);
},
);

View file

@ -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,
});
};

View file

@ -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,
});
};

View file

@ -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<AgGridReactType | null>(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 (
<AnimatedPage key={`playlist-detail-${playlistId}`}>
<PageHeader
backgroundColor={background}
isHidden={entry?.isIntersecting}
position="absolute"
>
<Group noWrap>
<PlayButton />
<TextTitle
fw="bold"
order={2}
overflow="hidden"
>
{detailsQuery?.data?.name}
</TextTitle>
</Group>
</PageHeader>
<ScrollArea
ref={containerRef}
h="100%"
offsetScrollbars={false}
styles={{ scrollbar: { marginTop: '35px' } }}
>
{background && (
<>
<PlaylistDetailHeader
ref={ref}
background={background}
imageUrl={imageUrl || undefined}
/>
<PlaylistDetailContent tableRef={tableRef} />
</>
)}
</ScrollArea>
</AnimatedPage>
);
};
export default PlaylistDetailRoute;

View file

@ -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 SongListRoute = lazy(() => import('/@/renderer/features/songs/routes/song-list-route'));
const PlaylistDetailRoute = lazy(
() => import('/@/renderer/features/playlists/routes/playlist-detail-route'),
);
const PlaylistListRoute = lazy( const PlaylistListRoute = lazy(
() => import('/@/renderer/features/playlists/routes/playlist-list-route'), () => import('/@/renderer/features/playlists/routes/playlist-list-route'),
); );
@ -72,6 +76,10 @@ export const AppRouter = () => {
element={<PlaylistListRoute />} element={<PlaylistListRoute />}
path={AppRoute.PLAYLISTS} path={AppRoute.PLAYLISTS}
/> />
<Route
element={<PlaylistDetailRoute />}
path={AppRoute.PLAYLISTS_DETAIL}
/>
<Route <Route
element={<AlbumArtistListRoute />} element={<AlbumArtistListRoute />}
path={AppRoute.LIBRARY_ALBUMARTISTS} path={AppRoute.LIBRARY_ALBUMARTISTS}