mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
use album order on artist page for queue add (#1754)
This commit is contained in:
@@ -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,17 +17,22 @@ 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 {
|
||||||
|
albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDetailHeaderProps>(
|
||||||
|
({ albumsQuery }, ref) => {
|
||||||
const { albumArtistId, artistId } = useParams() as {
|
const { albumArtistId, artistId } = useParams() as {
|
||||||
albumArtistId?: string;
|
albumArtistId?: string;
|
||||||
artistId?: string;
|
artistId?: string;
|
||||||
@@ -73,17 +79,48 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
|
|||||||
const setFavorite = useSetFavorite();
|
const setFavorite = useSetFavorite();
|
||||||
const setRating = useSetRating();
|
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(
|
const handlePlay = useCallback(
|
||||||
(type?: Play) => {
|
(type?: Play) => {
|
||||||
if (!server?.id || !routeId) return;
|
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(
|
addToQueueByFetch(
|
||||||
server.id,
|
server.id,
|
||||||
[routeId],
|
albumIds,
|
||||||
LibraryItem.ALBUM_ARTIST,
|
LibraryItem.ALBUM,
|
||||||
type || playButtonBehavior,
|
type || playButtonBehavior,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[addToQueueByFetch, playButtonBehavior, routeId, server.id],
|
[
|
||||||
|
addToQueueByFetch,
|
||||||
|
playButtonBehavior,
|
||||||
|
routeId,
|
||||||
|
server.id,
|
||||||
|
albumsQuery.data?.items,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
groupingType,
|
||||||
|
artistReleaseTypeItems,
|
||||||
|
t,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFavorite = useCallback(() => {
|
const handleFavorite = useCallback(() => {
|
||||||
@@ -190,4 +227,5 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
|
|||||||
</Stack>
|
</Stack>
|
||||||
</LibraryHeader>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user