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, '\\$&');
}