From 58ae76ce2adb356000145c11cd738e8b448195c9 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 9 Mar 2026 00:47:55 -0700 Subject: [PATCH] use album order on artist page for queue add (#1754) --- .../album-artist-detail-content.tsx | 340 +---------------- .../components/album-artist-detail-header.tsx | 348 +++++++++-------- .../hooks/use-artist-albums-grouped.ts | 354 ++++++++++++++++++ .../routes/album-artist-detail-route.tsx | 5 +- 4 files changed, 554 insertions(+), 493 deletions(-) create mode 100644 src/renderer/features/artists/hooks/use-artist-albums-grouped.ts diff --git a/src/renderer/features/artists/components/album-artist-detail-content.tsx b/src/renderer/features/artists/components/album-artist-detail-content.tsx index e52acf2bf..4248152cd 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -45,7 +45,6 @@ import { useGenreRoute } from '/@/renderer/hooks/use-genre-route'; import { AppRoute } from '/@/renderer/router/routes'; import { ArtistItem, - ArtistReleaseTypeItem, useAppStore, useCurrentServer, useCurrentServerId, @@ -54,13 +53,11 @@ import { import { useArtistItems, useArtistRadioCount, - useArtistReleaseTypeItems, useExternalLinks, useSettingsStore, } from '/@/renderer/store/settings.store'; -import { titleCase } from '/@/renderer/utils'; import { sanitize } from '/@/renderer/utils/sanitize'; -import { SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils'; +import { sortAlbumList } from '/@/shared/api/utils'; import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon'; import { Badge } from '/@/shared/components/badge/badge'; import { Button } from '/@/shared/components/button/button'; @@ -93,8 +90,6 @@ import { } from '/@/shared/types/domain-types'; import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types'; -const collator = new Intl.Collator(); - interface AlbumArtistActionButtonsProps { artistDiscographyLink: string; artistSongsLink: string; @@ -1327,141 +1322,7 @@ const AlbumSection = ({ ); }; -type GroupingType = 'all' | 'primary'; - -const PRIMARY_RELEASE_TYPES = ['album', 'broadcast', 'ep', 'other', 'single']; - -const groupAlbumsByReleaseType = ( - albums: Album[], - routeId: string, - groupingType: GroupingType = 'primary', -): Record => { - if (groupingType === 'all') { - // Group by all individual release types - const grouped = albums.reduce( - (acc, album) => { - // Priority 1: Appears on - artist is not an album artist - const isAlbumArtist = album.albumArtists?.some((artist) => artist.id === routeId); - if (!isAlbumArtist) { - const appearsOnKey = 'appears-on'; - if (!acc[appearsOnKey]) { - acc[appearsOnKey] = []; - } - acc[appearsOnKey].push(album); - return acc; - } - - // Priority 2: Compilations - if (album.isCompilation) { - const compilationKey = 'compilation'; - if (!acc[compilationKey]) { - acc[compilationKey] = []; - } - acc[compilationKey].push(album); - return acc; - } - - // Group by all release types - const releaseTypes = album.releaseTypes || []; - if (releaseTypes.length > 0) { - // Sort release types: primaries first (alphabetically), then secondaries (alphabetically) - const normalizedTypes = releaseTypes.map((type) => type.toLowerCase()); - const primaryTypes = normalizedTypes - .filter((type) => PRIMARY_RELEASE_TYPES.includes(type)) - .sort(); - const secondaryTypes = normalizedTypes - .filter((type) => !PRIMARY_RELEASE_TYPES.includes(type)) - .sort(); - const sortedTypes = [...primaryTypes, ...secondaryTypes]; - - const combinedKey = sortedTypes.join('/'); - if (!acc[combinedKey]) { - acc[combinedKey] = []; - } - acc[combinedKey].push(album); - } else { - // If no release types, use "album" as fallback - const albumKey = 'album'; - if (!acc[albumKey]) { - acc[albumKey] = []; - } - acc[albumKey].push(album); - } - - return acc; - }, - {} as Record, - ); - - return grouped; - } - - // Group by primary release types - const grouped = albums.reduce( - (acc, album) => { - // Priority 1: Appears on - artist is not an album artist - const isAlbumArtist = album.albumArtists?.some((artist) => artist.id === routeId); - if (!isAlbumArtist) { - const appearsOnKey = 'appears-on'; - if (!acc[appearsOnKey]) { - acc[appearsOnKey] = []; - } - acc[appearsOnKey].push(album); - return acc; - } - - const releaseTypes = album.releaseTypes || []; - const normalizedTypes = releaseTypes.map((type) => type.toLowerCase()); - - let matchedType: null | string = null; - - if (normalizedTypes.includes('album')) { - matchedType = 'album'; - } else if (normalizedTypes.includes('single')) { - matchedType = 'single'; - } else if (normalizedTypes.includes('ep')) { - matchedType = 'ep'; - } else if (normalizedTypes.includes('broadcast')) { - matchedType = 'broadcast'; - } else if (normalizedTypes.includes('other')) { - matchedType = 'other'; - } else { - matchedType = 'album'; - } - - const releaseTypeKey = matchedType; - if (!acc[releaseTypeKey]) { - acc[releaseTypeKey] = []; - } - acc[releaseTypeKey].push(album); - return acc; - }, - {} as Record, - ); - - return grouped; -}; - -const releaseTypeToEnumMap: Record = { - album: ArtistReleaseTypeItem.RELEASE_TYPE_ALBUM, - 'appears-on': ArtistReleaseTypeItem.APPEARS_ON, - audiobook: ArtistReleaseTypeItem.RELEASE_TYPE_AUDIOBOOK, - 'audio drama': ArtistReleaseTypeItem.RELEASE_TYPE_AUDIO_DRAMA, - broadcast: ArtistReleaseTypeItem.RELEASE_TYPE_BROADCAST, - compilation: ArtistReleaseTypeItem.RELEASE_TYPE_COMPILATION, - demo: ArtistReleaseTypeItem.RELEASE_TYPE_DEMO, - 'dj-mix': ArtistReleaseTypeItem.RELEASE_TYPE_DJ_MIX, - ep: ArtistReleaseTypeItem.RELEASE_TYPE_EP, - 'field recording': ArtistReleaseTypeItem.RELEASE_TYPE_FIELD_RECORDING, - interview: ArtistReleaseTypeItem.RELEASE_TYPE_INTERVIEW, - live: ArtistReleaseTypeItem.RELEASE_TYPE_LIVE, - 'mixtape/street': ArtistReleaseTypeItem.RELEASE_TYPE_MIXTAPE_STREET, - other: ArtistReleaseTypeItem.RELEASE_TYPE_OTHER, - remix: ArtistReleaseTypeItem.RELEASE_TYPE_REMIX, - single: ArtistReleaseTypeItem.RELEASE_TYPE_SINGLE, - soundtrack: ArtistReleaseTypeItem.RELEASE_TYPE_SOUNDTRACK, - spokenword: ArtistReleaseTypeItem.RELEASE_TYPE_SPOKENWORD, -}; +import { useArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped'; interface ArtistAlbumsProps { albumsQuery: UseSuspenseQueryResult; @@ -1470,14 +1331,12 @@ interface ArtistAlbumsProps { const ArtistAlbums = ({ albumsQuery, order }: ArtistAlbumsProps) => { const { t } = useTranslation(); - const artistReleaseTypeItems = useArtistReleaseTypeItems(); const [searchTerm, setSearchTerm] = useState(''); const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300); const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort); const setAlbumArtistDetailSort = useAppStore((state) => state.actions.setAlbumArtistDetailSort); const sortBy = albumArtistDetailSort.sortBy; const sortOrder = albumArtistDetailSort.sortOrder; - const groupingType = albumArtistDetailSort.groupingType; const { albumArtistId, artistId } = useParams() as { albumArtistId?: string; @@ -1495,200 +1354,7 @@ const ArtistAlbums = ({ albumsQuery, order }: ArtistAlbumsProps) => { const controls = useDefaultItemListControls(); - const albumsByReleaseType = useMemo(() => { - return groupAlbumsByReleaseType(filteredAndSortedAlbums, routeId, groupingType); - }, [filteredAndSortedAlbums, routeId, groupingType]); - - const releaseTypeEntries = useMemo(() => { - const enabledReleaseTypeEnums = new Set( - artistReleaseTypeItems.filter((item) => !item.disabled).map((item) => item.id), - ); - - const priorityMap = new Map(); - artistReleaseTypeItems - .filter((item) => !item.disabled) - .forEach((item, index) => { - const releaseTypeKey = Object.keys(releaseTypeToEnumMap).find( - (key) => releaseTypeToEnumMap[key] === item.id, - ); - if (releaseTypeKey) { - priorityMap.set(releaseTypeKey, index); - } - }); - - const getDisplayNameForType = (releaseType: string): string => { - switch (releaseType) { - case 'album': - return t('releaseType.primary.album', { - postProcess: 'sentenceCase', - }); - case 'appears-on': - return t('page.albumArtistDetail.appearsOn', { - postProcess: 'sentenceCase', - }); - case 'audiobook': - return t('releaseType.secondary.audiobook', { - postProcess: 'sentenceCase', - }); - case 'audio drama': - return t('releaseType.secondary.audioDrama', { - postProcess: 'sentenceCase', - }); - case 'broadcast': - return t('releaseType.primary.broadcast', { - postProcess: 'sentenceCase', - }); - case 'compilation': - return t('releaseType.secondary.compilation', { - postProcess: 'sentenceCase', - }); - case 'demo': - return t('releaseType.secondary.demo', { - postProcess: 'sentenceCase', - }); - case 'dj-mix': - return t('releaseType.secondary.djMix', { - postProcess: 'sentenceCase', - }); - case 'ep': - return t('releaseType.primary.ep', { - postProcess: 'upperCase', - }); - case 'field recording': - return t('releaseType.secondary.fieldRecording', { - postProcess: 'sentenceCase', - }); - case 'interview': - return t('releaseType.secondary.interview', { - postProcess: 'sentenceCase', - }); - case 'live': - return t('releaseType.secondary.live', { - postProcess: 'sentenceCase', - }); - case 'mixtape/street': - return t('releaseType.secondary.mixtape', { - postProcess: 'sentenceCase', - }); - case 'other': - return t('releaseType.primary.other', { - postProcess: 'sentenceCase', - }); - case 'remix': - return t('releaseType.secondary.remix', { - postProcess: 'sentenceCase', - }); - case 'single': - return t('releaseType.primary.single', { - postProcess: 'sentenceCase', - }); - case 'soundtrack': - return t('releaseType.secondary.soundtrack', { - postProcess: 'sentenceCase', - }); - case 'spokenword': - return t('releaseType.secondary.spokenWord', { - postProcess: 'sentenceCase', - }); - default: - return titleCase(releaseType); - } - }; - - const getPriority = (releaseType: string) => { - if (releaseType.includes('/')) { - const types = releaseType.split('/'); - // Check if there's a primary type in the joined types - const primaryTypes = types.filter((type) => PRIMARY_RELEASE_TYPES.includes(type)); - - if (primaryTypes.length > 0) { - // Use the primary type's priority (first primary if multiple) - const primaryPriority = priorityMap.get(primaryTypes[0]) ?? 999; - return primaryPriority; - } else { - // Only secondary types - use minimum priority from settings - const priorities = types - .map((type) => priorityMap.get(type) ?? 999) - .filter((p) => p !== 999); - return priorities.length > 0 ? Math.min(...priorities) : 999; - } - } - return priorityMap.get(releaseType) ?? 999; - }; - - const getSecondaryTypePriorityKey = (releaseType: string): string => { - if (releaseType.includes('/')) { - const types = releaseType.split('/'); - const secondaryTypes = types.filter( - (type) => !PRIMARY_RELEASE_TYPES.includes(type), - ); - - if (secondaryTypes.length > 0) { - const priorities = secondaryTypes - .map((type) => priorityMap.get(type) ?? 999) - .filter((p) => p !== 999) - .sort((a, b) => a - b); - - // Create a comparison key from sorted priorities - return priorities.map((p) => String(p).padStart(3, '0')).join(','); - } - } - return ''; - }; - - const isReleaseTypeEnabled = (releaseType: string): boolean => { - if (releaseType.includes('/')) { - const types = releaseType.split('/'); - return types.some((type) => { - const enumValue = releaseTypeToEnumMap[type]; - return enumValue ? enabledReleaseTypeEnums.has(enumValue) : false; - }); - } - const enumValue = releaseTypeToEnumMap[releaseType]; - return enumValue ? enabledReleaseTypeEnums.has(enumValue) : false; - }; - - return Object.entries(albumsByReleaseType) - .filter(([releaseType]) => isReleaseTypeEnabled(releaseType)) - .map(([releaseType, albums]) => { - let displayName: React.ReactNode | string; - - if (releaseType.includes('/')) { - const types = releaseType.split('/'); - const displayNames = types.map((type) => getDisplayNameForType(type)); - displayName = displayNames.join(SEPARATOR_STRING); - } else { - displayName = getDisplayNameForType(releaseType); - } - - return { albums, displayName, releaseType }; - }) - .sort((a, b) => { - const priorityA = getPriority(a.releaseType); - const priorityB = getPriority(b.releaseType); - - // First sort by priority - if (priorityA !== priorityB) { - return priorityA - priorityB; - } - - // If priorities are equal, use weighted ordering for combined release types - const isCombinedA = a.releaseType.includes('/'); - const isCombinedB = b.releaseType.includes('/'); - - if (isCombinedA && isCombinedB) { - const secondaryKeyA = getSecondaryTypePriorityKey(a.releaseType); - const secondaryKeyB = getSecondaryTypePriorityKey(b.releaseType); - - if (secondaryKeyA && secondaryKeyB) { - return collator.compare(secondaryKeyA, secondaryKeyB); - } - } - - // Fallback to alphabetical for non-combined types or if weighted comparison isn't applicable - return collator.compare(a.releaseType, b.releaseType); - }); - }, [albumsByReleaseType, artistReleaseTypeItems, t]); + const { releaseTypeEntries } = useArtistAlbumsGrouped(filteredAndSortedAlbums, routeId); const cq = useContainerQuery({ '2xl': 1280, diff --git a/src/renderer/features/artists/components/album-artist-detail-header.tsx b/src/renderer/features/artists/components/album-artist-detail-header.tsx index a7254df37..933b7363d 100644 --- a/src/renderer/features/artists/components/album-artist-detail-header.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-header.tsx @@ -1,5 +1,5 @@ -import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; -import { forwardRef, Fragment, Ref, useCallback, useMemo } from 'react'; +import { useQuery, useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query'; +import { forwardRef, Fragment, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router'; @@ -7,6 +7,7 @@ import styles from './album-artist-detail-header.module.css'; import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; +import { getArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { @@ -16,178 +17,215 @@ import { import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite'; import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating'; import { AppRoute } from '/@/renderer/router/routes'; -import { useCurrentServer, useShowRatings } from '/@/renderer/store'; -import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; +import { useAppStore, useCurrentServer, useShowRatings } from '/@/renderer/store'; +import { useArtistReleaseTypeItems, usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { formatDurationString } from '/@/renderer/utils'; -import { SEPARATOR_STRING } from '/@/shared/api/utils'; +import { SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils'; import { Group } from '/@/shared/components/group/group'; import { Stack } from '/@/shared/components/stack/stack'; import { Text } from '/@/shared/components/text/text'; -import { LibraryItem, ServerType } from '/@/shared/types/domain-types'; +import { AlbumListResponse, LibraryItem, ServerType } from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; -export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref) => { - const { albumArtistId, artistId } = useParams() as { - albumArtistId?: string; - artistId?: string; - }; - const routeId = (artistId || albumArtistId) as string; - const server = useCurrentServer(); - const showRatings = useShowRatings(); - const { t } = useTranslation(); - const detailQuery = useSuspenseQuery( - artistsQueries.albumArtistDetail({ - query: { id: routeId }, - serverId: server?.id, - }), - ); +interface AlbumArtistDetailHeaderProps { + albumsQuery: UseSuspenseQueryResult; +} - const albumCount = detailQuery.data?.albumCount; - const songCount = detailQuery.data?.songCount; - const duration = detailQuery.data?.duration; - const durationEnabled = duration !== null && duration !== undefined; - - const metadataItems = [ - { - enabled: albumCount !== null && albumCount !== undefined, - id: 'albumCount', - secondary: false, - value: t('entity.albumWithCount', { count: albumCount || 0 }), - }, - { - enabled: songCount !== null && songCount !== undefined, - id: 'songCount', - secondary: false, - value: t('entity.trackWithCount', { count: songCount || 0 }), - }, - { - enabled: durationEnabled, - id: 'duration', - secondary: true, - value: durationEnabled && formatDurationString(duration), - }, - ]; - - const { addToQueueByFetch } = usePlayer(); - const playButtonBehavior = usePlayButtonBehavior(); - const setFavorite = useSetFavorite(); - const setRating = useSetRating(); - - const handlePlay = useCallback( - (type?: Play) => { - if (!server?.id || !routeId) return; - addToQueueByFetch( - server.id, - [routeId], - LibraryItem.ALBUM_ARTIST, - type || playButtonBehavior, - ); - }, - [addToQueueByFetch, playButtonBehavior, routeId, server.id], - ); - - const handleFavorite = useCallback(() => { - if (!detailQuery.data) return; - setFavorite( - detailQuery.data._serverId, - [detailQuery.data.id], - LibraryItem.ALBUM_ARTIST, - !detailQuery.data.userFavorite, +export const AlbumArtistDetailHeader = forwardRef( + ({ albumsQuery }, ref) => { + const { albumArtistId, artistId } = useParams() as { + albumArtistId?: string; + artistId?: string; + }; + const routeId = (artistId || albumArtistId) as string; + const server = useCurrentServer(); + const showRatings = useShowRatings(); + const { t } = useTranslation(); + const detailQuery = useSuspenseQuery( + artistsQueries.albumArtistDetail({ + query: { id: routeId }, + serverId: server?.id, + }), ); - }, [detailQuery.data, setFavorite]); - const handleUpdateRating = useCallback( - (rating: number) => { + const albumCount = detailQuery.data?.albumCount; + const songCount = detailQuery.data?.songCount; + const duration = detailQuery.data?.duration; + const durationEnabled = duration !== null && duration !== undefined; + + const metadataItems = [ + { + enabled: albumCount !== null && albumCount !== undefined, + id: 'albumCount', + secondary: false, + value: t('entity.albumWithCount', { count: albumCount || 0 }), + }, + { + enabled: songCount !== null && songCount !== undefined, + id: 'songCount', + secondary: false, + value: t('entity.trackWithCount', { count: songCount || 0 }), + }, + { + enabled: durationEnabled, + id: 'duration', + secondary: true, + value: durationEnabled && formatDurationString(duration), + }, + ]; + + const { addToQueueByFetch } = usePlayer(); + const playButtonBehavior = usePlayButtonBehavior(); + const setFavorite = useSetFavorite(); + const setRating = useSetRating(); + + const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort); + const sortBy = albumArtistDetailSort.sortBy; + const sortOrder = albumArtistDetailSort.sortOrder; + const groupingType = albumArtistDetailSort.groupingType; + const artistReleaseTypeItems = useArtistReleaseTypeItems(); + + const handlePlay = useCallback( + (type?: Play) => { + if (!server?.id || !routeId) return; + + const albums = albumsQuery.data?.items || []; + const sortedAlbums = sortAlbumList(albums, sortBy, sortOrder); + + const { flatSortedAlbums } = getArtistAlbumsGrouped( + sortedAlbums, + routeId, + groupingType, + artistReleaseTypeItems, + t, + ); + + const albumIds = flatSortedAlbums.map((album) => album.id); + if (albumIds.length === 0) return; + addToQueueByFetch( + server.id, + albumIds, + LibraryItem.ALBUM, + type || playButtonBehavior, + ); + }, + [ + addToQueueByFetch, + playButtonBehavior, + routeId, + server.id, + albumsQuery.data?.items, + sortBy, + sortOrder, + groupingType, + artistReleaseTypeItems, + t, + ], + ); + + const handleFavorite = useCallback(() => { if (!detailQuery.data) return; + setFavorite( + detailQuery.data._serverId, + [detailQuery.data.id], + LibraryItem.ALBUM_ARTIST, + !detailQuery.data.userFavorite, + ); + }, [detailQuery.data, setFavorite]); + + const handleUpdateRating = useCallback( + (rating: number) => { + if (!detailQuery.data) return; + + if (detailQuery.data.userRating === rating) { + return setRating( + detailQuery.data._serverId, + [detailQuery.data.id], + LibraryItem.ALBUM_ARTIST, + 0, + ); + } - if (detailQuery.data.userRating === rating) { return setRating( detailQuery.data._serverId, [detailQuery.data.id], LibraryItem.ALBUM_ARTIST, - 0, + rating, ); - } + }, + [detailQuery.data, setRating], + ); - return setRating( - detailQuery.data._serverId, - [detailQuery.data.id], - LibraryItem.ALBUM_ARTIST, - rating, - ); - }, - [detailQuery.data, setRating], - ); + const handleMoreOptions = useCallback( + (e: React.MouseEvent) => { + if (!detailQuery.data) return; + ContextMenuController.call({ + cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM_ARTIST }, + event: e, + }); + }, + [detailQuery.data], + ); - const handleMoreOptions = useCallback( - (e: React.MouseEvent) => { - if (!detailQuery.data) return; - ContextMenuController.call({ - cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM_ARTIST }, - event: e, - }); - }, - [detailQuery.data], - ); + const imageUrl = useItemImageUrl({ + id: detailQuery.data?.imageId || undefined, + imageUrl: detailQuery.data?.imageUrl, + itemType: LibraryItem.ALBUM_ARTIST, + type: 'itemCard', + }); - const imageUrl = useItemImageUrl({ - id: detailQuery.data?.imageId || undefined, - imageUrl: detailQuery.data?.imageUrl, - itemType: LibraryItem.ALBUM_ARTIST, - type: 'itemCard', - }); + const artistInfoQuery = useQuery({ + ...artistsQueries.albumArtistInfo({ + query: { id: routeId, limit: 10 }, + serverId: server?.id, + }), + enabled: Boolean(server?.id && routeId), + }); - const artistInfoQuery = useQuery({ - ...artistsQueries.albumArtistInfo({ - query: { id: routeId, limit: 10 }, - serverId: server?.id, - }), - enabled: Boolean(server?.id && routeId), - }); + const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME; - const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME; + const selectedImageUrl = useMemo(() => { + return detailQuery.data?.imageUrl || artistInfoQuery.data?.imageUrl || imageUrl; + }, [artistInfoQuery.data?.imageUrl, detailQuery.data?.imageUrl, imageUrl]); - const selectedImageUrl = useMemo(() => { - return detailQuery.data?.imageUrl || artistInfoQuery.data?.imageUrl || imageUrl; - }, [artistInfoQuery.data?.imageUrl, detailQuery.data?.imageUrl, imageUrl]); - - return ( - - - - {metadataItems - .filter((i) => i.enabled) - .map((item, index) => ( - - {index > 0 && ( - - {SEPARATOR_STRING} - - )} - {item.value} - - ))} - - handlePlay(type)} - onRating={showRating ? handleUpdateRating : undefined} - onShuffle={() => handlePlay(Play.SHUFFLE)} - rating={detailQuery.data?.userRating || 0} - /> - - - ); -}); + return ( + + + + {metadataItems + .filter((i) => i.enabled) + .map((item, index) => ( + + {index > 0 && ( + + {SEPARATOR_STRING} + + )} + {item.value} + + ))} + + handlePlay(type)} + onRating={showRating ? handleUpdateRating : undefined} + onShuffle={() => handlePlay(Play.SHUFFLE)} + rating={detailQuery.data?.userRating || 0} + /> + + + ); + }, +); diff --git a/src/renderer/features/artists/hooks/use-artist-albums-grouped.ts b/src/renderer/features/artists/hooks/use-artist-albums-grouped.ts new file mode 100644 index 000000000..545a86885 --- /dev/null +++ b/src/renderer/features/artists/hooks/use-artist-albums-grouped.ts @@ -0,0 +1,354 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ArtistReleaseTypeItem, useAppStore } from '/@/renderer/store'; +import { useArtistReleaseTypeItems } from '/@/renderer/store/settings.store'; +import { titleCase } from '/@/renderer/utils'; +import { SEPARATOR_STRING } from '/@/shared/api/utils'; +import { Album } from '/@/shared/types/domain-types'; + +const collator = new Intl.Collator(); + +export type GroupingType = 'all' | 'primary'; + +const PRIMARY_RELEASE_TYPES = ['album', 'broadcast', 'ep', 'other', 'single']; + +export const groupAlbumsByReleaseType = ( + albums: Album[], + routeId: string, + groupingType: GroupingType = 'primary', +): Record => { + if (groupingType === 'all') { + // Group by all individual release types + const grouped = albums.reduce( + (acc, album) => { + // Priority 1: Appears on - artist is not an album artist + const isAlbumArtist = album.albumArtists?.some((artist) => artist.id === routeId); + if (!isAlbumArtist) { + const appearsOnKey = 'appears-on'; + if (!acc[appearsOnKey]) { + acc[appearsOnKey] = []; + } + acc[appearsOnKey].push(album); + return acc; + } + + // Priority 2: Compilations + if (album.isCompilation) { + const compilationKey = 'compilation'; + if (!acc[compilationKey]) { + acc[compilationKey] = []; + } + acc[compilationKey].push(album); + return acc; + } + + // Group by all release types + const releaseTypes = album.releaseTypes || []; + if (releaseTypes.length > 0) { + // Sort release types: primaries first (alphabetically), then secondaries (alphabetically) + const normalizedTypes = releaseTypes.map((type) => type.toLowerCase()); + const primaryTypes = normalizedTypes + .filter((type) => PRIMARY_RELEASE_TYPES.includes(type)) + .sort(); + const secondaryTypes = normalizedTypes + .filter((type) => !PRIMARY_RELEASE_TYPES.includes(type)) + .sort(); + const sortedTypes = [...primaryTypes, ...secondaryTypes]; + + const combinedKey = sortedTypes.join('/'); + if (!acc[combinedKey]) { + acc[combinedKey] = []; + } + acc[combinedKey].push(album); + } else { + // If no release types, use "album" as fallback + const albumKey = 'album'; + if (!acc[albumKey]) { + acc[albumKey] = []; + } + acc[albumKey].push(album); + } + + return acc; + }, + {} as Record, + ); + + return grouped; + } + + // Group by primary release types + const grouped = albums.reduce( + (acc, album) => { + // Priority 1: Appears on - artist is not an album artist + const isAlbumArtist = album.albumArtists?.some((artist) => artist.id === routeId); + if (!isAlbumArtist) { + const appearsOnKey = 'appears-on'; + if (!acc[appearsOnKey]) { + acc[appearsOnKey] = []; + } + acc[appearsOnKey].push(album); + return acc; + } + + const releaseTypes = album.releaseTypes || []; + const normalizedTypes = releaseTypes.map((type) => type.toLowerCase()); + + let matchedType: null | string = null; + + if (normalizedTypes.includes('album')) { + matchedType = 'album'; + } else if (normalizedTypes.includes('single')) { + matchedType = 'single'; + } else if (normalizedTypes.includes('ep')) { + matchedType = 'ep'; + } else if (normalizedTypes.includes('broadcast')) { + matchedType = 'broadcast'; + } else if (normalizedTypes.includes('other')) { + matchedType = 'other'; + } else { + matchedType = 'album'; + } + + const releaseTypeKey = matchedType; + if (!acc[releaseTypeKey]) { + acc[releaseTypeKey] = []; + } + acc[releaseTypeKey].push(album); + return acc; + }, + {} as Record, + ); + + return grouped; +}; + +export const releaseTypeToEnumMap: Record = { + album: ArtistReleaseTypeItem.RELEASE_TYPE_ALBUM, + 'appears-on': ArtistReleaseTypeItem.APPEARS_ON, + audiobook: ArtistReleaseTypeItem.RELEASE_TYPE_AUDIOBOOK, + 'audio drama': ArtistReleaseTypeItem.RELEASE_TYPE_AUDIO_DRAMA, + broadcast: ArtistReleaseTypeItem.RELEASE_TYPE_BROADCAST, + compilation: ArtistReleaseTypeItem.RELEASE_TYPE_COMPILATION, + demo: ArtistReleaseTypeItem.RELEASE_TYPE_DEMO, + 'dj-mix': ArtistReleaseTypeItem.RELEASE_TYPE_DJ_MIX, + ep: ArtistReleaseTypeItem.RELEASE_TYPE_EP, + 'field recording': ArtistReleaseTypeItem.RELEASE_TYPE_FIELD_RECORDING, + interview: ArtistReleaseTypeItem.RELEASE_TYPE_INTERVIEW, + live: ArtistReleaseTypeItem.RELEASE_TYPE_LIVE, + 'mixtape/street': ArtistReleaseTypeItem.RELEASE_TYPE_MIXTAPE_STREET, + other: ArtistReleaseTypeItem.RELEASE_TYPE_OTHER, + remix: ArtistReleaseTypeItem.RELEASE_TYPE_REMIX, + single: ArtistReleaseTypeItem.RELEASE_TYPE_SINGLE, + soundtrack: ArtistReleaseTypeItem.RELEASE_TYPE_SOUNDTRACK, + spokenword: ArtistReleaseTypeItem.RELEASE_TYPE_SPOKENWORD, +}; + +export const getArtistAlbumsGrouped = ( + albums: Album[], + routeId: string, + groupingType: GroupingType, + artistReleaseTypeItems: { disabled: boolean; id: string }[], + t: (key: string, options?: any) => string, +) => { + const albumsByReleaseType = groupAlbumsByReleaseType(albums, routeId, groupingType); + + const enabledReleaseTypeEnums = new Set( + artistReleaseTypeItems.filter((item) => !item.disabled).map((item) => item.id), + ); + + const priorityMap = new Map(); + artistReleaseTypeItems + .filter((item) => !item.disabled) + .forEach((item, index) => { + const releaseTypeKey = Object.keys(releaseTypeToEnumMap).find( + (key) => releaseTypeToEnumMap[key] === item.id, + ); + if (releaseTypeKey) { + priorityMap.set(releaseTypeKey, index); + } + }); + + const getDisplayNameForType = (releaseType: string): string => { + switch (releaseType) { + case 'album': + return t('releaseType.primary.album', { + postProcess: 'sentenceCase', + }); + case 'appears-on': + return t('page.albumArtistDetail.appearsOn', { + postProcess: 'sentenceCase', + }); + case 'audiobook': + return t('releaseType.secondary.audiobook', { + postProcess: 'sentenceCase', + }); + case 'audio drama': + return t('releaseType.secondary.audioDrama', { + postProcess: 'sentenceCase', + }); + case 'broadcast': + return t('releaseType.primary.broadcast', { + postProcess: 'sentenceCase', + }); + case 'compilation': + return t('releaseType.secondary.compilation', { + postProcess: 'sentenceCase', + }); + case 'demo': + return t('releaseType.secondary.demo', { + postProcess: 'sentenceCase', + }); + case 'dj-mix': + return t('releaseType.secondary.djMix', { + postProcess: 'sentenceCase', + }); + case 'ep': + return t('releaseType.primary.ep', { + postProcess: 'upperCase', + }); + case 'field recording': + return t('releaseType.secondary.fieldRecording', { + postProcess: 'sentenceCase', + }); + case 'interview': + return t('releaseType.secondary.interview', { + postProcess: 'sentenceCase', + }); + case 'live': + return t('releaseType.secondary.live', { + postProcess: 'sentenceCase', + }); + case 'mixtape/street': + return t('releaseType.secondary.mixtape', { + postProcess: 'sentenceCase', + }); + case 'other': + return t('releaseType.primary.other', { + postProcess: 'sentenceCase', + }); + case 'remix': + return t('releaseType.secondary.remix', { + postProcess: 'sentenceCase', + }); + case 'single': + return t('releaseType.primary.single', { + postProcess: 'sentenceCase', + }); + case 'soundtrack': + return t('releaseType.secondary.soundtrack', { + postProcess: 'sentenceCase', + }); + case 'spokenword': + return t('releaseType.secondary.spokenWord', { + postProcess: 'sentenceCase', + }); + default: + return titleCase(releaseType); + } + }; + + const getPriority = (releaseType: string) => { + if (releaseType.includes('/')) { + const types = releaseType.split('/'); + // Check if there's a primary type in the joined types + const primaryTypes = types.filter((type) => PRIMARY_RELEASE_TYPES.includes(type)); + + if (primaryTypes.length > 0) { + // Use the primary type's priority (first primary if multiple) + const primaryPriority = priorityMap.get(primaryTypes[0]) ?? 999; + return primaryPriority; + } else { + // Only secondary types - use minimum priority from settings + const priorities = types + .map((type) => priorityMap.get(type) ?? 999) + .filter((p) => p !== 999); + return priorities.length > 0 ? Math.min(...priorities) : 999; + } + } + return priorityMap.get(releaseType) ?? 999; + }; + + const getSecondaryTypePriorityKey = (releaseType: string): string => { + if (releaseType.includes('/')) { + const types = releaseType.split('/'); + const secondaryTypes = types.filter((type) => !PRIMARY_RELEASE_TYPES.includes(type)); + + if (secondaryTypes.length > 0) { + const priorities = secondaryTypes + .map((type) => priorityMap.get(type) ?? 999) + .filter((p) => p !== 999) + .sort((a, b) => a - b); + + return priorities.map((p) => String(p).padStart(3, '0')).join(','); + } + } + return ''; + }; + + const isReleaseTypeEnabled = (releaseType: string): boolean => { + if (releaseType.includes('/')) { + const types = releaseType.split('/'); + return types.some((type) => { + const enumValue = releaseTypeToEnumMap[type]; + return enumValue ? enabledReleaseTypeEnums.has(enumValue) : false; + }); + } + const enumValue = releaseTypeToEnumMap[releaseType]; + return enumValue ? enabledReleaseTypeEnums.has(enumValue) : false; + }; + + const releaseTypeEntries = Object.entries(albumsByReleaseType) + .filter(([releaseType]) => isReleaseTypeEnabled(releaseType)) + .map(([releaseType, albums]) => { + let displayName: React.ReactNode | string; + + if (releaseType.includes('/')) { + const types = releaseType.split('/'); + const displayNames = types.map((type) => getDisplayNameForType(type)); + displayName = displayNames.join(SEPARATOR_STRING); + } else { + displayName = getDisplayNameForType(releaseType); + } + + return { albums, displayName, releaseType }; + }) + .sort((a, b) => { + const priorityA = getPriority(a.releaseType); + const priorityB = getPriority(b.releaseType); + + if (priorityA !== priorityB) { + return priorityA - priorityB; + } + + const isCombinedA = a.releaseType.includes('/'); + const isCombinedB = b.releaseType.includes('/'); + + if (isCombinedA && isCombinedB) { + const secondaryKeyA = getSecondaryTypePriorityKey(a.releaseType); + const secondaryKeyB = getSecondaryTypePriorityKey(b.releaseType); + + if (secondaryKeyA && secondaryKeyB) { + return collator.compare(secondaryKeyA, secondaryKeyB); + } + } + + return collator.compare(a.releaseType, b.releaseType); + }); + + const flatSortedAlbums = releaseTypeEntries.flatMap((entry) => entry.albums); + + return { flatSortedAlbums, releaseTypeEntries }; +}; + +export const useArtistAlbumsGrouped = (albums: Album[], routeId: string) => { + const { t } = useTranslation(); + const artistReleaseTypeItems = useArtistReleaseTypeItems(); + const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort); + const groupingType = albumArtistDetailSort.groupingType; + + return useMemo(() => { + return getArtistAlbumsGrouped(albums, routeId, groupingType, artistReleaseTypeItems, t); + }, [albums, routeId, groupingType, artistReleaseTypeItems, t]); +}; diff --git a/src/renderer/features/artists/routes/album-artist-detail-route.tsx b/src/renderer/features/artists/routes/album-artist-detail-route.tsx index 8de869ea6..023075370 100644 --- a/src/renderer/features/artists/routes/album-artist-detail-route.tsx +++ b/src/renderer/features/artists/routes/album-artist-detail-route.tsx @@ -140,7 +140,10 @@ const AlbumArtistDetailRouteContent = () => { )} - } /> + } + />