[enhancement]: Show item details (#573)
* start * More details, don't show manage server when other modal
This commit is contained in:
parent
7bebe286d5
commit
197497df05
6 changed files with 365 additions and 82 deletions
|
@ -26,6 +26,8 @@
|
|||
"action_one": "action",
|
||||
"action_other": "actions",
|
||||
"add": "add",
|
||||
"albumGain": "album gain",
|
||||
"albumPeak": "album peak",
|
||||
"areYouSure": "are you sure?",
|
||||
"ascending": "ascending",
|
||||
"backward": "backward",
|
||||
|
@ -72,6 +74,7 @@
|
|||
"menu": "menu",
|
||||
"minimize": "minimize",
|
||||
"modified": "modified",
|
||||
"mbid": "MusicBrainz ID",
|
||||
"name": "name",
|
||||
"no": "no",
|
||||
"none": "none",
|
||||
|
@ -102,6 +105,8 @@
|
|||
"sortOrder": "order",
|
||||
"title": "title",
|
||||
"trackNumber": "track",
|
||||
"trackGain": "track gain",
|
||||
"trackPeak": "track peak",
|
||||
"unknown": "unknown",
|
||||
"version": "version",
|
||||
"year": "year",
|
||||
|
@ -306,7 +311,8 @@
|
|||
"removeFromFavorites": "$t(action.removeFromFavorites)",
|
||||
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
|
||||
"removeFromQueue": "$t(action.removeFromQueue)",
|
||||
"setRating": "$t(action.setRating)"
|
||||
"setRating": "$t(action.setRating)",
|
||||
"showDetails": "get info"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
|
|
|
@ -80,19 +80,23 @@ const ActionRequiredRoute = () => {
|
|||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Group
|
||||
noWrap
|
||||
position="center"
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
leftIcon={<RiEdit2Line />}
|
||||
variant="filled"
|
||||
onClick={handleManageServersModal}
|
||||
{!displayedCheck && (
|
||||
<Group
|
||||
noWrap
|
||||
position="center"
|
||||
>
|
||||
{t('page.appMenu.manageServers', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
<Button
|
||||
fullWidth
|
||||
leftIcon={<RiEdit2Line />}
|
||||
variant="filled"
|
||||
onClick={handleManageServersModal}
|
||||
>
|
||||
{t('page.appMenu.manageServers', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Center>
|
||||
|
|
|
@ -9,6 +9,7 @@ export const QUEUE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
|||
{ divider: true, id: 'removeFromFavorites' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
{ disabled: false, id: 'deselectAll' },
|
||||
{ divider: true, id: 'showDetails' },
|
||||
];
|
||||
|
||||
export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
|
@ -19,6 +20,7 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
|||
{ id: 'addToFavorites' },
|
||||
{ divider: true, id: 'removeFromFavorites' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
{ divider: true, id: 'showDetails' },
|
||||
];
|
||||
|
||||
export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
|
@ -30,6 +32,7 @@ export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
|||
{ id: 'addToFavorites' },
|
||||
{ divider: true, id: 'removeFromFavorites' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
{ divider: true, id: 'showDetails' },
|
||||
];
|
||||
|
||||
export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
|
@ -40,6 +43,7 @@ export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
|||
{ id: 'addToFavorites' },
|
||||
{ divider: true, id: 'removeFromFavorites' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
{ divider: true, id: 'showDetails' },
|
||||
];
|
||||
|
||||
export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
|
@ -50,6 +54,7 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
|||
{ id: 'addToFavorites' },
|
||||
{ id: 'removeFromFavorites' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
{ divider: true, id: 'showDetails' },
|
||||
];
|
||||
|
||||
export const GENRE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
|
@ -67,6 +72,7 @@ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
|||
{ id: 'addToFavorites' },
|
||||
{ divider: true, id: 'removeFromFavorites' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
{ divider: true, id: 'showDetails' },
|
||||
];
|
||||
|
||||
export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
|
|
|
@ -25,6 +25,7 @@ import {
|
|||
RiPlayListAddFill,
|
||||
RiStarFill,
|
||||
RiCloseCircleLine,
|
||||
RiInformationFill,
|
||||
} from 'react-icons/ri';
|
||||
import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types';
|
||||
import {
|
||||
|
@ -53,6 +54,7 @@ import {
|
|||
} from '/@/renderer/store';
|
||||
import { usePlaybackType } from '/@/renderer/store/settings.store';
|
||||
import { Play, PlaybackType } from '/@/renderer/types';
|
||||
import { ItemDetailsModal } from '/@/renderer/features/item-details/components/item-details-modal';
|
||||
|
||||
type ContextMenuContextProps = {
|
||||
closeContextMenu: () => void;
|
||||
|
@ -627,6 +629,16 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||
ctx.tableApi?.deselectAll();
|
||||
}, [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(() => {
|
||||
return {
|
||||
addToFavorites: {
|
||||
|
@ -775,20 +787,29 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||
onClick: () => {},
|
||||
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,
|
||||
handleAddToPlaylist,
|
||||
openDeletePlaylistModal,
|
||||
handleDeselectAll,
|
||||
handleMoveToBottom,
|
||||
handleMoveToTop,
|
||||
handlePlay,
|
||||
handleRemoveFromFavorites,
|
||||
handleRemoveFromPlaylist,
|
||||
handleRemoveSelected,
|
||||
ctx.data,
|
||||
handleOpenItemDetails,
|
||||
handlePlay,
|
||||
handleUpdateRating,
|
||||
openDeletePlaylistModal,
|
||||
t,
|
||||
]);
|
||||
|
||||
const mergedRef = useMergedRef(ref, clickOutsideRef);
|
||||
|
@ -819,72 +840,80 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||
>
|
||||
{ctx.menuItems?.map((item) => {
|
||||
return (
|
||||
<Fragment key={`context-menu-${item.id}`}>
|
||||
{item.children ? (
|
||||
<HoverCard
|
||||
offset={5}
|
||||
position="right"
|
||||
>
|
||||
<HoverCard.Target>
|
||||
<ContextMenuButton
|
||||
disabled={item.disabled}
|
||||
leftIcon={
|
||||
contextMenuItems[item.id]
|
||||
.leftIcon
|
||||
}
|
||||
rightIcon={
|
||||
contextMenuItems[item.id]
|
||||
.rightIcon
|
||||
}
|
||||
onClick={
|
||||
contextMenuItems[item.id]
|
||||
.onClick
|
||||
}
|
||||
>
|
||||
{contextMenuItems[item.id].label}
|
||||
</ContextMenuButton>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Stack spacing={0}>
|
||||
{contextMenuItems[
|
||||
item.id
|
||||
].children?.map((child) => (
|
||||
<ContextMenuButton
|
||||
key={`sub-${child.id}`}
|
||||
disabled={child.disabled}
|
||||
leftIcon={child.leftIcon}
|
||||
rightIcon={child.rightIcon}
|
||||
onClick={child.onClick}
|
||||
>
|
||||
{child.label}
|
||||
</ContextMenuButton>
|
||||
))}
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<ContextMenuButton
|
||||
disabled={item.disabled}
|
||||
leftIcon={
|
||||
contextMenuItems[item.id].leftIcon
|
||||
}
|
||||
rightIcon={
|
||||
contextMenuItems[item.id].rightIcon
|
||||
}
|
||||
onClick={contextMenuItems[item.id].onClick}
|
||||
>
|
||||
{contextMenuItems[item.id].label}
|
||||
</ContextMenuButton>
|
||||
)}
|
||||
!contextMenuItems[item.id].disabled && (
|
||||
<Fragment key={`context-menu-${item.id}`}>
|
||||
{item.children ? (
|
||||
<HoverCard
|
||||
offset={5}
|
||||
position="right"
|
||||
>
|
||||
<HoverCard.Target>
|
||||
<ContextMenuButton
|
||||
leftIcon={
|
||||
contextMenuItems[item.id]
|
||||
.leftIcon
|
||||
}
|
||||
rightIcon={
|
||||
contextMenuItems[item.id]
|
||||
.rightIcon
|
||||
}
|
||||
onClick={
|
||||
contextMenuItems[item.id]
|
||||
.onClick
|
||||
}
|
||||
>
|
||||
{
|
||||
contextMenuItems[item.id]
|
||||
.label
|
||||
}
|
||||
</ContextMenuButton>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Stack spacing={0}>
|
||||
{contextMenuItems[
|
||||
item.id
|
||||
].children?.map((child) => (
|
||||
<ContextMenuButton
|
||||
key={`sub-${child.id}`}
|
||||
leftIcon={
|
||||
child.leftIcon
|
||||
}
|
||||
rightIcon={
|
||||
child.rightIcon
|
||||
}
|
||||
onClick={child.onClick}
|
||||
>
|
||||
{child.label}
|
||||
</ContextMenuButton>
|
||||
))}
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<ContextMenuButton
|
||||
leftIcon={
|
||||
contextMenuItems[item.id].leftIcon
|
||||
}
|
||||
rightIcon={
|
||||
contextMenuItems[item.id].rightIcon
|
||||
}
|
||||
onClick={
|
||||
contextMenuItems[item.id].onClick
|
||||
}
|
||||
>
|
||||
{contextMenuItems[item.id].label}
|
||||
</ContextMenuButton>
|
||||
)}
|
||||
|
||||
{item.divider && (
|
||||
<Divider
|
||||
key={`context-menu-divider-${item.id}`}
|
||||
color="rgb(62, 62, 62)"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
{item.divider && (
|
||||
<Divider
|
||||
key={`context-menu-divider-${item.id}`}
|
||||
color="rgb(62, 62, 62)"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
|
|
@ -33,7 +33,8 @@ export type ContextMenuItemType =
|
|||
| 'moveToBottomOfQueue'
|
||||
| 'moveToTopOfQueue'
|
||||
| 'removeFromQueue'
|
||||
| 'deselectAll';
|
||||
| 'deselectAll'
|
||||
| 'showDetails';
|
||||
|
||||
export type SetContextMenuItems = {
|
||||
children?: boolean;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
Reference in a new issue