Add initial album artist detail route

This commit is contained in:
jeffvli 2023-01-12 18:43:25 -08:00
parent 55e2a9bf37
commit 9b8bcb05bd
21 changed files with 1000 additions and 27 deletions

View file

@ -37,6 +37,8 @@ import type {
UserListArgs,
RawUserListResponse,
FavoriteArgs,
TopSongListArgs,
RawTopSongListResponse,
} from '/@/renderer/api/types';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
@ -52,6 +54,7 @@ export type ControllerEndpoint = Partial<{
getAlbumDetail: (args: AlbumDetailArgs) => Promise<RawAlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<RawAlbumListResponse>;
getArtistDetail: () => void;
getArtistInfo: (args: any) => void;
getArtistList: (args: ArtistListArgs) => Promise<RawArtistListResponse>;
getFavoritesList: () => void;
getFolderItemList: () => void;
@ -64,6 +67,7 @@ export type ControllerEndpoint = Partial<{
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<RawSongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<RawSongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
getTopSongs: (args: TopSongListArgs) => Promise<RawTopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
@ -87,6 +91,7 @@ const endpoints: ApiController = {
getAlbumDetail: jellyfinApi.getAlbumDetail,
getAlbumList: jellyfinApi.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: jellyfinApi.getArtistList,
getFavoritesList: undefined,
getFolderItemList: undefined,
@ -99,6 +104,7 @@ const endpoints: ApiController = {
getPlaylistSongList: jellyfinApi.getPlaylistSongList,
getSongDetail: undefined,
getSongList: jellyfinApi.getSongList,
getTopSongs: undefined,
getUserList: undefined,
updatePlaylist: jellyfinApi.updatePlaylist,
updateRating: undefined,
@ -114,6 +120,7 @@ const endpoints: ApiController = {
getAlbumDetail: navidromeApi.getAlbumDetail,
getAlbumList: navidromeApi.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
@ -126,6 +133,7 @@ const endpoints: ApiController = {
getPlaylistSongList: navidromeApi.getPlaylistSongList,
getSongDetail: navidromeApi.getSongDetail,
getSongList: navidromeApi.getSongList,
getTopSongs: subsonicApi.getTopSongList,
getUserList: navidromeApi.getUserList,
updatePlaylist: navidromeApi.updatePlaylist,
updateRating: subsonicApi.updateRating,
@ -141,6 +149,7 @@ const endpoints: ApiController = {
getAlbumDetail: subsonicApi.getAlbumDetail,
getAlbumList: subsonicApi.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
@ -152,6 +161,7 @@ const endpoints: ApiController = {
getPlaylistList: undefined,
getSongDetail: undefined,
getSongList: undefined,
getTopSongs: subsonicApi.getTopSongList,
getUserList: undefined,
updatePlaylist: undefined,
updateRating: undefined,
@ -255,6 +265,10 @@ const updateRating = async (args: RatingArgs) => {
return (apiController('updateRating') as ControllerEndpoint['updateRating'])?.(args);
};
const getTopSongList = async (args: TopSongListArgs) => {
return (apiController('getTopSongs') as ControllerEndpoint['getTopSongs'])?.(args);
};
export const controller = {
createFavorite,
createPlaylist,
@ -271,6 +285,7 @@ export const controller = {
getPlaylistList,
getPlaylistSongList,
getSongList,
getTopSongList,
getUserList,
updatePlaylist,
updateRating,

View file

@ -140,7 +140,7 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<JFAlbu
const { query, server, signal } = args;
const searchParams = {
fields: 'Genres',
fields: 'Genres, Overview',
};
const data = await api
@ -152,7 +152,16 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<JFAlbu
})
.json<JFAlbumArtistDetailResponse>();
return data;
const similarArtists = await api
.get(`artists/${query.id}/similar`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams({ limit: 10 }),
signal,
})
.json<JFAlbumArtistListResponse>();
return { ...data, similarArtists: { items: similarArtists.Items } };
};
// const getAlbumArtistAlbums = () => {
@ -642,10 +651,14 @@ const normalizeSong = (
): Song => {
return {
album: item.Album,
albumArtists: item.AlbumArtists?.map((entry) => ({ id: entry.Id, name: entry.Name })),
albumArtists: item.AlbumArtists?.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})),
albumId: item.AlbumId,
artistName: item.ArtistItems[0]?.Name,
artists: item.ArtistItems.map((entry) => ({ id: entry.Id, name: entry.Name })),
artists: item.ArtistItems.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
bpm: null,
channels: null,
@ -691,9 +704,10 @@ const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: numbe
albumArtists:
item.AlbumArtists.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})) || [],
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
backdropImageUrl: null,
createdAt: item.DateCreated,
duration: item.RunTimeTicks / 10000,
@ -747,6 +761,17 @@ const normalizeAlbumArtist = (
playCount: item.UserData.PlayCount,
serverId: server.id,
serverType: ServerType.JELLYFIN,
similarArtists: item.similarArtists?.items
?.filter((entry) => entry.Name !== 'Various Artists')
.map((entry) => ({
id: entry.Id,
imageUrl: getAlbumArtistCoverArtUrl({
baseUrl: server.url,
item: entry,
size: imageSize || 300,
}),
name: entry.Name,
})),
songCount: null,
userFavorite: item.UserData.IsFavorite || false,
userRating: null,

View file

@ -173,6 +173,10 @@ export type JFAlbumArtist = {
PlaybackPositionTicks: number;
Played: boolean;
};
} & {
similarArtists: {
items: JFAlbumArtist[];
};
};
export type JFArtist = {

View file

@ -78,6 +78,7 @@ import { toast } from '/@/renderer/components/toast';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
import { parseSearchParams } from '/@/renderer/utils';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
const api = ky.create({
hooks: {
@ -183,6 +184,15 @@ const getGenreList = async (args: GenreListArgs): Promise<NDGenreList> => {
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<NDAlbumArtistDetail> => {
const { query, server, signal } = args;
const artistInfo = await subsonicApi.getArtistInfo({
query: {
artistId: query.id,
limit: 15,
},
server,
signal,
});
const data = await api
.get(`api/artist/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
@ -191,7 +201,7 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<NDAlbu
})
.json<NDAlbumArtistDetailResponse>();
return { ...data };
return { ...data, similarArtists: artistInfo.similarArtist };
};
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<NDAlbumArtistList> => {
@ -510,10 +520,10 @@ const normalizeSong = (
return {
album: item.album,
albumArtists: [{ id: item.artistId, name: item.artist }],
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
albumId: item.albumId,
artistName: item.artist,
artists: [{ id: item.artistId, name: item.artist }],
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
bitRate: item.bitRate,
bpm: item.bpm ? item.bpm : null,
channels: item.channels ? item.channels : null,
@ -559,8 +569,8 @@ const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: numbe
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
return {
albumArtists: [{ id: item.albumArtistId, name: item.albumArtist }],
artists: [{ id: item.artistId, name: item.artist }],
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
backdropImageUrl: imageBackdropUrl,
createdAt: item.createdAt.split('T')[0],
duration: item.duration * 1000 || null,
@ -602,6 +612,12 @@ const normalizeAlbumArtist = (item: NDAlbumArtist, server: ServerListItem): Albu
playCount: item.playCount,
serverId: server.id,
serverType: ServerType.NAVIDROME,
similarArtists:
item.similarArtists?.map((artist) => ({
id: artist.id,
imageUrl: artist?.artistImageUrl || null,
name: artist.name,
})) || null,
songCount: item.songCount,
userFavorite: item.starred,
userRating: item.rating,

View file

@ -1,3 +1,5 @@
import { SSArtistInfo } from '/@/renderer/api/subsonic.types';
export type NDAuthenticate = {
id: string;
isAdmin: boolean;
@ -126,6 +128,8 @@ export type NDAlbumArtist = {
songCount: number;
starred: boolean;
starredAt: string;
} & {
similarArtists?: SSArtistInfo['similarArtist'];
};
export type NDAuthenticationResponse = NDAuthenticate;

View file

@ -16,7 +16,8 @@ import type {
NDSong,
NDUser,
} from '/@/renderer/api/navidrome.types';
import { SSGenreList, SSMusicFolderList } from '/@/renderer/api/subsonic.types';
import { ssNormalize } from '/@/renderer/api/subsonic.api';
import { SSGenreList, SSMusicFolderList, SSSong } from '/@/renderer/api/subsonic.types';
import type {
Album,
AlbumArtist,
@ -29,6 +30,7 @@ import type {
RawPlaylistDetailResponse,
RawPlaylistListResponse,
RawSongListResponse,
RawTopSongListResponse,
RawUserListResponse,
} from '/@/renderer/api/types';
import { ServerListItem } from '/@/renderer/types';
@ -92,6 +94,25 @@ const songList = (data: RawSongListResponse | undefined, server: ServerListItem
};
};
const topSongList = (data: RawTopSongListResponse | undefined, server: ServerListItem | null) => {
let songs;
switch (server?.type) {
case 'jellyfin':
break;
case 'navidrome':
songs = data?.items?.map((item) => ssNormalize.song(item as SSSong, server, ''));
break;
case 'subsonic':
songs = data?.items?.map((item) => ssNormalize.song(item as SSSong, server, ''));
break;
}
return {
items: songs,
};
};
const musicFolderList = (
data: RawMusicFolderListResponse | undefined,
server: ServerListItem | null,
@ -265,5 +286,6 @@ export const normalize = {
playlistDetail,
playlistList,
songList,
topSongList,
userList,
};

View file

@ -9,6 +9,7 @@ import type {
PlaylistSongListQuery,
UserListQuery,
AlbumArtistDetailQuery,
TopSongListQuery,
} from './types';
export const queryKeys = {
@ -22,6 +23,10 @@ export const queryKeys = {
return [serverId, 'albumArtists', 'list'] as const;
},
root: (serverId: string) => [serverId, 'albumArtists'] as const,
topSongs: (serverId: string, query?: TopSongListQuery) => {
if (query) return [serverId, 'albumArtists', 'topSongs', query] as const;
return [serverId, 'albumArtists', 'topSongs'] as const;
},
},
albums: {
detail: (serverId: string, query?: AlbumDetailQuery) =>

View file

@ -19,12 +19,20 @@ import type {
SSRatingParams,
SSAlbumArtistDetailParams,
SSAlbumArtistListParams,
SSTopSongListParams,
SSTopSongListResponse,
SSArtistInfoParams,
SSArtistInfoResponse,
SSArtistInfo,
SSSong,
SSTopSongList,
} from '/@/renderer/api/subsonic.types';
import {
AlbumArtistDetailArgs,
AlbumArtistListArgs,
AlbumDetailArgs,
AlbumListArgs,
ArtistInfoArgs,
AuthenticationResponse,
FavoriteArgs,
FavoriteResponse,
@ -34,8 +42,12 @@ import {
RatingArgs,
RatingResponse,
ServerListItem,
ServerType,
Song,
TopSongListArgs,
} from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast';
import { nanoid } from 'nanoid/non-secure';
const getCoverArtUrl = (args: {
baseUrl: string;
@ -50,7 +62,7 @@ const getCoverArtUrl = (args: {
}
return (
`${args.baseUrl}/getCoverArt.view` +
`${args.baseUrl}/rest/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
@ -65,11 +77,14 @@ const api = ky.create({
async (_request, _options, response) => {
const data = await response.json();
if (data['subsonic-response'].status !== 'ok') {
// Suppress code related to non-linked lastfm or spotify from Navidrome
if (data['subsonic-response'].error.code !== 0) {
toast.error({
message: data['subsonic-response'].error.message,
title: 'Issue from Subsonic API',
});
}
}
return new Response(JSON.stringify(data['subsonic-response']), { status: 200 });
},
@ -325,6 +340,118 @@ const updateRating = async (args: RatingArgs): Promise<RatingResponse> => {
};
};
const getTopSongList = async (args: TopSongListArgs): Promise<SSTopSongList> => {
const { signal, server, query } = args;
const defaultParams = getDefaultParams(server);
const searchParams: SSTopSongListParams = {
artist: query.artist,
count: query.limit,
...defaultParams,
};
const data = await api
.get('rest/getTopSongs.view', {
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<SSTopSongListResponse>();
return {
items: data?.topSongs?.song,
startIndex: 0,
totalRecordCount: data?.topSongs?.song?.length || 0,
};
};
const getArtistInfo = async (args: ArtistInfoArgs): Promise<SSArtistInfo> => {
const { signal, server, query } = args;
const defaultParams = getDefaultParams(server);
const searchParams: SSArtistInfoParams = {
count: query.limit,
id: query.artistId,
...defaultParams,
};
const data = await api
.get('rest/getArtistInfo2.view', {
prefixUrl: server?.url,
searchParams,
signal,
})
.json<SSArtistInfoResponse>();
return data.artistInfo2;
};
const normalizeSong = (item: SSSong, server: ServerListItem, deviceId: string): Song => {
const imageUrl =
getCoverArtUrl({
baseUrl: server.url,
coverArtId: item.coverArt,
credential: server.credential,
size: 300,
}) || null;
const streamUrl = `${server.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`;
return {
album: item.album,
albumArtists: [
{
id: item.artistId || '',
imageUrl: null,
name: item.artist,
},
],
albumId: item.albumId,
artistName: item.artist,
artists: [
{
id: item.artistId || '',
imageUrl: null,
name: item.artist,
},
],
bitRate: item.bitRate,
bpm: null,
channels: null,
comment: null,
compilation: null,
container: item.contentType,
createdAt: item.created,
discNumber: item.discNumber || 1,
duration: item.duration,
genres: [
{
id: item.genre,
name: item.genre,
},
],
id: item.id,
imagePlaceholderUrl: null,
imageUrl,
itemType: LibraryItem.SONG,
lastPlayedAt: null,
name: item.title,
path: item.path,
playCount: item?.playCount || 0,
releaseDate: null,
releaseYear: item.year ? String(item.year) : null,
serverId: server.id,
serverType: ServerType.SUBSONIC,
size: item.size,
streamUrl,
trackNumber: item.track,
uniqueId: nanoid(),
updatedAt: '',
userFavorite: item.starred || false,
userRating: item.userRating || null,
};
};
export const subsonicApi = {
authenticate,
createFavorite,
@ -333,8 +460,14 @@ export const subsonicApi = {
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getArtistInfo,
getCoverArtUrl,
getGenreList,
getMusicFolderList,
getTopSongList,
updateRating,
};
export const ssNormalize = {
song: normalizeSong,
};

View file

@ -65,6 +65,12 @@ export type SSAlbumDetailResponse = {
album: SSAlbum;
};
export type SSArtistInfoParams = {
count?: number;
id: string;
includeNotPresent?: boolean;
};
export type SSArtistInfoResponse = {
artistInfo2: SSArtistInfo;
};
@ -75,6 +81,13 @@ export type SSArtistInfo = {
lastFmUrl?: string;
mediumImageUrl?: string;
musicBrainzId?: string;
similarArtist?: {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
}[];
smallImageUrl?: string;
};
@ -186,3 +199,20 @@ export type SSRatingParams = {
export type SSRating = null;
export type SSRatingResponse = null;
export type SSTopSongListParams = {
artist: string;
count?: number;
};
export type SSTopSongListResponse = {
topSongs: {
song: SSSong[];
};
};
export type SSTopSongList = {
items: SSSong[];
startIndex: number;
totalRecordCount: number | null;
};

View file

@ -43,6 +43,7 @@ import {
SSAlbumArtistDetail,
SSMusicFolderList,
SSGenreList,
SSTopSongList,
} from '/@/renderer/api/subsonic.types';
export enum LibraryItem {
@ -231,6 +232,7 @@ export type AlbumArtist = {
playCount: number | null;
serverId: string;
serverType: ServerType;
similarArtists: RelatedArtist[] | null;
songCount: number | null;
userFavorite: boolean;
userRating: number | null;
@ -256,6 +258,7 @@ export type Artist = {
export type RelatedArtist = {
id: string;
imageUrl: string | null;
name: string;
};
@ -959,3 +962,24 @@ export const userListSortMap: UserListSortMap = {
name: undefined,
},
};
// Top Songs List
export type RawTopSongListResponse = SSTopSongList | undefined;
export type TopSongListResponse = BasePaginatedResponse<Song[]>;
export type TopSongListQuery = {
artist: string;
limit?: number;
};
export type TopSongListArgs = { query: TopSongListQuery } & BaseEndpointArgs;
// Artist Info
export type ArtistInfoQuery = {
artistId: string;
limit: number;
musicFolderId?: string;
};
export type ArtistInfoArgs = { query: ArtistInfoQuery } & BaseEndpointArgs;

View file

@ -0,0 +1,411 @@
import { useMemo } from 'react';
import {
Button,
DropdownMenu,
getColumnDefs,
GridCarousel,
Text,
TextTitle,
VirtualTable,
} from '/@/renderer/components';
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
import { Box, Group, Stack } from '@mantine/core';
import { RiArrowDownSLine, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { generatePath, useParams } from 'react-router';
import { useCurrentServer } from '/@/renderer/store';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { AppRoute } from '/@/renderer/router/routes';
import { useContainerQuery } from '/@/renderer/hooks';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { Play, TableColumn } from '/@/renderer/types';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import {
PlayButton,
PLAY_TYPES,
useCreateFavorite,
useDeleteFavorite,
} from '/@/renderer/features/shared';
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
import {
AlbumListSort,
LibraryItem,
QueueSong,
ServerType,
SortOrder,
} from '/@/renderer/api/types';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
import { useTopSongsList } from '/@/renderer/features/artists/queries/top-songs-list-query';
const ContentContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
gap: 3rem;
max-width: 1920px;
padding: 1rem 2rem 5rem;
overflow: hidden;
.ag-theme-alpine-dark {
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
}
.ag-header {
margin-bottom: 0.5rem;
}
`;
export const AlbumArtistDetailContent = () => {
const { albumArtistId } = useParams() as { albumArtistId: string };
const cq = useContainerQuery();
const handlePlayQueueAdd = usePlayQueueAdd();
const server = useCurrentServer();
const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
const recentAlbumsQuery = useAlbumList({
jfParams: server?.type === ServerType.JELLYFIN ? { artistIds: albumArtistId } : undefined,
limit: itemsPerPage,
ndParams:
server?.type === ServerType.NAVIDROME
? { artist_id: albumArtistId, compilation: false }
: undefined,
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
startIndex: 0,
});
const topSongsQuery = useTopSongsList(
{ artist: detailQuery?.data?.name || '' },
{ enabled: server?.type !== ServerType.JELLYFIN && !!detailQuery?.data?.name },
);
const topSongsColumnDefs: ColDef[] = useMemo(
() =>
getColumnDefs([
{ column: TableColumn.ROW_INDEX, width: 0 },
{ column: TableColumn.TITLE_COMBINED, width: 0 },
{ column: TableColumn.DURATION, width: 0 },
{ column: TableColumn.ALBUM, width: 0 },
{ column: TableColumn.YEAR, width: 0 },
{ column: TableColumn.PLAY_COUNT, width: 0 },
{ column: TableColumn.USER_FAVORITE, width: 0 },
]),
[],
);
const cardRows = {
album: [
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
],
albumArtist: [
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
],
};
const carousels = [
{
data: recentAlbumsQuery?.data?.items,
itemType: LibraryItem.ALBUM,
loading: recentAlbumsQuery?.isLoading || recentAlbumsQuery.isFetching,
pagination: {
itemsPerPage,
},
title: (
<>
<TextTitle
fw="bold"
order={3}
>
Recent albums
</TextTitle>
<Button
compact
uppercase
component={Link}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY, {
albumArtistId,
})}
variant="subtle"
>
View discography
</Button>
</>
),
uniqueId: 'recentAlbums',
},
{
data: detailQuery?.data?.similarArtists?.slice(0, itemsPerPage),
isHidden: !detailQuery?.data?.similarArtists,
itemType: LibraryItem.ALBUM_ARTIST,
loading: detailQuery?.isLoading || detailQuery.isFetching,
pagination: {
itemsPerPage,
},
title: (
<TextTitle
fw="bold"
order={3}
>
Related artists
</TextTitle>
),
uniqueId: 'similarArtists',
},
];
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = async (playType?: Play) => {
handlePlayQueueAdd?.({
byItemType: {
id: [albumArtistId],
type: LibraryItem.ALBUM_ARTIST,
},
play: playType || playButtonBehavior,
});
};
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
handlePlayQueueAdd?.({
byData: [e.data],
play: playButtonBehavior,
});
};
const createFavoriteMutation = useCreateFavorite();
const deleteFavoriteMutation = useDeleteFavorite();
const handleFavorite = () => {
if (!detailQuery?.data) return;
if (detailQuery.data.userFavorite) {
deleteFavoriteMutation.mutate({
query: {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM_ARTIST,
},
});
} else {
createFavoriteMutation.mutate({
query: {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM_ARTIST,
},
});
}
};
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
const showBiography =
detailQuery?.data?.biography !== undefined && detailQuery?.data?.biography !== null;
const showTopSongs = server?.type !== ServerType.JELLYFIN && topSongsQuery?.data?.items?.length;
const showGenres = detailQuery?.data?.genres?.length !== 0;
const isLoading =
detailQuery?.isLoading ||
recentAlbumsQuery?.isLoading ||
(server?.type === ServerType.NAVIDROME && topSongsQuery?.isLoading);
if (isLoading) return <ContentContainer ref={cq.ref} />;
return (
<ContentContainer ref={cq.ref}>
<Box component="section">
<Group spacing="lg">
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group spacing="xs">
<Button
compact
loading={createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading}
variant="subtle"
onClick={handleFavorite}
>
{detailQuery?.data?.userFavorite ? (
<RiHeartFill
color="red"
size={20}
/>
) : (
<RiHeartLine size={20} />
)}
</Button>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
variant="subtle"
>
<RiMoreFill size={20} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
<DropdownMenu.Item
key={`playtype-${type.play}`}
onClick={() => handlePlay(type.play)}
>
{type.label}
</DropdownMenu.Item>
))}
<DropdownMenu.Divider />
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</Group>
</Box>
{showGenres && (
<Box component="section">
<Group>
{detailQuery?.data?.genres?.map((genre) => (
<Button
key={`genre-${genre.id}`}
compact
component={Link}
radius="md"
size="sm"
to={generatePath(`${AppRoute.LIBRARY_ALBUM_ARTISTS}?genre=${genre.id}`, {
albumArtistId,
})}
variant="default"
>
{genre.name}
</Button>
))}
</Group>
</Box>
)}
{showBiography ? (
<Box
component="section"
maw="1280px"
>
<TextTitle
fw="bold"
order={3}
>
About {detailQuery?.data?.name}
</TextTitle>
<Text
$secondary
component="p"
dangerouslySetInnerHTML={{ __html: detailQuery?.data?.biography || '' }}
sx={{ textAlign: 'justify' }}
/>
</Box>
) : null}
{showTopSongs && (
<Box component="section">
<Group
noWrap
position="apart"
>
<Group
noWrap
align="flex-end"
>
<TextTitle
fw="bold"
order={3}
>
Top Songs
</TextTitle>
<Button
compact
uppercase
component={Link}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS, {
albumArtistId,
})}
variant="subtle"
>
View all
</Button>
</Group>
<DropdownMenu>
<DropdownMenu.Target>
<Button
compact
uppercase
rightIcon={<RiArrowDownSLine size={20} />}
variant="subtle"
>
Community
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item>Community</DropdownMenu.Item>
<DropdownMenu.Item>User</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<VirtualTable
autoFitColumns
autoHeight
deselectOnClickOutside
suppressCellFocus
suppressHorizontalScroll
suppressLoadingOverlay
suppressRowDrag
columnDefs={topSongsColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.uniqueId}
rowData={topSongs}
rowHeight={60}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onRowDoubleClicked={handleRowDoubleClick}
/>
</Box>
)}
<Box component="section">
<Stack spacing="xl">
{carousels
.filter((c) => !c.isHidden)
.map((carousel) => (
<GridCarousel
key={`carousel-${carousel.uniqueId}`}
cardRows={cardRows[carousel.itemType as keyof typeof cardRows]}
containerWidth={cq.width}
data={carousel.data}
itemType={carousel.itemType}
loading={carousel.loading}
pagination={carousel.pagination}
uniqueId={carousel.uniqueId}
>
<GridCarousel.Title>{carousel.title}</GridCarousel.Title>
</GridCarousel>
))}
</Stack>
</Box>
</ContentContainer>
);
};

View file

@ -0,0 +1,75 @@
import { Group, Stack } from '@mantine/core';
import { forwardRef, Fragment, Ref } from 'react';
import { useParams } from 'react-router';
import { LibraryItem } from '/@/renderer/api/types';
import { Text } from '/@/renderer/components';
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
import { LibraryHeader } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { formatDurationString } from '/@/renderer/utils';
interface AlbumArtistDetailHeaderProps {
background: string;
}
export const AlbumArtistDetailHeader = forwardRef(
({ background }: AlbumArtistDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
const { albumArtistId } = useParams() as { albumArtistId: string };
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
const cq = useContainerQuery();
const metadataItems = [
{
id: 'albumCount',
secondary: false,
value: detailQuery?.data?.albumCount && `${detailQuery?.data?.albumCount} albums`,
},
{
id: 'songCount',
secondary: false,
value: detailQuery?.data?.songCount && `${detailQuery?.data?.songCount} songs`,
},
{
id: 'duration',
secondary: true,
value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
},
];
console.log('detailQuery?.data', detailQuery?.data);
return (
<Stack ref={cq.ref}>
<LibraryHeader
ref={ref}
background={background}
imageUrl={detailQuery?.data?.imageUrl}
item={{ route: AppRoute.LIBRARY_ALBUM_ARTISTS, type: LibraryItem.ALBUM_ARTIST }}
title={detailQuery?.data?.name || ''}
>
<Stack mt="1rem">
<Group>
{metadataItems
.filter((i) => i.value)
.map((item, index) => (
<Fragment key={`item-${item.id}-${index}`}>
{index > 0 && <Text $noSelect></Text>}
<Text $secondary={item.secondary}>{item.value}</Text>
</Fragment>
))}
</Group>
<Group
sx={{
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
display: '-webkit-box',
overflow: 'hidden',
}}
/>
</Stack>
</LibraryHeader>
</Stack>
);
},
);

View file

@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { AlbumArtistDetailQuery, RawAlbumArtistDetailResponse } from '/@/renderer/api/types';
import type { QueryOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import { api } from '/@/renderer/api';
export const useAlbumArtistDetail = (query: AlbumArtistDetailQuery, options?: QueryOptions) => {
const server = useCurrentServer();
return useQuery({
enabled: !!server?.id && !!query.id,
queryFn: ({ signal }) => api.controller.getAlbumArtistDetail({ query, server, signal }),
queryKey: queryKeys.albumArtists.detail(server?.id || '', query),
select: useCallback(
(data: RawAlbumArtistDetailResponse | undefined) =>
api.normalize.albumArtistDetail(data, server),
[server],
),
...options,
});
};

View file

@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { AlbumArtistDetailQuery, RawAlbumArtistDetailResponse } from '/@/renderer/api/types';
import type { QueryOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import { api } from '/@/renderer/api';
export const useAlbumArtistInfo = (query: AlbumArtistDetailQuery, options?: QueryOptions) => {
const server = useCurrentServer();
return useQuery({
enabled: !!server?.id && !!query.id,
queryFn: ({ signal }) => api.controller.getAlbumArtistDetail({ query, server, signal }),
queryKey: queryKeys.albumArtists.detail(server?.id || '', query),
select: useCallback(
(data: RawAlbumArtistDetailResponse | undefined) =>
api.normalize.albumArtistDetail(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 { RawTopSongListResponse, TopSongListQuery } from '/@/renderer/api/types';
import type { QueryOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import { api } from '/@/renderer/api';
export const useTopSongsList = (query: TopSongListQuery, options?: QueryOptions) => {
const server = useCurrentServer();
return useQuery({
enabled: !!server?.id,
queryFn: ({ signal }) => api.controller.getTopSongList({ query, server, signal }),
queryKey: queryKeys.albumArtists.topSongs(server?.id || '', query),
select: useCallback(
(data: RawTopSongListResponse | undefined) => api.normalize.topSongList(data, server),
[server],
),
...options,
});
};

View file

@ -0,0 +1,60 @@
import { NativeScrollArea } from '/@/renderer/components';
import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useRef } from 'react';
import { useParams } from 'react-router';
import { useFastAverageColor } from '/@/renderer/hooks';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { LibraryItem } from '/@/renderer/api/types';
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
import { AlbumArtistDetailHeader } from '/@/renderer/features/artists/components/album-artist-detail-header';
import { AlbumArtistDetailContent } from '/@/renderer/features/artists/components/album-artist-detail-content';
const AlbumArtistDetailRoute = () => {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const { albumArtistId } = useParams() as { albumArtistId: string };
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
const background = useFastAverageColor(detailQuery.data?.imageUrl, !detailQuery.isLoading);
const handlePlay = () => {
handlePlayQueueAdd?.({
byItemType: {
id: [albumArtistId],
type: LibraryItem.ALBUM_ARTIST,
},
play: playButtonBehavior,
});
};
if (detailQuery.isLoading || !background) return null;
return (
<AnimatedPage key={`album-artist-detail-${albumArtistId}`}>
<NativeScrollArea
ref={scrollAreaRef}
pageHeaderProps={{
backgroundColor: background,
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
target: headerRef,
}}
>
<AlbumArtistDetailHeader
ref={headerRef}
background={background}
/>
<AlbumArtistDetailContent />
</NativeScrollArea>
</AnimatedPage>
);
};
export default AlbumArtistDetailRoute;

View file

@ -1,10 +1,10 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { HTTPError } from 'ky';
import { api } from '/@/renderer/api';
import { JFAlbumDetail } from '/@/renderer/api/jellyfin.types';
import { NDAlbumDetail } from '/@/renderer/api/navidrome.types';
import { JFAlbumArtistDetail, JFAlbumDetail } from '/@/renderer/api/jellyfin.types';
import { NDAlbumArtistDetail, NDAlbumDetail } from '/@/renderer/api/navidrome.types';
import { queryKeys } from '/@/renderer/api/query-keys';
import { SSAlbumDetail } from '/@/renderer/api/subsonic.types';
import { SSAlbumArtistDetail, SSAlbumDetail } from '/@/renderer/api/subsonic.types';
import { FavoriteArgs, LibraryItem, RawFavoriteResponse, ServerType } from '/@/renderer/api/types';
import { MutationOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer, useSetAlbumListItemDataById } from '/@/renderer/store';
@ -55,6 +55,40 @@ export const useCreateFavorite = (options?: MutationOptions) => {
}
}
}
// We only need to set if we're already on the album detail page
if (variables.query.type === LibraryItem.ALBUM_ARTIST && variables.query.id.length === 1) {
const queryKey = queryKeys.albumArtists.detail(server?.id || '', {
id: variables.query.id[0],
});
const previous = queryClient.getQueryData<any>(queryKey);
if (previous) {
switch (server?.type) {
case ServerType.NAVIDROME:
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
...previous,
starred: true,
});
break;
case ServerType.SUBSONIC:
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
...previous,
starred: true,
});
break;
case ServerType.JELLYFIN:
queryClient.setQueryData<JFAlbumArtistDetail>(queryKey, {
...previous,
UserData: {
...previous.UserData,
IsFavorite: true,
},
});
break;
}
}
}
},
...options,

View file

@ -1,10 +1,10 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { HTTPError } from 'ky';
import { api } from '/@/renderer/api';
import { JFAlbumDetail } from '/@/renderer/api/jellyfin.types';
import { NDAlbumDetail } from '/@/renderer/api/navidrome.types';
import { JFAlbumArtistDetail, JFAlbumDetail } from '/@/renderer/api/jellyfin.types';
import { NDAlbumArtistDetail, NDAlbumDetail } from '/@/renderer/api/navidrome.types';
import { queryKeys } from '/@/renderer/api/query-keys';
import { SSAlbumDetail } from '/@/renderer/api/subsonic.types';
import { SSAlbumArtistDetail, SSAlbumDetail } from '/@/renderer/api/subsonic.types';
import { FavoriteArgs, LibraryItem, RawFavoriteResponse, ServerType } from '/@/renderer/api/types';
import { MutationOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer, useSetAlbumListItemDataById } from '/@/renderer/store';
@ -55,6 +55,40 @@ export const useDeleteFavorite = (options?: MutationOptions) => {
}
}
}
// We only need to set if we're already on the album detail page
if (variables.query.type === LibraryItem.ALBUM_ARTIST && variables.query.id.length === 1) {
const queryKey = queryKeys.albumArtists.detail(server?.id || '', {
id: variables.query.id[0],
});
const previous = queryClient.getQueryData<any>(queryKey);
if (previous) {
switch (server?.type) {
case ServerType.NAVIDROME:
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
...previous,
starred: false,
});
break;
case ServerType.SUBSONIC:
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
...previous,
starred: false,
});
break;
case ServerType.JELLYFIN:
queryClient.setQueryData<JFAlbumArtistDetail>(queryKey, {
...previous,
UserData: {
...previous.UserData,
IsFavorite: false,
},
});
break;
}
}
}
},
...options,
});

View file

@ -234,9 +234,9 @@ export const Sidebar = () => {
Tracks
</Group>
</SidebarItem>
<SidebarItem to={AppRoute.LIBRARY_ALBUMARTISTS}>
<SidebarItem to={AppRoute.LIBRARY_ALBUM_ARTISTS}>
<Group>
{location.pathname === AppRoute.LIBRARY_ALBUMARTISTS ? (
{location.pathname === AppRoute.LIBRARY_ALBUM_ARTISTS ? (
<RiUserVoiceFill size="1.1em" />
) : (
<RiUserVoiceLine size="1.1em" />

View file

@ -0,0 +1 @@
export * from './queries/song-list-query';

View file

@ -44,6 +44,10 @@ const AlbumArtistListRoute = lazy(
() => import('/@/renderer/features/artists/routes/album-artist-list-route'),
);
const AlbumArtistDetailRoute = lazy(
() => import('/@/renderer/features/artists/routes/album-artist-detail-route'),
);
const AlbumDetailRoute = lazy(
() => import('/@/renderer/features/albums/routes/album-detail-route'),
);
@ -108,10 +112,18 @@ export const AppRouter = () => {
path={AppRoute.PLAYLISTS_DETAIL_SONGS}
/>
<Route
element={<AlbumArtistListRoute />}
errorElement={<RouteErrorBoundary />}
path={AppRoute.LIBRARY_ALBUMARTISTS}
path={AppRoute.LIBRARY_ALBUM_ARTISTS}
>
<Route
index
element={<AlbumArtistListRoute />}
/>
<Route
element={<AlbumArtistDetailRoute />}
path={AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL}
/>
</Route>
<Route
element={<InvalidRoute />}
path="*"