[enhancement]: Show item details (#573)

* start

* More details, don't show manage server when other modal
This commit is contained in:
Kendall Garner 2024-04-04 04:19:46 +00:00 committed by GitHub
parent 7bebe286d5
commit 197497df05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 365 additions and 82 deletions

View file

@ -26,6 +26,8 @@
"action_one": "action", "action_one": "action",
"action_other": "actions", "action_other": "actions",
"add": "add", "add": "add",
"albumGain": "album gain",
"albumPeak": "album peak",
"areYouSure": "are you sure?", "areYouSure": "are you sure?",
"ascending": "ascending", "ascending": "ascending",
"backward": "backward", "backward": "backward",
@ -72,6 +74,7 @@
"menu": "menu", "menu": "menu",
"minimize": "minimize", "minimize": "minimize",
"modified": "modified", "modified": "modified",
"mbid": "MusicBrainz ID",
"name": "name", "name": "name",
"no": "no", "no": "no",
"none": "none", "none": "none",
@ -102,6 +105,8 @@
"sortOrder": "order", "sortOrder": "order",
"title": "title", "title": "title",
"trackNumber": "track", "trackNumber": "track",
"trackGain": "track gain",
"trackPeak": "track peak",
"unknown": "unknown", "unknown": "unknown",
"version": "version", "version": "version",
"year": "year", "year": "year",
@ -306,7 +311,8 @@
"removeFromFavorites": "$t(action.removeFromFavorites)", "removeFromFavorites": "$t(action.removeFromFavorites)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)", "removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)", "removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)" "setRating": "$t(action.setRating)",
"showDetails": "get info"
}, },
"fullscreenPlayer": { "fullscreenPlayer": {
"config": { "config": {

View file

@ -80,19 +80,23 @@ const ActionRequiredRoute = () => {
</Button> </Button>
</> </>
)} )}
<Group {!displayedCheck && (
noWrap <Group
position="center" noWrap
> position="center"
<Button
fullWidth
leftIcon={<RiEdit2Line />}
variant="filled"
onClick={handleManageServersModal}
> >
{t('page.appMenu.manageServers', { postProcess: 'sentenceCase' })} <Button
</Button> fullWidth
</Group> leftIcon={<RiEdit2Line />}
variant="filled"
onClick={handleManageServersModal}
>
{t('page.appMenu.manageServers', {
postProcess: 'sentenceCase',
})}
</Button>
</Group>
)}
</Stack> </Stack>
</Stack> </Stack>
</Center> </Center>

View file

@ -9,6 +9,7 @@ export const QUEUE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'removeFromFavorites' }, { divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' }, { children: true, disabled: false, id: 'setRating' },
{ disabled: false, id: 'deselectAll' }, { disabled: false, id: 'deselectAll' },
{ divider: true, id: 'showDetails' },
]; ];
export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@ -19,6 +20,7 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'addToFavorites' }, { id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' }, { divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' }, { children: true, disabled: false, id: 'setRating' },
{ divider: true, id: 'showDetails' },
]; ];
export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@ -30,6 +32,7 @@ export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'addToFavorites' }, { id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' }, { divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' }, { children: true, disabled: false, id: 'setRating' },
{ divider: true, id: 'showDetails' },
]; ];
export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@ -40,6 +43,7 @@ export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'addToFavorites' }, { id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' }, { divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' }, { children: true, disabled: false, id: 'setRating' },
{ divider: true, id: 'showDetails' },
]; ];
export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@ -50,6 +54,7 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'addToFavorites' }, { id: 'addToFavorites' },
{ id: 'removeFromFavorites' }, { id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' }, { children: true, disabled: false, id: 'setRating' },
{ divider: true, id: 'showDetails' },
]; ];
export const GENRE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ export const GENRE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@ -67,6 +72,7 @@ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'addToFavorites' }, { id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' }, { divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' }, { children: true, disabled: false, id: 'setRating' },
{ divider: true, id: 'showDetails' },
]; ];
export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [

View file

@ -25,6 +25,7 @@ import {
RiPlayListAddFill, RiPlayListAddFill,
RiStarFill, RiStarFill,
RiCloseCircleLine, RiCloseCircleLine,
RiInformationFill,
} from 'react-icons/ri'; } from 'react-icons/ri';
import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types'; import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types';
import { import {
@ -53,6 +54,7 @@ import {
} from '/@/renderer/store'; } from '/@/renderer/store';
import { usePlaybackType } from '/@/renderer/store/settings.store'; import { usePlaybackType } from '/@/renderer/store/settings.store';
import { Play, PlaybackType } from '/@/renderer/types'; import { Play, PlaybackType } from '/@/renderer/types';
import { ItemDetailsModal } from '/@/renderer/features/item-details/components/item-details-modal';
type ContextMenuContextProps = { type ContextMenuContextProps = {
closeContextMenu: () => void; closeContextMenu: () => void;
@ -627,6 +629,16 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
ctx.tableApi?.deselectAll(); ctx.tableApi?.deselectAll();
}, [ctx.tableApi]); }, [ctx.tableApi]);
const handleOpenItemDetails = useCallback(() => {
const item = ctx.data[0];
openModal({
children: <ItemDetailsModal item={item} />,
size: 'xl',
title: t('page.contextMenu.showDetails', { postProcess: 'titleCase' }),
});
}, [ctx.data, t]);
const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => { const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => {
return { return {
addToFavorites: { addToFavorites: {
@ -775,20 +787,29 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
onClick: () => {}, onClick: () => {},
rightIcon: <RiArrowRightSFill size="1.2rem" />, rightIcon: <RiArrowRightSFill size="1.2rem" />,
}, },
showDetails: {
disabled: ctx.data?.length !== 1 || !ctx.data[0].itemType,
id: 'showDetails',
label: t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' }),
leftIcon: <RiInformationFill />,
onClick: handleOpenItemDetails,
},
}; };
}, [ }, [
t,
handleAddToFavorites, handleAddToFavorites,
handleAddToPlaylist, handleAddToPlaylist,
openDeletePlaylistModal,
handleDeselectAll, handleDeselectAll,
handleMoveToBottom, handleMoveToBottom,
handleMoveToTop, handleMoveToTop,
handlePlay,
handleRemoveFromFavorites, handleRemoveFromFavorites,
handleRemoveFromPlaylist, handleRemoveFromPlaylist,
handleRemoveSelected, handleRemoveSelected,
ctx.data,
handleOpenItemDetails,
handlePlay,
handleUpdateRating, handleUpdateRating,
openDeletePlaylistModal,
t,
]); ]);
const mergedRef = useMergedRef(ref, clickOutsideRef); const mergedRef = useMergedRef(ref, clickOutsideRef);
@ -819,72 +840,80 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
> >
{ctx.menuItems?.map((item) => { {ctx.menuItems?.map((item) => {
return ( return (
<Fragment key={`context-menu-${item.id}`}> !contextMenuItems[item.id].disabled && (
{item.children ? ( <Fragment key={`context-menu-${item.id}`}>
<HoverCard {item.children ? (
offset={5} <HoverCard
position="right" offset={5}
> position="right"
<HoverCard.Target> >
<ContextMenuButton <HoverCard.Target>
disabled={item.disabled} <ContextMenuButton
leftIcon={ leftIcon={
contextMenuItems[item.id] contextMenuItems[item.id]
.leftIcon .leftIcon
} }
rightIcon={ rightIcon={
contextMenuItems[item.id] contextMenuItems[item.id]
.rightIcon .rightIcon
} }
onClick={ onClick={
contextMenuItems[item.id] contextMenuItems[item.id]
.onClick .onClick
} }
> >
{contextMenuItems[item.id].label} {
</ContextMenuButton> contextMenuItems[item.id]
</HoverCard.Target> .label
<HoverCard.Dropdown> }
<Stack spacing={0}> </ContextMenuButton>
{contextMenuItems[ </HoverCard.Target>
item.id <HoverCard.Dropdown>
].children?.map((child) => ( <Stack spacing={0}>
<ContextMenuButton {contextMenuItems[
key={`sub-${child.id}`} item.id
disabled={child.disabled} ].children?.map((child) => (
leftIcon={child.leftIcon} <ContextMenuButton
rightIcon={child.rightIcon} key={`sub-${child.id}`}
onClick={child.onClick} leftIcon={
> child.leftIcon
{child.label} }
</ContextMenuButton> rightIcon={
))} child.rightIcon
</Stack> }
</HoverCard.Dropdown> onClick={child.onClick}
</HoverCard> >
) : ( {child.label}
<ContextMenuButton </ContextMenuButton>
disabled={item.disabled} ))}
leftIcon={ </Stack>
contextMenuItems[item.id].leftIcon </HoverCard.Dropdown>
} </HoverCard>
rightIcon={ ) : (
contextMenuItems[item.id].rightIcon <ContextMenuButton
} leftIcon={
onClick={contextMenuItems[item.id].onClick} contextMenuItems[item.id].leftIcon
> }
{contextMenuItems[item.id].label} rightIcon={
</ContextMenuButton> contextMenuItems[item.id].rightIcon
)} }
onClick={
contextMenuItems[item.id].onClick
}
>
{contextMenuItems[item.id].label}
</ContextMenuButton>
)}
{item.divider && ( {item.divider && (
<Divider <Divider
key={`context-menu-divider-${item.id}`} key={`context-menu-divider-${item.id}`}
color="rgb(62, 62, 62)" color="rgb(62, 62, 62)"
size="sm" size="sm"
/> />
)} )}
</Fragment> </Fragment>
)
); );
})} })}
</Stack> </Stack>

View file

@ -33,7 +33,8 @@ export type ContextMenuItemType =
| 'moveToBottomOfQueue' | 'moveToBottomOfQueue'
| 'moveToTopOfQueue' | 'moveToTopOfQueue'
| 'removeFromQueue' | 'removeFromQueue'
| 'deselectAll'; | 'deselectAll'
| 'showDetails';
export type SetContextMenuItems = { export type SetContextMenuItems = {
children?: boolean; children?: boolean;

View file

@ -0,0 +1,237 @@
import { Group, Table } from '@mantine/core';
import dayjs from 'dayjs';
import { RiCheckFill, RiCloseFill } from 'react-icons/ri';
import { TFunction, useTranslation } from 'react-i18next';
import { ReactNode } from 'react';
import { Album, AlbumArtist, AnyLibraryItem, LibraryItem, Song } from '/@/renderer/api/types';
import { formatDurationString } from '/@/renderer/utils';
import { formatSizeString } from '/@/renderer/utils/format-size-string';
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
import { Rating, Spoiler } from '/@/renderer/components';
import { sanitize } from '/@/renderer/utils/sanitize';
export type ItemDetailsModalProps = {
item: Album | AlbumArtist | Song;
};
type ItemDetailRow<T> = {
key?: keyof T;
label: string;
postprocess?: string[];
render?: (item: T) => ReactNode;
};
const handleRow = <T extends AnyLibraryItem>(t: TFunction, item: T, rule: ItemDetailRow<T>) => {
let value: ReactNode;
if (rule.render) {
value = rule.render(item);
} else {
const prop = item[rule.key!];
value = prop !== undefined && prop !== null ? String(prop) : null;
}
if (!value) return null;
return (
<tr key={rule.label}>
<td>{t(rule.label, { postProcess: rule.postprocess || 'sentenceCase' })}</td>
<td>{value}</td>
</tr>
);
};
const formatArtists = (item: Album | Song) =>
item.albumArtists?.map((artist) => artist.name).join(' · ');
const formatComment = (item: Album | Song) =>
item.comment ? <Spoiler maxHeight={50}>{replaceURLWithHTMLLinks(item.comment)}</Spoiler> : null;
const formatDate = (key: string | null) => (key ? dayjs(key).fromNow() : '');
const formatGenre = (item: Album | AlbumArtist | Song) =>
item.genres?.map((genre) => genre.name).join(' · ');
const formatRating = (item: Album | AlbumArtist | Song) =>
item.userRating !== null ? (
<Rating
readOnly
value={item.userRating}
/>
) : null;
const BoolField = (key: boolean) =>
key ? <RiCheckFill size="1.1rem" /> : <RiCloseFill size="1.1rem" />;
const AlbumPropertyMapping: ItemDetailRow<Album>[] = [
{ key: 'name', label: 'common.title' },
{ label: 'entity.albumArtist_one', render: formatArtists },
{ label: 'entity.genre_other', render: formatGenre },
{
label: 'common.duration',
render: (album) => album.duration && formatDurationString(album.duration),
},
{ key: 'releaseYear', label: 'filter.releaseYear' },
{ key: 'songCount', label: 'filter.songCount' },
{ label: 'filter.isCompilation', render: (album) => BoolField(album.isCompilation || false) },
{
key: 'size',
label: 'common.size',
render: (album) => album.size && formatSizeString(album.size),
},
{
label: 'common.favorite',
render: (album) => BoolField(album.userFavorite),
},
{ label: 'common.rating', render: formatRating },
{ key: 'playCount', label: 'filter.playCount' },
{
label: 'filter.lastPlayed',
render: (song) => formatDate(song.lastPlayedAt),
},
{
label: 'common.modified',
render: (song) => formatDate(song.updatedAt),
},
{ label: 'filter.comment', render: formatComment },
{
label: 'common.mbid',
postprocess: [],
render: (album) =>
album.mbzId ? (
<a
href={`https://musicbrainz.org/release/${album.mbzId}`}
rel="noopener noreferrer"
target="_blank"
>
{album.mbzId}
</a>
) : null,
},
];
const AlbumArtistPropertyMapping: ItemDetailRow<AlbumArtist>[] = [
{ key: 'name', label: 'common.name' },
{ label: 'entity.genre_other', render: formatGenre },
{
label: 'common.duration',
render: (artist) => artist.duration && formatDurationString(artist.duration),
},
{ key: 'songCount', label: 'filter.songCount' },
{
label: 'common.favorite',
render: (artist) => BoolField(artist.userFavorite),
},
{ label: 'common.rating', render: formatRating },
{ key: 'playCount', label: 'filter.playCount' },
{
label: 'filter.lastPlayed',
render: (song) => formatDate(song.lastPlayedAt),
},
{
label: 'common.mbid',
postprocess: [],
render: (artist) =>
artist.mbz ? (
<a
href={`https://musicbrainz.org/artist/${artist.mbz}`}
rel="noopener noreferrer"
target="_blank"
>
{artist.mbz}
</a>
) : null,
},
{
label: 'common.biography',
render: (artist) =>
artist.biography ? (
<Spoiler
dangerouslySetInnerHTML={{ __html: sanitize(artist.biography) }}
maxHeight={50}
/>
) : null,
},
];
const SongPropertyMapping: ItemDetailRow<Song>[] = [
{ key: 'name', label: 'common.title' },
{ key: 'path', label: 'common.path' },
{ label: 'entity.albumArtist_one', render: formatArtists },
{ key: 'album', label: 'entity.album_one' },
{ key: 'discNumber', label: 'common.disc' },
{ key: 'trackNumber', label: 'common.trackNumber' },
{ key: 'releaseYear', label: 'filter.releaseYear' },
{ label: 'entity.genre_other', render: formatGenre },
{
label: 'common.duration',
render: (song) => formatDurationString(song.duration),
},
{ label: 'filter.isCompilation', render: (song) => BoolField(song.compilation || false) },
{ key: 'container', label: 'common.codec' },
{ key: 'bitRate', label: 'common.bitrate', render: (song) => `${song.bitRate} kbps` },
{ key: 'channels', label: 'common.channel_other' },
{ key: 'size', label: 'common.size', render: (song) => formatSizeString(song.size) },
{
label: 'common.favorite',
render: (song) => BoolField(song.userFavorite),
},
{ label: 'common.rating', render: formatRating },
{ key: 'playCount', label: 'filter.playCount' },
{
label: 'filter.lastPlayed',
render: (song) => formatDate(song.lastPlayedAt),
},
{
label: 'common.modified',
render: (song) => formatDate(song.updatedAt),
},
{
label: 'common.albumGain',
render: (song) => (song.gain?.album !== undefined ? `${song.gain.album} dB` : null),
},
{
label: 'common.trackGain',
render: (song) => (song.gain?.track !== undefined ? `${song.gain.track} dB` : null),
},
{
label: 'common.albumPeak',
render: (song) => (song.peak?.album !== undefined ? `${song.peak.album}` : null),
},
{
label: 'common.trackPeak',
render: (song) => (song.peak?.track !== undefined ? `${song.peak.track}` : null),
},
{ label: 'filter.comment', render: formatComment },
];
export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => {
const { t } = useTranslation();
let body: ReactNode;
switch (item.itemType) {
case LibraryItem.ALBUM:
body = AlbumPropertyMapping.map((rule) => handleRow(t, item, rule));
break;
case LibraryItem.ALBUM_ARTIST:
body = AlbumArtistPropertyMapping.map((rule) => handleRow(t, item, rule));
break;
case LibraryItem.SONG:
body = SongPropertyMapping.map((rule) => handleRow(t, item, rule));
break;
default:
body = null;
}
return (
<Group>
<Table
highlightOnHover
horizontalSpacing="sm"
verticalSpacing="sm"
>
<tbody>{body}</tbody>
</Table>
</Group>
);
};