use album order on artist page for queue add (#1754)

This commit is contained in:
jeffvli
2026-03-09 00:47:55 -07:00
parent bc6cd5b014
commit 58ae76ce2a
4 changed files with 554 additions and 493 deletions
@@ -45,7 +45,6 @@ import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { import {
ArtistItem, ArtistItem,
ArtistReleaseTypeItem,
useAppStore, useAppStore,
useCurrentServer, useCurrentServer,
useCurrentServerId, useCurrentServerId,
@@ -54,13 +53,11 @@ import {
import { import {
useArtistItems, useArtistItems,
useArtistRadioCount, useArtistRadioCount,
useArtistReleaseTypeItems,
useExternalLinks, useExternalLinks,
useSettingsStore, useSettingsStore,
} from '/@/renderer/store/settings.store'; } from '/@/renderer/store/settings.store';
import { titleCase } from '/@/renderer/utils';
import { sanitize } from '/@/renderer/utils/sanitize'; 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 { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
import { Badge } from '/@/shared/components/badge/badge'; import { Badge } from '/@/shared/components/badge/badge';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
@@ -93,8 +90,6 @@ import {
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types'; import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';
const collator = new Intl.Collator();
interface AlbumArtistActionButtonsProps { interface AlbumArtistActionButtonsProps {
artistDiscographyLink: string; artistDiscographyLink: string;
artistSongsLink: string; artistSongsLink: string;
@@ -1327,141 +1322,7 @@ const AlbumSection = ({
); );
}; };
type GroupingType = 'all' | 'primary'; import { useArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped';
const PRIMARY_RELEASE_TYPES = ['album', 'broadcast', 'ep', 'other', 'single'];
const groupAlbumsByReleaseType = (
albums: Album[],
routeId: string,
groupingType: GroupingType = 'primary',
): Record<string, Album[]> => {
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<string, Album[]>,
);
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<string, Album[]>,
);
return grouped;
};
const releaseTypeToEnumMap: Record<string, ArtistReleaseTypeItem> = {
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,
};
interface ArtistAlbumsProps { interface ArtistAlbumsProps {
albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>; albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;
@@ -1470,14 +1331,12 @@ interface ArtistAlbumsProps {
const ArtistAlbums = ({ albumsQuery, order }: ArtistAlbumsProps) => { const ArtistAlbums = ({ albumsQuery, order }: ArtistAlbumsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const artistReleaseTypeItems = useArtistReleaseTypeItems();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300); const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort); const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);
const setAlbumArtistDetailSort = useAppStore((state) => state.actions.setAlbumArtistDetailSort); const setAlbumArtistDetailSort = useAppStore((state) => state.actions.setAlbumArtistDetailSort);
const sortBy = albumArtistDetailSort.sortBy; const sortBy = albumArtistDetailSort.sortBy;
const sortOrder = albumArtistDetailSort.sortOrder; const sortOrder = albumArtistDetailSort.sortOrder;
const groupingType = albumArtistDetailSort.groupingType;
const { albumArtistId, artistId } = useParams() as { const { albumArtistId, artistId } = useParams() as {
albumArtistId?: string; albumArtistId?: string;
@@ -1495,200 +1354,7 @@ const ArtistAlbums = ({ albumsQuery, order }: ArtistAlbumsProps) => {
const controls = useDefaultItemListControls(); const controls = useDefaultItemListControls();
const albumsByReleaseType = useMemo(() => { const { releaseTypeEntries } = useArtistAlbumsGrouped(filteredAndSortedAlbums, routeId);
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<string, number>();
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 cq = useContainerQuery({ const cq = useContainerQuery({
'2xl': 1280, '2xl': 1280,
@@ -1,5 +1,5 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useQuery, useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query';
import { forwardRef, Fragment, Ref, useCallback, useMemo } from 'react'; import { forwardRef, Fragment, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router'; 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 { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; 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 { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { import {
@@ -16,178 +17,215 @@ import {
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite'; import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating'; import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useShowRatings } from '/@/renderer/store'; import { useAppStore, useCurrentServer, useShowRatings } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { useArtistReleaseTypeItems, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { formatDurationString } from '/@/renderer/utils'; 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 { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text'; 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'; import { Play } from '/@/shared/types/types';
export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivElement>) => { interface AlbumArtistDetailHeaderProps {
const { albumArtistId, artistId } = useParams() as { albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;
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,
}),
);
const albumCount = detailQuery.data?.albumCount; export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDetailHeaderProps>(
const songCount = detailQuery.data?.songCount; ({ albumsQuery }, ref) => {
const duration = detailQuery.data?.duration; const { albumArtistId, artistId } = useParams() as {
const durationEnabled = duration !== null && duration !== undefined; albumArtistId?: string;
artistId?: string;
const metadataItems = [ };
{ const routeId = (artistId || albumArtistId) as string;
enabled: albumCount !== null && albumCount !== undefined, const server = useCurrentServer();
id: 'albumCount', const showRatings = useShowRatings();
secondary: false, const { t } = useTranslation();
value: t('entity.albumWithCount', { count: albumCount || 0 }), const detailQuery = useSuspenseQuery(
}, artistsQueries.albumArtistDetail({
{ query: { id: routeId },
enabled: songCount !== null && songCount !== undefined, serverId: server?.id,
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,
); );
}, [detailQuery.data, setFavorite]);
const handleUpdateRating = useCallback( const albumCount = detailQuery.data?.albumCount;
(rating: number) => { 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; 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( return setRating(
detailQuery.data._serverId, detailQuery.data._serverId,
[detailQuery.data.id], [detailQuery.data.id],
LibraryItem.ALBUM_ARTIST, LibraryItem.ALBUM_ARTIST,
0, rating,
); );
} },
[detailQuery.data, setRating],
);
return setRating( const handleMoreOptions = useCallback(
detailQuery.data._serverId, (e: React.MouseEvent<HTMLButtonElement>) => {
[detailQuery.data.id], if (!detailQuery.data) return;
LibraryItem.ALBUM_ARTIST, ContextMenuController.call({
rating, cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM_ARTIST },
); event: e,
}, });
[detailQuery.data, setRating], },
); [detailQuery.data],
);
const handleMoreOptions = useCallback( const imageUrl = useItemImageUrl({
(e: React.MouseEvent<HTMLButtonElement>) => { id: detailQuery.data?.imageId || undefined,
if (!detailQuery.data) return; imageUrl: detailQuery.data?.imageUrl,
ContextMenuController.call({ itemType: LibraryItem.ALBUM_ARTIST,
cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM_ARTIST }, type: 'itemCard',
event: e, });
});
},
[detailQuery.data],
);
const imageUrl = useItemImageUrl({ const artistInfoQuery = useQuery({
id: detailQuery.data?.imageId || undefined, ...artistsQueries.albumArtistInfo({
imageUrl: detailQuery.data?.imageUrl, query: { id: routeId, limit: 10 },
itemType: LibraryItem.ALBUM_ARTIST, serverId: server?.id,
type: 'itemCard', }),
}); enabled: Boolean(server?.id && routeId),
});
const artistInfoQuery = useQuery({ const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME;
...artistsQueries.albumArtistInfo({
query: { id: routeId, limit: 10 },
serverId: server?.id,
}),
enabled: Boolean(server?.id && routeId),
});
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 (
return detailQuery.data?.imageUrl || artistInfoQuery.data?.imageUrl || imageUrl; <LibraryHeader
}, [artistInfoQuery.data?.imageUrl, detailQuery.data?.imageUrl, imageUrl]); imageUrl={selectedImageUrl}
item={{
return ( imageId: detailQuery.data?.imageId,
<LibraryHeader imageUrl: detailQuery.data?.imageUrl,
imageUrl={selectedImageUrl} route: AppRoute.LIBRARY_ALBUM_ARTISTS,
item={{ type: LibraryItem.ALBUM_ARTIST,
imageId: detailQuery.data?.imageId, }}
imageUrl: detailQuery.data?.imageUrl, ref={ref}
route: AppRoute.LIBRARY_ALBUM_ARTISTS, title={detailQuery.data?.name || ''}
type: LibraryItem.ALBUM_ARTIST, >
}} <Stack gap="md" w="100%">
ref={ref} <Group className={styles.metadataGroup}>
title={detailQuery.data?.name || ''} {metadataItems
> .filter((i) => i.enabled)
<Stack gap="md" w="100%"> .map((item, index) => (
<Group className={styles.metadataGroup}> <Fragment key={`item-${item.id}-${index}`}>
{metadataItems {index > 0 && (
.filter((i) => i.enabled) <Text isMuted isNoSelect>
.map((item, index) => ( {SEPARATOR_STRING}
<Fragment key={`item-${item.id}-${index}`}> </Text>
{index > 0 && ( )}
<Text isMuted isNoSelect> <Text isMuted={item.secondary}>{item.value}</Text>
{SEPARATOR_STRING} </Fragment>
</Text> ))}
)} </Group>
<Text isMuted={item.secondary}>{item.value}</Text> <LibraryHeaderMenu
</Fragment> favorite={detailQuery.data?.userFavorite}
))} onFavorite={handleFavorite}
</Group> onMore={handleMoreOptions}
<LibraryHeaderMenu onPlay={(type) => handlePlay(type)}
favorite={detailQuery.data?.userFavorite} onRating={showRating ? handleUpdateRating : undefined}
onFavorite={handleFavorite} onShuffle={() => handlePlay(Play.SHUFFLE)}
onMore={handleMoreOptions} rating={detailQuery.data?.userRating || 0}
onPlay={(type) => handlePlay(type)} />
onRating={showRating ? handleUpdateRating : undefined} </Stack>
onShuffle={() => handlePlay(Play.SHUFFLE)} </LibraryHeader>
rating={detailQuery.data?.userRating || 0} );
/> },
</Stack> );
</LibraryHeader>
);
});
@@ -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<string, Album[]> => {
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<string, Album[]>,
);
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<string, Album[]>,
);
return grouped;
};
export const releaseTypeToEnumMap: Record<string, ArtistReleaseTypeItem> = {
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<string, number>();
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]);
};
@@ -140,7 +140,10 @@ const AlbumArtistDetailRouteContent = () => {
<LibraryBackgroundOverlay backgroundColor={background} headerRef={headerRef} /> <LibraryBackgroundOverlay backgroundColor={background} headerRef={headerRef} />
)} )}
<LibraryContainer> <LibraryContainer>
<AlbumArtistDetailHeader ref={headerRef as React.Ref<HTMLDivElement>} /> <AlbumArtistDetailHeader
albumsQuery={albumsQuery}
ref={headerRef as React.Ref<HTMLDivElement>}
/>
<AlbumArtistDetailContent albumsQuery={albumsQuery} detailQuery={detailQuery} /> <AlbumArtistDetailContent albumsQuery={albumsQuery} detailQuery={detailQuery} />
</LibraryContainer> </LibraryContainer>
</NativeScrollArea> </NativeScrollArea>