From 49bd61de448bc2722e7eaf66e8b139ac87f0730d Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 1 Apr 2024 20:30:32 -0700 Subject: [PATCH 1/2] start --- src/i18n/locales/en.json | 3 +- .../context-menu/context-menu-items.tsx | 7 ++ .../context-menu/context-menu-provider.tsx | 43 ++++++++-- src/renderer/features/context-menu/events.ts | 3 +- .../components/item-details-modal.tsx | 80 +++++++++++++++++++ 5 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 src/renderer/features/item-details/components/item-details-modal.tsx diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b7965f778..eb4bb00c4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -306,7 +306,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/context-menu/context-menu-items.tsx b/src/renderer/features/context-menu/context-menu-items.tsx index e7ee6c486..632e0111d 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 = [ @@ -74,4 +80,5 @@ export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ { id: 'playLast' }, { divider: true, id: 'playNext' }, { id: 'deletePlaylist' }, + { divider: true, id: 'showDetails' }, ]; diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx index 34ebc325e..9b72119a6 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,17 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { ctx.tableApi?.deselectAll(); }, [ctx.tableApi]); + const handleOpenItemDetails = useCallback(() => { + const item = ctx.dataNodes ? ctx.dataNodes[0] : ctx.data[0]; + console.log(item); + + openModal({ + children: , + size: 'xl', + title: t('page.contextMenu.showDetails', { postProcess: 'titleCase' }), + }); + }, [ctx.data, ctx.dataNodes, t]); + const contextMenuItems: Record = useMemo(() => { return { addToFavorites: { @@ -775,20 +788,29 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { onClick: () => {}, rightIcon: , }, + showDetails: { + disabled: ctx.data?.length !== 1, + 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?.length, + handleOpenItemDetails, + handlePlay, handleUpdateRating, - openDeletePlaylistModal, - t, ]); const mergedRef = useMergedRef(ref, clickOutsideRef); @@ -827,7 +849,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { > { ].children?.map((child) => ( { ) : ( = { + key: keyof T; + label: string; + render?: (item: T) => ReactNode; +}; + +const SongPropertyMapping: ItemDetailRow[] = [ + { key: 'path', label: 'common.path' }, + { + key: 'albumArtists', + label: 'entity.albumArtist_one', + render: (song) => { + console.log(song); + return song.albumArtists.map((artist) => artist.name).join(' · '); + }, + }, + { key: 'album', label: 'entity.album_one' }, + { key: 'discNumber', label: 'common.disc' }, + { key: 'trackNumber', label: 'common.trackNumber' }, + { key: 'releaseYear', label: 'filter.releaseYear' }, + { + key: 'genres', + label: 'entity.genre_other', + render: (song) => song.genres?.map((genre) => genre.name).join(' · '), + }, + { + key: 'duration', + label: 'common.duration', + render: (song) => formatDurationString(song.duration), + }, + { 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' }, +]; + +export const SongDetailTable = (item: Song) => { + const { t } = useTranslation(); + + return SongPropertyMapping.map((rule) => ( + + {t(rule.label, { postProcess: 'sentenceCase' })} + {rule.render ? rule.render(item) : String(item[rule.key])} + + )); +}; + +export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => { + let body: ReactNode[]; + + switch (item.itemType) { + case LibraryItem.SONG: + body = SongDetailTable(item); + break; + default: + body = []; + } + + return ( + + + {body} +
+
+ ); +}; From 07b8078a3714dd829f7029b7ed52803deac65009 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:50:16 -0700 Subject: [PATCH 2/2] More details, don't show manage server when other modal --- src/i18n/locales/en.json | 5 + .../routes/action-required-route.tsx | 28 ++- .../context-menu/context-menu-items.tsx | 1 - .../context-menu/context-menu-provider.tsx | 160 +++++++------ .../components/item-details-modal.tsx | 223 +++++++++++++++--- 5 files changed, 290 insertions(+), 127 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 34fe93df3..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", 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 632e0111d..eecef08a3 100644 --- a/src/renderer/features/context-menu/context-menu-items.tsx +++ b/src/renderer/features/context-menu/context-menu-items.tsx @@ -80,5 +80,4 @@ export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ { id: 'playLast' }, { divider: true, id: 'playNext' }, { id: 'deletePlaylist' }, - { divider: true, id: 'showDetails' }, ]; diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx index 9b72119a6..914051e63 100644 --- a/src/renderer/features/context-menu/context-menu-provider.tsx +++ b/src/renderer/features/context-menu/context-menu-provider.tsx @@ -630,15 +630,14 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { }, [ctx.tableApi]); const handleOpenItemDetails = useCallback(() => { - const item = ctx.dataNodes ? ctx.dataNodes[0] : ctx.data[0]; - console.log(item); + const item = ctx.data[0]; openModal({ - children: , + children: , size: 'xl', title: t('page.contextMenu.showDetails', { postProcess: 'titleCase' }), }); - }, [ctx.data, ctx.dataNodes, t]); + }, [ctx.data, t]); const contextMenuItems: Record = useMemo(() => { return { @@ -789,7 +788,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { rightIcon: , }, showDetails: { - disabled: ctx.data?.length !== 1, + disabled: ctx.data?.length !== 1 || !ctx.data[0].itemType, id: 'showDetails', label: t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' }), leftIcon: , @@ -807,7 +806,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { handleRemoveFromFavorites, handleRemoveFromPlaylist, handleRemoveSelected, - ctx.data?.length, + ctx.data, handleOpenItemDetails, handlePlay, handleUpdateRating, @@ -841,81 +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/item-details/components/item-details-modal.tsx b/src/renderer/features/item-details/components/item-details-modal.tsx index d4f52c87e..89dcb1866 100644 --- a/src/renderer/features/item-details/components/item-details-modal.tsx +++ b/src/renderer/features/item-details/components/item-details-modal.tsx @@ -1,69 +1,226 @@ import { Group, Table } from '@mantine/core'; -import { AnyLibraryItem, LibraryItem, Song } from '/@/renderer/api/types'; -import { useTranslation } from 'react-i18next'; +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: AnyLibraryItem; + item: Album | AlbumArtist | Song; }; type ItemDetailRow = { - key: keyof T; + key?: keyof T; label: string; + postprocess?: string[]; render?: (item: T) => ReactNode; }; -const SongPropertyMapping: ItemDetailRow[] = [ - { key: 'path', label: 'common.path' }, +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), + }, { - key: 'albumArtists', - label: 'entity.albumArtist_one', - render: (song) => { - console.log(song); - return song.albumArtists.map((artist) => artist.name).join(' · '); - }, + 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 }, { - key: 'genres', - label: 'entity.genre_other', - render: (song) => song.genres?.map((genre) => genre.name).join(' · '), - }, - { - key: 'duration', 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' }, + { 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 SongDetailTable = (item: Song) => { - const { t } = useTranslation(); - - return SongPropertyMapping.map((rule) => ( - - {t(rule.label, { postProcess: 'sentenceCase' })} - {rule.render ? rule.render(item) : String(item[rule.key])} - - )); -}; - export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => { - let body: ReactNode[]; + 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 = SongDetailTable(item); + body = SongPropertyMapping.map((rule) => handleRow(t, item, rule)); break; default: - body = []; + body = null; } return (