From cb6c2092e52eb5cebbb933b327436b12fd3c62ab Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 9 Feb 2026 03:46:29 -0800 Subject: [PATCH] add links and additional data to metadata section --- .../columns/album-artist-column.tsx | 9 +- .../columns/artist-column.tsx | 9 +- .../item-detail-list/item-detail.module.css | 88 ++++++++ .../item-detail-list/item-detail.tsx | 204 ++++++++++++++---- .../albums/components/joined-artists.tsx | 11 +- 5 files changed, 277 insertions(+), 44 deletions(-) diff --git a/src/renderer/components/item-list/item-detail-list/columns/album-artist-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/album-artist-column.tsx index dcc13385e..28a3d2087 100644 --- a/src/renderer/components/item-list/item-detail-list/columns/album-artist-column.tsx +++ b/src/renderer/components/item-list/item-detail-list/columns/album-artist-column.tsx @@ -1,6 +1,9 @@ import { ItemDetailListCellProps } from './types'; -import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists'; +import { + JOINED_ARTISTS_MUTED_PROPS, + JoinedArtists, +} from '/@/renderer/features/albums/components/joined-artists'; import { Text } from '/@/shared/components/text/text'; export const AlbumArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => { @@ -21,8 +24,8 @@ export const AlbumArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProp ); }; diff --git a/src/renderer/components/item-list/item-detail-list/columns/artist-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/artist-column.tsx index 97fb9276e..b97bebec1 100644 --- a/src/renderer/components/item-list/item-detail-list/columns/artist-column.tsx +++ b/src/renderer/components/item-list/item-detail-list/columns/artist-column.tsx @@ -1,6 +1,9 @@ import { ItemDetailListCellProps } from './types'; -import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists'; +import { + JOINED_ARTISTS_MUTED_PROPS, + JoinedArtists, +} from '/@/renderer/features/albums/components/joined-artists'; import { Text } from '/@/shared/components/text/text'; export const ArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => { @@ -21,8 +24,8 @@ export const ArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => ); }; diff --git a/src/renderer/components/item-list/item-detail-list/item-detail.module.css b/src/renderer/components/item-list/item-detail-list/item-detail.module.css index 5b0a51eac..0a849c83d 100644 --- a/src/renderer/components/item-list/item-detail-list/item-detail.module.css +++ b/src/renderer/components/item-list/item-detail-list/item-detail.module.css @@ -55,6 +55,47 @@ } } } + + &:hover .favorite-badge, + &:hover .rating-badge { + opacity: 0; + } +} + +.favorite-badge { + position: absolute; + top: -50px; + left: -50px; + z-index: 1; + width: 80px; + height: 80px; + pointer-events: none; + background-color: var(--theme-colors-primary); + box-shadow: 0 0 10px 8px rgb(0 0 0 / 80%); + opacity: 1; + transform: rotate(-45deg); + transition: opacity 0.2s ease-in-out; +} + +.rating-badge { + position: absolute; + top: var(--theme-spacing-sm); + right: var(--theme-spacing-sm); + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + padding: var(--theme-spacing-xs) var(--theme-spacing-sm); + font-size: var(--theme-font-size-md); + font-weight: 600; + color: var(--theme-colors-foreground); + text-shadow: 0 1px 2px rgb(0 0 0 / 80%); + pointer-events: none; + background-color: var(--theme-colors-primary); + border-radius: var(--theme-radius-md); + box-shadow: 0 2px 8px rgb(0 0 0 / 50%); + opacity: 1; + transition: opacity 0.2s ease-in-out; } .row .image { @@ -76,11 +117,58 @@ .row .title { font-weight: 500; + color: inherit; + text-decoration: none; +} + +.row .title:hover { + text-decoration: underline; } .row .artist { font-size: var(--theme-font-size-sm); color: var(--theme-colors-foreground-muted); + text-decoration: none; +} + +.row .artist-plain-text:hover { + text-decoration: underline; +} + +.row .metadata-link { + color: inherit; + text-decoration: none; +} + +.row .metadata-link:hover { + text-decoration: underline; +} + +.row .metadata-extra { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-3xs); + align-items: center; + width: 100%; + margin-top: var(--theme-spacing-3xs); + font-size: var(--theme-font-size-sm); + color: var(--theme-colors-foreground-muted); + text-align: center; +} + +.row .metadata-line { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + text-wrap-style: balance; + white-space: nowrap; +} + +.row .metadata-line-clamp-2 { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + white-space: normal; } .row .right { diff --git a/src/renderer/components/item-list/item-detail-list/item-detail.tsx b/src/renderer/components/item-list/item-detail-list/item-detail.tsx index 8c97a2e25..d295d7d37 100644 --- a/src/renderer/components/item-list/item-detail-list/item-detail.tsx +++ b/src/renderer/components/item-list/item-detail-list/item-detail.tsx @@ -3,7 +3,17 @@ import clsx from 'clsx'; import throttle from 'lodash/throttle'; import { AnimatePresence } from 'motion/react'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; -import { memo, type ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Fragment, + memo, + type ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; import { generatePath, Link } from 'react-router'; import { List, RowComponentProps, useDynamicRowHeight } from 'react-window-v2'; @@ -31,12 +41,18 @@ import { import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state'; import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types'; import { albumQueries } from '/@/renderer/features/albums/api/album-api'; +import { + JOINED_ARTISTS_MUTED_PROPS, + JoinedArtists, +} from '/@/renderer/features/albums/components/joined-artists'; import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; import { AppRoute } from '/@/renderer/router/routes'; -import { useSettingsStore } from '/@/renderer/store'; +import { useSettingsStore, useShowRatings } from '/@/renderer/store'; +import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils'; import { Skeleton } from '/@/shared/components/skeleton/skeleton'; +import { Text } from '/@/shared/components/text/text'; import { Album, LibraryItem, Song } from '/@/shared/types/domain-types'; import { ItemListKey, TableColumn } from '/@/shared/types/types'; @@ -277,6 +293,151 @@ const TrackRow = memo( TrackRow.displayName = 'TrackRow'; +interface MetadataSectionProps { + controls?: ItemControls; + internalState: ItemListStateActions; + item: Album; +} + +const MetadataSection = memo( + ({ controls, internalState, item }: MetadataSectionProps) => { + const { t } = useTranslation(); + const showRatings = useShowRatings(); + const [isImageHovered, setIsImageHovered] = useState(false); + const [isMetadataHovered, setIsMetadataHovered] = useState(false); + + const isFavorite = item.userFavorite ?? false; + const userRating = item.userRating ?? null; + const hasRating = showRatings && userRating !== null && userRating > 0; + + const metadataExtra = useMemo(() => { + const parts: Array<{ content: React.ReactNode; key: string }> = []; + const releaseStr = + (item.releaseDate && formatDateAbsoluteUTC(item.releaseDate)) || + (item.releaseYear != null ? String(item.releaseYear) : ''); + if (releaseStr) parts.push({ content: releaseStr, key: 'release' }); + const genres = item.genres?.filter((g) => g.name) ?? []; + if (genres.length > 0) { + parts.push({ + content: genres.map((genre, i) => ( + + {i > 0 && ', '} + + {genre.name} + + + )), + key: 'genres', + }); + } + const songCount = item.songCount ?? 0; + const duration = item.duration ?? 0; + const tracksAndDurationParts: string[] = []; + if (songCount > 0) { + tracksAndDurationParts.push(t('entity.trackWithCount', { count: songCount })); + } + if (duration > 0) { + tracksAndDurationParts.push(formatDurationString(duration)); + } + const tracksAndDuration = tracksAndDurationParts.join(' ยท '); + if (tracksAndDuration) { + parts.push({ content: tracksAndDuration, key: 'tracks' }); + } + return parts.length > 0 ? parts : null; + }, [item, t]); + + const hasArtist = + (item.albumArtistName?.trim()?.length ?? 0) > 0 || (item.albumArtists?.length ?? 0) > 0; + + return ( +
setIsMetadataHovered(true)} + onMouseLeave={() => setIsMetadataHovered(false)} + > + setIsImageHovered(true)} + onMouseLeave={() => setIsImageHovered(false)} + state={{ item }} + to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { + albumId: item.id, + })} + > + + {isFavorite &&
} + {hasRating &&
{userRating}
} + + {controls && isImageHovered && ( + + )} + + + + {item.name} + +
+ {!hasArtist ? ( + <>  + ) : !isMetadataHovered ? ( + + {item.albumArtistName ?? ''} + + ) : ( + + )} +
+ {metadataExtra && metadataExtra.length > 0 && ( +
+ {metadataExtra.map((part) => ( +
+ {part.content} +
+ ))} +
+ )} +
+ ); + }, + (prev, next) => prev.item === next.item, +); + +MetadataSection.displayName = 'MetadataSection'; + type RowContentProps = Omit, 'style'>; const RowContent = memo( @@ -296,7 +457,6 @@ const RowContent = memo( trackColumns, trackTableSize, }: RowContentProps) => { - const [showControls, setShowControls] = useState(false); const item = useMemo(() => { if (getItem) { return getItem(index) as Album | undefined; @@ -372,39 +532,11 @@ const RowContent = memo( return ( <>
-
- setShowControls(true)} - onMouseLeave={() => setShowControls(false)} - state={{ item }} - to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { - albumId: item.id, - })} - > - - - {controls && showControls && ( - - )} - - -
{item.name}
-
{item.albumArtistName}
-
+
diff --git a/src/renderer/features/albums/components/joined-artists.tsx b/src/renderer/features/albums/components/joined-artists.tsx index 4387d96b3..4466c95e1 100644 --- a/src/renderer/features/albums/components/joined-artists.tsx +++ b/src/renderer/features/albums/components/joined-artists.tsx @@ -1,10 +1,15 @@ -import { Fragment } from 'react'; +import { Fragment, memo } from 'react'; import { generatePath, Link } from 'react-router'; import { AppRoute } from '/@/renderer/router/routes'; import { Text, TextProps } from '/@/shared/components/text/text'; import { AlbumArtist, RelatedAlbumArtist, RelatedArtist } from '/@/shared/types/domain-types'; +export const JOINED_ARTISTS_MUTED_PROPS = { + linkProps: { fw: 400, isMuted: true }, + rootTextProps: { fw: 400, isMuted: true, size: 'sm' as const }, +} as const; + interface JoinedArtistsProps { artistName: string; artists: AlbumArtist[] | RelatedAlbumArtist[] | RelatedArtist[]; @@ -12,7 +17,7 @@ interface JoinedArtistsProps { rootTextProps?: Partial>; } -export const JoinedArtists = ({ +const JoinedArtistsComponent = ({ artistName, artists, linkProps, @@ -205,6 +210,8 @@ export const JoinedArtists = ({ ); }; +export const JoinedArtists = memo(JoinedArtistsComponent); + function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }