diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 1c0ebfca..4c3602f4 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -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; getAlbumList: (args: AlbumListArgs) => Promise; getArtistDetail: () => void; + getArtistInfo: (args: any) => void; getArtistList: (args: ArtistListArgs) => Promise; getFavoritesList: () => void; getFolderItemList: () => void; @@ -64,6 +67,7 @@ export type ControllerEndpoint = Partial<{ getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; getSongDetail: (args: SongDetailArgs) => Promise; getSongList: (args: SongListArgs) => Promise; + getTopSongs: (args: TopSongListArgs) => Promise; getUserList: (args: UserListArgs) => Promise; updatePlaylist: (args: UpdatePlaylistArgs) => Promise; updateRating: (args: RatingArgs) => Promise; @@ -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, diff --git a/src/renderer/api/jellyfin.api.ts b/src/renderer/api/jellyfin.api.ts index efc01f34..34ebae14 100644 --- a/src/renderer/api/jellyfin.api.ts +++ b/src/renderer/api/jellyfin.api.ts @@ -140,7 +140,7 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise(); - 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(); + + 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, diff --git a/src/renderer/api/jellyfin.types.ts b/src/renderer/api/jellyfin.types.ts index 629f1459..762e7a51 100644 --- a/src/renderer/api/jellyfin.types.ts +++ b/src/renderer/api/jellyfin.types.ts @@ -173,6 +173,10 @@ export type JFAlbumArtist = { PlaybackPositionTicks: number; Played: boolean; }; +} & { + similarArtists: { + items: JFAlbumArtist[]; + }; }; export type JFArtist = { diff --git a/src/renderer/api/navidrome.api.ts b/src/renderer/api/navidrome.api.ts index 3dbb624f..ef80653f 100644 --- a/src/renderer/api/navidrome.api.ts +++ b/src/renderer/api/navidrome.api.ts @@ -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 => { const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise => { 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(); - return { ...data }; + return { ...data, similarArtists: artistInfo.similarArtist }; }; const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { @@ -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, diff --git a/src/renderer/api/navidrome.types.ts b/src/renderer/api/navidrome.types.ts index 60811af4..442331cb 100644 --- a/src/renderer/api/navidrome.types.ts +++ b/src/renderer/api/navidrome.types.ts @@ -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; diff --git a/src/renderer/api/normalize.ts b/src/renderer/api/normalize.ts index bdee695d..38528a47 100644 --- a/src/renderer/api/normalize.ts +++ b/src/renderer/api/normalize.ts @@ -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, }; diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index c79920bc..8c7765b8 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -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) => diff --git a/src/renderer/api/subsonic.api.ts b/src/renderer/api/subsonic.api.ts index 4acc0901..c7c69d8f 100644 --- a/src/renderer/api/subsonic.api.ts +++ b/src/renderer/api/subsonic.api.ts @@ -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,10 +77,13 @@ const api = ky.create({ async (_request, _options, response) => { const data = await response.json(); if (data['subsonic-response'].status !== 'ok') { - toast.error({ - message: data['subsonic-response'].error.message, - title: 'Issue from Subsonic API', - }); + // 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 => { }; }; +const getTopSongList = async (args: TopSongListArgs): Promise => { + 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(); + + return { + items: data?.topSongs?.song, + startIndex: 0, + totalRecordCount: data?.topSongs?.song?.length || 0, + }; +}; + +const getArtistInfo = async (args: ArtistInfoArgs): Promise => { + 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(); + + 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, +}; diff --git a/src/renderer/api/subsonic.types.ts b/src/renderer/api/subsonic.types.ts index 867cee19..322ade49 100644 --- a/src/renderer/api/subsonic.types.ts +++ b/src/renderer/api/subsonic.types.ts @@ -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; +}; diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 945a055f..e277f0e0 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -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; + +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; diff --git a/src/renderer/features/artists/components/album-artist-detail-content.tsx b/src/renderer/features/artists/components/album-artist-detail-content.tsx new file mode 100644 index 00000000..c9c313ed --- /dev/null +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -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: ( + <> + + Recent albums + + + + ), + 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: ( + + Related artists + + ), + 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) => { + 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 ; + + return ( + + + + handlePlay(playButtonBehavior)} /> + + + + + + + + {PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => ( + handlePlay(type.play)} + > + {type.label} + + ))} + + Add to playlist + + + + + + {showGenres && ( + + + {detailQuery?.data?.genres?.map((genre) => ( + + ))} + + + )} + {showBiography ? ( + + + About {detailQuery?.data?.name} + + + + ) : null} + {showTopSongs && ( + + + + + Top Songs + + + + + + + + + Community + User + + + + data.data.uniqueId} + rowData={topSongs} + rowHeight={60} + rowSelection="multiple" + onCellContextMenu={handleContextMenu} + onRowDoubleClicked={handleRowDoubleClick} + /> + + )} + + + {carousels + .filter((c) => !c.isHidden) + .map((carousel) => ( + + {carousel.title} + + ))} + + + + ); +}; diff --git a/src/renderer/features/artists/components/album-artist-detail-header.tsx b/src/renderer/features/artists/components/album-artist-detail-header.tsx new file mode 100644 index 00000000..dd79fdac --- /dev/null +++ b/src/renderer/features/artists/components/album-artist-detail-header.tsx @@ -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) => { + 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 ( + + + + + {metadataItems + .filter((i) => i.value) + .map((item, index) => ( + + {index > 0 && } + {item.value} + + ))} + + + + + + ); + }, +); diff --git a/src/renderer/features/artists/queries/album-artist-detail-query.ts b/src/renderer/features/artists/queries/album-artist-detail-query.ts new file mode 100644 index 00000000..f2a08122 --- /dev/null +++ b/src/renderer/features/artists/queries/album-artist-detail-query.ts @@ -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, + }); +}; diff --git a/src/renderer/features/artists/queries/artist-info-query.ts b/src/renderer/features/artists/queries/artist-info-query.ts new file mode 100644 index 00000000..8c93abe4 --- /dev/null +++ b/src/renderer/features/artists/queries/artist-info-query.ts @@ -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, + }); +}; diff --git a/src/renderer/features/artists/queries/top-songs-list-query.ts b/src/renderer/features/artists/queries/top-songs-list-query.ts new file mode 100644 index 00000000..038c006a --- /dev/null +++ b/src/renderer/features/artists/queries/top-songs-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 { 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, + }); +}; diff --git a/src/renderer/features/artists/routes/album-artist-detail-route.tsx b/src/renderer/features/artists/routes/album-artist-detail-route.tsx new file mode 100644 index 00000000..faa53f3f --- /dev/null +++ b/src/renderer/features/artists/routes/album-artist-detail-route.tsx @@ -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(null); + const headerRef = useRef(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 ( + + + + {detailQuery?.data?.name} + + ), + target: headerRef, + }} + > + + + + + ); +}; + +export default AlbumArtistDetailRoute; diff --git a/src/renderer/features/shared/mutations/create-favorite-mutation.ts b/src/renderer/features/shared/mutations/create-favorite-mutation.ts index ce8e76e5..a205ec9a 100644 --- a/src/renderer/features/shared/mutations/create-favorite-mutation.ts +++ b/src/renderer/features/shared/mutations/create-favorite-mutation.ts @@ -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(queryKey); + + if (previous) { + switch (server?.type) { + case ServerType.NAVIDROME: + queryClient.setQueryData(queryKey, { + ...previous, + starred: true, + }); + break; + case ServerType.SUBSONIC: + queryClient.setQueryData(queryKey, { + ...previous, + starred: true, + }); + break; + case ServerType.JELLYFIN: + queryClient.setQueryData(queryKey, { + ...previous, + UserData: { + ...previous.UserData, + IsFavorite: true, + }, + }); + break; + } + } + } }, ...options, diff --git a/src/renderer/features/shared/mutations/delete-favorite-mutation.ts b/src/renderer/features/shared/mutations/delete-favorite-mutation.ts index d41f76f0..1892159e 100644 --- a/src/renderer/features/shared/mutations/delete-favorite-mutation.ts +++ b/src/renderer/features/shared/mutations/delete-favorite-mutation.ts @@ -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(queryKey); + + if (previous) { + switch (server?.type) { + case ServerType.NAVIDROME: + queryClient.setQueryData(queryKey, { + ...previous, + starred: false, + }); + break; + case ServerType.SUBSONIC: + queryClient.setQueryData(queryKey, { + ...previous, + starred: false, + }); + break; + case ServerType.JELLYFIN: + queryClient.setQueryData(queryKey, { + ...previous, + UserData: { + ...previous.UserData, + IsFavorite: false, + }, + }); + break; + } + } + } }, ...options, }); diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index c617cc64..cd96590a 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -234,9 +234,9 @@ export const Sidebar = () => { Tracks - + - {location.pathname === AppRoute.LIBRARY_ALBUMARTISTS ? ( + {location.pathname === AppRoute.LIBRARY_ALBUM_ARTISTS ? ( ) : ( diff --git a/src/renderer/features/songs/index.ts b/src/renderer/features/songs/index.ts index e69de29b..29c6c296 100644 --- a/src/renderer/features/songs/index.ts +++ b/src/renderer/features/songs/index.ts @@ -0,0 +1 @@ +export * from './queries/song-list-query'; diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index 18c8ea3a..db813f0a 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -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} /> } errorElement={} - path={AppRoute.LIBRARY_ALBUMARTISTS} - /> + path={AppRoute.LIBRARY_ALBUM_ARTISTS} + > + } + /> + } + path={AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL} + /> + } path="*"