diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a1e0ab5cd..c8f785acc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/features/action-required/routes/action-required-route.tsx b/src/renderer/features/action-required/routes/action-required-route.tsx index afe3b28d7..81a067339 100644 --- a/src/renderer/features/action-required/routes/action-required-route.tsx +++ b/src/renderer/features/action-required/routes/action-required-route.tsx @@ -80,19 +80,23 @@ const ActionRequiredRoute = () => { )} - - - + + + )} diff --git a/src/renderer/features/context-menu/context-menu-items.tsx b/src/renderer/features/context-menu/context-menu-items.tsx index e7ee6c486..eecef08a3 100644 --- a/src/renderer/features/context-menu/context-menu-items.tsx +++ b/src/renderer/features/context-menu/context-menu-items.tsx @@ -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 = [ diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx index 34ebc325e..914051e63 100644 --- a/src/renderer/features/context-menu/context-menu-provider.tsx +++ b/src/renderer/features/context-menu/context-menu-provider.tsx @@ -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: , + size: 'xl', + title: t('page.contextMenu.showDetails', { postProcess: 'titleCase' }), + }); + }, [ctx.data, t]); + const contextMenuItems: Record = useMemo(() => { return { addToFavorites: { @@ -775,20 +787,29 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { onClick: () => {}, rightIcon: , }, + showDetails: { + disabled: ctx.data?.length !== 1 || !ctx.data[0].itemType, + id: 'showDetails', + label: t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' }), + leftIcon: , + 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 ( - - {item.children ? ( - - - - {contextMenuItems[item.id].label} - - - - - {contextMenuItems[ - item.id - ].children?.map((child) => ( - - {child.label} - - ))} - - - - ) : ( - - {contextMenuItems[item.id].label} - - )} - - {item.divider && ( - - )} - + !contextMenuItems[item.id].disabled && ( + + {item.children ? ( + + + + { + contextMenuItems[item.id] + .label + } + + + + + {contextMenuItems[ + item.id + ].children?.map((child) => ( + + {child.label} + + ))} + + + + ) : ( + + {contextMenuItems[item.id].label} + + )} + + {item.divider && ( + + )} + + ) ); })} diff --git a/src/renderer/features/context-menu/events.ts b/src/renderer/features/context-menu/events.ts index dedcf8193..ac3a0d8af 100644 --- a/src/renderer/features/context-menu/events.ts +++ b/src/renderer/features/context-menu/events.ts @@ -33,7 +33,8 @@ export type ContextMenuItemType = | 'moveToBottomOfQueue' | 'moveToTopOfQueue' | 'removeFromQueue' - | 'deselectAll'; + | 'deselectAll' + | 'showDetails'; export type SetContextMenuItems = { children?: boolean; diff --git a/src/renderer/features/item-details/components/item-details-modal.tsx b/src/renderer/features/item-details/components/item-details-modal.tsx new file mode 100644 index 000000000..89dcb1866 --- /dev/null +++ b/src/renderer/features/item-details/components/item-details-modal.tsx @@ -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 = { + key?: keyof T; + label: string; + postprocess?: string[]; + render?: (item: T) => ReactNode; +}; + +const handleRow = (t: TFunction, item: T, rule: ItemDetailRow) => { + 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 ( + + {t(rule.label, { postProcess: rule.postprocess || 'sentenceCase' })} + {value} + + ); +}; + +const formatArtists = (item: Album | Song) => + item.albumArtists?.map((artist) => artist.name).join(' · '); + +const formatComment = (item: Album | Song) => + item.comment ? {replaceURLWithHTMLLinks(item.comment)} : 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 ? ( + + ) : null; + +const BoolField = (key: boolean) => + key ? : ; + +const AlbumPropertyMapping: ItemDetailRow[] = [ + { 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 ? ( + + {album.mbzId} + + ) : null, + }, +]; + +const AlbumArtistPropertyMapping: ItemDetailRow[] = [ + { 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 ? ( + + {artist.mbz} + + ) : null, + }, + { + label: 'common.biography', + render: (artist) => + artist.biography ? ( + + ) : null, + }, +]; + +const SongPropertyMapping: ItemDetailRow[] = [ + { 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 ( + + + {body} +
+
+ ); +};