From f39a7f8d6f7e21f9d3f1db28d5c1d2144356ef80 Mon Sep 17 00:00:00 2001 From: Jeff <42182408+jeffvli@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:56:08 -0800 Subject: [PATCH] Add album detail list view (#1681) --- src/i18n/locales/en.json | 1 + .../api/jellyfin/jellyfin-controller.ts | 6 +- .../item-detail/item-detail.module.css | 84 - .../components/item-detail/item-detail.tsx | 146 -- .../item-list/helpers/item-list-controls.ts | 13 +- .../helpers/use-item-list-column-reorder.ts | 28 +- .../helpers/use-item-list-column-resize.ts | 30 +- .../helpers/use-item-list-scroll-persist.ts | 21 +- .../columns/actions-column.tsx | 38 + .../columns/album-artist-column.tsx | 23 + .../item-detail-list/columns/album-column.tsx | 3 + .../columns/artist-column.tsx | 23 + .../columns/bit-depth-column.tsx | 3 + .../columns/bit-rate-column.tsx | 4 + .../item-detail-list/columns/bpm-column.tsx | 3 + .../columns/channels-column.tsx | 4 + .../item-detail-list/columns/codec-column.tsx | 3 + .../columns/comment-column.tsx | 3 + .../columns/composer-column.tsx | 7 + .../columns/date-added-column.tsx | 6 + .../columns/default-column.tsx | 11 + .../columns/disc-number-column.tsx | 3 + .../columns/duration-column.tsx | 5 + .../columns/favorite-column.tsx | 54 + .../columns/genre-badge-column.module.css | 12 + .../columns/genre-badge-column.tsx | 46 + .../item-detail-list/columns/genre-column.tsx | 40 + .../columns/image-column.module.css | 50 + .../item-detail-list/columns/image-column.tsx | 71 + .../item-detail-list/columns/index.ts | 127 ++ .../columns/last-played-column.tsx | 6 + .../item-detail-list/columns/path-column.tsx | 3 + .../columns/play-count-column.tsx | 4 + .../columns/rating-column.tsx | 29 + .../columns/release-date-column.tsx | 6 + .../columns/row-index-column.module.css | 5 + .../columns/row-index-column.tsx | 23 + .../columns/sample-rate-column.tsx | 4 + .../item-detail-list/columns/size-column.tsx | 6 + .../columns/title-artist-column.tsx | 18 + .../columns/title-column.module.css | 3 + .../item-detail-list/columns/title-column.tsx | 18 + .../columns/title-combined-column.tsx | 18 + .../columns/track-number-column.tsx | 7 + .../item-detail-list/columns/types.ts | 14 + .../item-detail-list/columns/year-column.tsx | 4 + .../item-detail-list.module.css | 556 +++++++ .../item-detail-list/item-detail-list.tsx | 1380 +++++++++++++++++ .../item-list/item-detail-list/utils.ts | 65 + .../item-table-list/columns/image-column.tsx | 1 + .../columns/title-combined-column.tsx | 2 + .../hooks/use-item-drag-drop-state.tsx | 10 +- .../item-table-list-column.module.css | 10 + .../item-table-list-column.tsx | 6 +- src/renderer/components/item-list/types.ts | 2 +- .../albums/components/album-detail-header.tsx | 4 +- .../albums/components/album-list-content.tsx | 49 +- .../components/album-list-header-filters.tsx | 14 +- .../components/album-list-infinite-detail.tsx | 69 + .../album-list-paginated-detail.tsx | 80 + .../albums/components/joined-artists.tsx | 27 +- .../components/album-artist-detail-header.tsx | 7 +- .../player/hooks/use-is-current-song.ts | 14 +- .../components/display-type-toggle-button.tsx | 7 +- .../shared/components/list-config-menu.tsx | 33 + .../list-display-type-toggle-button.tsx | 32 +- .../shared/components/table-config.tsx | 142 +- .../mutations/favorite-optimistic-updates.ts | 24 + .../mutations/rating-optimistic-updates.ts | 24 + src/renderer/store/index.ts | 1 + src/renderer/store/scroll.store.ts | 16 + src/renderer/store/settings.store.ts | 60 +- src/renderer/utils/format.tsx | 29 +- src/shared/api/subsonic/subsonic-normalize.ts | 9 +- src/shared/api/utils.ts | 2 +- src/shared/components/icon/icon.tsx | 2 + .../read-only-rating.module.css | 31 + .../read-only-rating/read-only-rating.tsx | 81 + src/shared/types/types.ts | 1 + 79 files changed, 3462 insertions(+), 364 deletions(-) delete mode 100644 src/renderer/components/item-detail/item-detail.module.css delete mode 100644 src/renderer/components/item-detail/item-detail.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/actions-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/album-artist-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/album-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/artist-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/bit-depth-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/bit-rate-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/bpm-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/channels-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/codec-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/comment-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/composer-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/date-added-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/default-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/disc-number-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/duration-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/favorite-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/genre-badge-column.module.css create mode 100644 src/renderer/components/item-list/item-detail-list/columns/genre-badge-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/genre-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/image-column.module.css create mode 100644 src/renderer/components/item-list/item-detail-list/columns/image-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/index.ts create mode 100644 src/renderer/components/item-list/item-detail-list/columns/last-played-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/path-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/play-count-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/rating-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/release-date-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/row-index-column.module.css create mode 100644 src/renderer/components/item-list/item-detail-list/columns/row-index-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/sample-rate-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/size-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/title-artist-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/title-column.module.css create mode 100644 src/renderer/components/item-list/item-detail-list/columns/title-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/title-combined-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/track-number-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/columns/types.ts create mode 100644 src/renderer/components/item-list/item-detail-list/columns/year-column.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/item-detail-list.module.css create mode 100644 src/renderer/components/item-list/item-detail-list/item-detail-list.tsx create mode 100644 src/renderer/components/item-list/item-detail-list/utils.ts create mode 100644 src/renderer/features/albums/components/album-list-infinite-detail.tsx create mode 100644 src/renderer/features/albums/components/album-list-paginated-detail.tsx create mode 100644 src/renderer/store/scroll.store.ts create mode 100644 src/shared/components/read-only-rating/read-only-rating.module.css create mode 100644 src/shared/components/read-only-rating/read-only-rating.tsx diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0c0e22f5e..b611caa87 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1145,6 +1145,7 @@ "year": "$t(common.year)" }, "view": { + "detail": "detail", "grid": "grid", "list": "list", "table": "table" diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 20dc2570e..aaa37852b 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -57,7 +57,7 @@ const JF_FIELDS = { ALBUM_ARTIST_DETAIL: 'Genres, Overview, SortName, ProviderIds', ALBUM_ARTIST_LIST: 'Genres, DateCreated, ExternalUrls, Overview, SortName, ProviderIds', ALBUM_DETAIL: 'Genres, DateCreated, ChildCount, People, Tags, ProviderIds', - ALBUM_LIST: 'People, Tags, Studios, SortName, UserData, ProviderIds', + ALBUM_LIST: 'People, Tags, Studios, SortName, UserData, ProviderIds, ChildCount', FOLDER: 'Genres, DateCreated, MediaSources, UserData, ParentId', GENRE: 'ItemCounts', PLAYLIST_DETAIL: 'Genres, DateCreated, MediaSources, ChildCount, ParentId, SortName', @@ -1112,7 +1112,7 @@ export const JellyfinController: InternalControllerEndpoint = { GenreIds: query.genreIds?.join(','), IncludeItemTypes: 'Audio', IsFavorite: query.favorite, - Limit: query.limit, + Limit: query.limit === -1 ? undefined : query.limit, ParentId: getLibraryId(query.musicFolderId), Recursive: true, SearchTerm: query.searchTerm, @@ -1147,7 +1147,7 @@ export const JellyfinController: InternalControllerEndpoint = { GenreIds: query.genreIds?.join(','), IncludeItemTypes: 'Audio', IsFavorite: query.favorite, - Limit: query.limit, + Limit: query.limit === -1 ? undefined : query.limit, ParentId: getLibraryId(query.musicFolderId), Recursive: true, SearchTerm: query.searchTerm, diff --git a/src/renderer/components/item-detail/item-detail.module.css b/src/renderer/components/item-detail/item-detail.module.css deleted file mode 100644 index 1e359b6a1..000000000 --- a/src/renderer/components/item-detail/item-detail.module.css +++ /dev/null @@ -1,84 +0,0 @@ -.container { - display: grid; - grid-template-rows: 1fr; - grid-template-columns: auto minmax(0, 1fr); - gap: var(--theme-spacing-sm); - width: 100%; - height: 100%; - padding: var(--theme-spacing-sm); - container-type: inline-size; - background: var(--theme-colors-surface); - border-radius: var(--theme-radius-md); - - @container (min-width: 500px) { - grid-template-columns: minmax(0, 1fr); - } -} - -.image-container { - position: relative; - display: none; - height: 100%; - min-height: 0; - aspect-ratio: 1/1; - - &::before { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - content: ''; - background-color: rgb(0 0 0); - opacity: 0; - transition: all 0.2s ease-in-out; - } - - &:hover { - &::before { - opacity: 0.6; - } - } - - @container (min-width: 500px) { - display: block; - } -} - -.image { - aspect-ratio: 1/1; -} - -.metadata-container { - display: flex; - flex-direction: column; - gap: var(--theme-spacing-sm); - width: 100%; - height: 100%; - padding: var(--theme-spacing-xs) 0; - overflow: hidden; -} - -.metadata-container .header { - display: flex; - align-items: center; - justify-content: space-between; - font-weight: 600; - line-height: 1.2; -} - -.metadata-container .header .title { - max-width: 70%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.metadata-container .content { - display: flex; - flex-direction: column; - gap: var(--theme-spacing-xs); -} - -.metadata-container .content .tags { -} diff --git a/src/renderer/components/item-detail/item-detail.tsx b/src/renderer/components/item-detail/item-detail.tsx deleted file mode 100644 index c7b12ee32..000000000 --- a/src/renderer/components/item-detail/item-detail.tsx +++ /dev/null @@ -1,146 +0,0 @@ -// import { AnimatePresence } from 'motion/react'; -// import { MouseEvent, useMemo, useState } from 'react'; -// import { Link } from 'react-router'; - -// import styles from './item-detail.module.css'; - -// import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls'; -// import { useFastAverageColor } from '/@/renderer/hooks'; -// import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; -// import { Badge } from '/@/shared/components/badge/badge'; -// import { Divider } from '/@/shared/components/divider/divider'; -// import { Group } from '/@/shared/components/group/group'; -// import { Image } from '/@/shared/components/image/image'; -// import { Rating } from '/@/shared/components/rating/rating'; -// import { Text } from '/@/shared/components/text/text'; -// import { -// Album, -// AlbumArtist, -// Artist, -// LibraryItem, -// Playlist, -// Song, -// } from '/@/shared/types/domain-types'; -// import { stringToColor } from '/@/shared/utils/string-to-color'; - -// interface ItemDetailProps { -// data: Album | AlbumArtist | Artist | Playlist | Song | undefined; -// itemHeight: number; -// itemType: LibraryItem; -// onClick?: (e: MouseEvent, item: unknown, itemType: LibraryItem) => void; -// withControls?: boolean; -// } - -// export const ItemDetail = ({ data, itemType, onClick, withControls }: ItemDetailProps) => { -// const imageUrl = getImageUrl(data); - -// const [showControls, setShowControls] = useState(false); - -// const { background } = useFastAverageColor({ -// algorithm: 'simple', -// src: imageUrl, -// srcLoaded: false, -// }); - -// // const tags = [...(data?.genres ?? [])]; - -// const tags = useMemo(() => { -// if (!data) { -// return []; -// } - -// const items: { -// color?: string; -// id: string; -// isLight?: boolean; -// itemType: LibraryItem; -// name: string; -// }[] = []; - -// if ('albumArtists' in data && Array.isArray(data.albumArtists)) { -// data.albumArtists?.forEach((tag: { id: string; name: string }) => { -// items.push({ id: tag.id, itemType: LibraryItem.ALBUM_ARTIST, name: tag.name }); -// }); -// } - -// if ('genres' in data && Array.isArray(data.genres)) { -// data.genres?.forEach((tag: { id: string; itemType: LibraryItem; name: string }) => { -// const { color, isLight } = stringToColor(tag.name); -// items.push({ ...tag, color, isLight }); -// }); -// } - -// // if ('tags' in data && typeof data.tags === 'object') { -// // console.log('data.tags :>> ', data.tags); -// // Object.entries(data.tags).forEach(([key, value]) => { -// // items.push({ id: key, itemType: LibraryItem.TAG, name: value }); -// // }); -// // } - -// return items; -// }, [data]); - -// return ( -//
onClick?.(e, data, itemType)} -// style={{ backgroundColor: background }} -// > -//
withControls && setShowControls(true)} -// onMouseLeave={() => withControls && setShowControls(false)} -// > -// {data?.name} -// -// {withControls && showControls && } -// -//
-//
-//
-// -// {data?.name} -// -// -// {data && 'userRating' in data && ( -// -// )} -// {data && 'userFavorite' in data && ( -// -// )} -// -//
-// -//
-// -// {tags.map((tag) => ( -// -// {tag.name} -// -// ))} -// -//
-//
-//
-// ); -// }; - -// const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => { -// if (data && 'imageUrl' in data) { -// return data.imageUrl || undefined; -// } - -// return undefined; -// }; diff --git a/src/renderer/components/item-list/helpers/item-list-controls.ts b/src/renderer/components/item-list/helpers/item-list-controls.ts index 7adf6a717..458063589 100644 --- a/src/renderer/components/item-list/helpers/item-list-controls.ts +++ b/src/renderer/components/item-list/helpers/item-list-controls.ts @@ -192,9 +192,10 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs onColumnReordered?.(columnIdFrom, columnIdTo, edge); }, - onColumnResized: ({ columnId, width }: { columnId: TableColumn; width: number }) => { - onColumnResized?.(columnId, width); - }, + onColumnResized: onColumnResized + ? ({ columnId, width }: { columnId: TableColumn; width: number }) => + onColumnResized(columnId, width) + : undefined, onDoubleClick: ({ internalState, item, itemType, meta }: DefaultItemControlProps) => { if (!item || !internalState) { @@ -241,11 +242,13 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs } const playType = (meta?.playType as Play) || Play.NOW; + const singleSongOnly = meta?.singleSongOnly === true; - // For NEXT, LAST, NEXT_SHUFFLE, and LAST_SHUFFLE, only add the clicked song - // For NOW and SHUFFLE, add a range of songs around the clicked song + // For single-song actions (e.g. image play button), or NEXT/LAST/..., only add the clicked song + // For row double-click with NOW/SHUFFLE, add a range of songs around the clicked song let songsToAdd: Song[]; if ( + singleSongOnly || playType === Play.NEXT || playType === Play.LAST || playType === Play.NEXT_SHUFFLE || diff --git a/src/renderer/components/item-list/helpers/use-item-list-column-reorder.ts b/src/renderer/components/item-list/helpers/use-item-list-column-reorder.ts index 9d54560ae..328ba26d2 100644 --- a/src/renderer/components/item-list/helpers/use-item-list-column-reorder.ts +++ b/src/renderer/components/item-list/helpers/use-item-list-column-reorder.ts @@ -7,14 +7,19 @@ import { ItemListKey, TableColumn } from '/@/shared/types/types'; interface UseItemListColumnReorderProps { itemListKey: ItemListKey; + tableKey?: 'detail' | 'main'; } -export const useItemListColumnReorder = ({ itemListKey }: UseItemListColumnReorderProps) => { +export const useItemListColumnReorder = ({ + itemListKey, + tableKey = 'main', +}: UseItemListColumnReorderProps) => { const { setList } = useSettingsStoreActions(); const handleColumnReordered = useCallback( (columnIdFrom: TableColumn, columnIdTo: TableColumn, edge: Edge | null) => { - const columns = useSettingsStore.getState().lists[itemListKey]?.table.columns; + const list = useSettingsStore.getState().lists[itemListKey]; + const columns = tableKey === 'detail' ? list?.detail?.columns : list?.table?.columns; if (!columns) { return; @@ -83,13 +88,20 @@ export const useItemListColumnReorder = ({ itemListKey }: UseItemListColumnReord // Insert the column at the new position newColumns.splice(newIndex, 0, updatedMovedColumn); - setList(itemListKey, { - table: { - columns: newColumns, - }, - }); + if (tableKey === 'detail') { + type SetListData = Parameters< + ReturnType['setList'] + >[1]; + setList(itemListKey, { detail: { columns: newColumns } } as SetListData); + } else { + setList(itemListKey, { + table: { + columns: newColumns, + }, + }); + } }, - [itemListKey, setList], + [itemListKey, setList, tableKey], ); return { handleColumnReordered }; diff --git a/src/renderer/components/item-list/helpers/use-item-list-column-resize.ts b/src/renderer/components/item-list/helpers/use-item-list-column-resize.ts index 1b79edf9e..bfe87bcbb 100644 --- a/src/renderer/components/item-list/helpers/use-item-list-column-resize.ts +++ b/src/renderer/components/item-list/helpers/use-item-list-column-resize.ts @@ -5,11 +5,18 @@ import { ItemListKey, TableColumn } from '/@/shared/types/types'; interface UseItemListColumnResizeProps { itemListKey: ItemListKey; + tableKey?: 'detail' | 'main'; } -export const useItemListColumnResize = ({ itemListKey }: UseItemListColumnResizeProps) => { +export const useItemListColumnResize = ({ + itemListKey, + tableKey = 'main', +}: UseItemListColumnResizeProps) => { const { setList } = useSettingsStoreActions(); - const columns = useSettingsStore((state) => state.lists[itemListKey]?.table.columns); + const columns = useSettingsStore((state) => { + const list = state.lists[itemListKey]; + return tableKey === 'detail' ? list?.detail?.columns : list?.table?.columns; + }); const handleColumnResized = useCallback( (columnId: TableColumn, width: number) => { @@ -19,13 +26,20 @@ export const useItemListColumnResize = ({ itemListKey }: UseItemListColumnResize column.id === columnId ? { ...column, width } : column, ); - setList(itemListKey, { - table: { - columns: updatedColumns, - }, - }); + if (tableKey === 'detail') { + type SetListData = Parameters< + ReturnType['setList'] + >[1]; + setList(itemListKey, { detail: { columns: updatedColumns } } as SetListData); + } else { + setList(itemListKey, { + table: { + columns: updatedColumns, + }, + }); + } }, - [columns, itemListKey, setList], + [columns, itemListKey, setList, tableKey], ); return { handleColumnResized }; diff --git a/src/renderer/components/item-list/helpers/use-item-list-scroll-persist.ts b/src/renderer/components/item-list/helpers/use-item-list-scroll-persist.ts index 76f829648..b45a267f2 100644 --- a/src/renderer/components/item-list/helpers/use-item-list-scroll-persist.ts +++ b/src/renderer/components/item-list/helpers/use-item-list-scroll-persist.ts @@ -1,26 +1,29 @@ import { useCallback, useMemo } from 'react'; -import { useSearchParams } from 'react-router'; +import { useLocation, useNavigationType } from 'react-router'; -import { parseIntParam, setSearchParam } from '/@/renderer/utils/query-params'; +import { useScrollStore } from '/@/renderer/store/scroll.store'; interface UseItemListScrollPersistProps { enabled: boolean; } export const useItemListScrollPersist = ({ enabled }: UseItemListScrollPersistProps) => { - const [searchParams, setSearchParams] = useSearchParams(); + const location = useLocation(); + const navigationType = useNavigationType(); + const setOffset = useScrollStore((s) => s.setOffset); + const getOffset = useScrollStore((s) => s.getOffset); - const scrollOffset = useMemo(() => parseIntParam(searchParams, 'scrollOffset'), [searchParams]); + const scrollOffset = useMemo(() => { + if (navigationType !== 'POP') return undefined; + return getOffset(location.key); + }, [getOffset, location.key, navigationType]); const handleOnScrollEnd = useCallback( (offset: number) => { if (!enabled) return; - - setSearchParams((prev) => setSearchParam(prev, 'scrollOffset', offset), { - replace: true, - }); + setOffset(location.key, offset); }, - [enabled, setSearchParams], + [enabled, location.key, setOffset], ); return { handleOnScrollEnd, scrollOffset }; diff --git a/src/renderer/components/item-list/item-detail-list/columns/actions-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/actions-column.tsx new file mode 100644 index 000000000..54cfdbf47 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/actions-column.tsx @@ -0,0 +1,38 @@ +import { ItemDetailListCellProps } from './types'; + +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { LibraryItem } from '/@/shared/types/domain-types'; + +export const ActionsColumn = ({ controls, internalState, song }: ItemDetailListCellProps) => { + const handleClick = (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + const index = internalState?.findItemIndex(song.id) ?? -1; + controls?.onMore?.({ + event, + index, + internalState: internalState ?? undefined, + item: song, + itemType: LibraryItem.SONG, + }); + }; + + const handleDoubleClick = (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + }; + + return ( + + ); +}; 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 new file mode 100644 index 000000000..7269215cf --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/album-artist-column.tsx @@ -0,0 +1,23 @@ +import { ItemDetailListCellProps } from './types'; + +import { + JOINED_ARTISTS_MUTED_PROPS, + JoinedArtists, +} from '/@/renderer/features/albums/components/joined-artists'; + +export const AlbumArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => { + const name = song.albumArtistName?.trim() ?? ''; + const hasArtists = name.length > 0 || (song.albumArtists?.length ?? 0) > 0; + + if (!hasArtists) return <> ; + + return ( + + ); +}; diff --git a/src/renderer/components/item-list/item-detail-list/columns/album-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/album-column.tsx new file mode 100644 index 000000000..d42517ce1 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/album-column.tsx @@ -0,0 +1,3 @@ +import { ItemDetailListCellProps } from './types'; + +export const AlbumColumn = ({ song }: ItemDetailListCellProps) => song.album ?? <> ; 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 new file mode 100644 index 000000000..62f50d7d1 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/artist-column.tsx @@ -0,0 +1,23 @@ +import { ItemDetailListCellProps } from './types'; + +import { + JOINED_ARTISTS_MUTED_PROPS, + JoinedArtists, +} from '/@/renderer/features/albums/components/joined-artists'; + +export const ArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => { + const name = song.artistName?.trim() ?? ''; + const hasArtists = name.length > 0 || (song.artists?.length ?? 0) > 0; + + if (!hasArtists) return <> ; + + return ( + + ); +}; diff --git a/src/renderer/components/item-list/item-detail-list/columns/bit-depth-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/bit-depth-column.tsx new file mode 100644 index 000000000..512a80687 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/bit-depth-column.tsx @@ -0,0 +1,3 @@ +import { ItemDetailListCellProps } from './types'; + +export const BitDepthColumn = ({ song }: ItemDetailListCellProps) => song.bitDepth; diff --git a/src/renderer/components/item-list/item-detail-list/columns/bit-rate-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/bit-rate-column.tsx new file mode 100644 index 000000000..3da37a1d8 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/bit-rate-column.tsx @@ -0,0 +1,4 @@ +import { ItemDetailListCellProps } from './types'; + +export const BitRateColumn = ({ song }: ItemDetailListCellProps) => + song.bitRate != null ? `${song.bitRate} kbps` : <> ; diff --git a/src/renderer/components/item-list/item-detail-list/columns/bpm-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/bpm-column.tsx new file mode 100644 index 000000000..6bb88b4f6 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/bpm-column.tsx @@ -0,0 +1,3 @@ +import { ItemDetailListCellProps } from './types'; + +export const BpmColumn = ({ song }: ItemDetailListCellProps) => song.bpm ?? <> ; diff --git a/src/renderer/components/item-list/item-detail-list/columns/channels-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/channels-column.tsx new file mode 100644 index 000000000..ac6b78e0d --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/channels-column.tsx @@ -0,0 +1,4 @@ +import { ItemDetailListCellProps } from './types'; + +export const ChannelsColumn = ({ song }: ItemDetailListCellProps) => + song.channels != null ? String(song.channels) : <> ; diff --git a/src/renderer/components/item-list/item-detail-list/columns/codec-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/codec-column.tsx new file mode 100644 index 000000000..0f7f0d2d5 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/codec-column.tsx @@ -0,0 +1,3 @@ +import { ItemDetailListCellProps } from './types'; + +export const CodecColumn = ({ song }: ItemDetailListCellProps) => song.container ?? <> ; diff --git a/src/renderer/components/item-list/item-detail-list/columns/comment-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/comment-column.tsx new file mode 100644 index 000000000..1d36577b7 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/comment-column.tsx @@ -0,0 +1,3 @@ +import { ItemDetailListCellProps } from './types'; + +export const CommentColumn = ({ song }: ItemDetailListCellProps) => song.comment ?? <> ; diff --git a/src/renderer/components/item-list/item-detail-list/columns/composer-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/composer-column.tsx new file mode 100644 index 000000000..877e63882 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/composer-column.tsx @@ -0,0 +1,7 @@ +import { ItemDetailListCellProps } from './types'; + +export const ComposerColumn = ({ song }: ItemDetailListCellProps) => { + const composers = song.participants?.composer; + if (!composers?.length) return <> ; + return composers.map((a) => a.name).join(', '); +}; diff --git a/src/renderer/components/item-list/item-detail-list/columns/date-added-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/date-added-column.tsx new file mode 100644 index 000000000..874b443d6 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/date-added-column.tsx @@ -0,0 +1,6 @@ +import { ItemDetailListCellProps } from './types'; + +import { formatDateAbsolute } from '/@/renderer/utils/format'; + +export const DateAddedColumn = ({ song }: ItemDetailListCellProps) => + song.createdAt ? formatDateAbsolute(song.createdAt) : <> ; diff --git a/src/renderer/components/item-list/item-detail-list/columns/default-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/default-column.tsx new file mode 100644 index 000000000..94d221579 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/default-column.tsx @@ -0,0 +1,11 @@ +import { ItemDetailListCellProps } from './types'; + +interface DefaultColumnProps extends ItemDetailListCellProps { + columnId: string; +} + +export const DefaultColumn = ({ columnId, song }: DefaultColumnProps) => { + const raw = (song as Record)[columnId]; + if (raw === undefined || raw === null || typeof raw === 'object') return <> ; + return String(raw); +}; diff --git a/src/renderer/components/item-list/item-detail-list/columns/disc-number-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/disc-number-column.tsx new file mode 100644 index 000000000..267312581 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/disc-number-column.tsx @@ -0,0 +1,3 @@ +import { ItemDetailListCellProps } from './types'; + +export const DiscNumberColumn = ({ song }: ItemDetailListCellProps) => String(song.discNumber ?? 1); diff --git a/src/renderer/components/item-list/item-detail-list/columns/duration-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/duration-column.tsx new file mode 100644 index 000000000..45d280aa6 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/duration-column.tsx @@ -0,0 +1,5 @@ +import formatDuration from 'format-duration'; + +import { ItemDetailListCellProps } from './types'; + +export const DurationColumn = ({ song }: ItemDetailListCellProps) => formatDuration(song.duration); diff --git a/src/renderer/components/item-list/item-detail-list/columns/favorite-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/favorite-column.tsx new file mode 100644 index 000000000..99291e607 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/favorite-column.tsx @@ -0,0 +1,54 @@ +import { ItemDetailListCellProps } from './types'; + +import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; +import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { LibraryItem } from '/@/shared/types/domain-types'; + +export const FavoriteColumn = ({ + controls, + internalState, + isMutatingFavorite, + onFavoriteClick, + song, +}: ItemDetailListCellProps) => { + const isMutatingCreateFavorite = useIsMutatingCreateFavorite(); + const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite(); + const isMutating = isMutatingFavorite ?? (isMutatingCreateFavorite || isMutatingDeleteFavorite); + const isFavorite = song.userFavorite ?? false; + + return ( + { + event.stopPropagation(); + event.preventDefault(); + const index = internalState?.findItemIndex(song.id) ?? -1; + if (controls?.onFavorite) { + controls.onFavorite({ + event, + favorite: !isFavorite, + index, + internalState: internalState ?? undefined, + item: song, + itemType: LibraryItem.SONG, + }); + } else { + onFavoriteClick?.(song); + } + }} + onDoubleClick={(event) => { + event.stopPropagation(); + event.preventDefault(); + }} + size="xs" + variant="subtle" + /> + ); +}; diff --git a/src/renderer/components/item-list/item-detail-list/columns/genre-badge-column.module.css b/src/renderer/components/item-list/item-detail-list/columns/genre-badge-column.module.css new file mode 100644 index 000000000..2a4af2c49 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/genre-badge-column.module.css @@ -0,0 +1,12 @@ +.group { + flex-wrap: nowrap; + gap: var(--theme-spacing-sm) var(--theme-spacing-xs); + min-width: 0; + padding: var(--theme-spacing-xs) 0; + overflow: hidden; +} + +.group a { + cursor: pointer; + user-select: none; +} diff --git a/src/renderer/components/item-list/item-detail-list/columns/genre-badge-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/genre-badge-column.tsx new file mode 100644 index 000000000..8cff88338 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/genre-badge-column.tsx @@ -0,0 +1,46 @@ +import { useMemo } from 'react'; +import { generatePath, Link } from 'react-router'; + +import styles from './genre-badge-column.module.css'; +import { ItemDetailListCellProps } from './types'; + +import { AppRoute } from '/@/renderer/router/routes'; +import { Badge } from '/@/shared/components/badge/badge'; +import { Group } from '/@/shared/components/group/group'; +import { stringToColor } from '/@/shared/utils/string-to-color'; + +const MAX_GENRES = 4; + +export const GenreBadgeColumn = ({ song }: ItemDetailListCellProps) => { + const genres = song.genres; + + const genresWithStyle = useMemo(() => { + if (!genres) return []; + return genres.slice(0, MAX_GENRES).map((genre) => { + const { color, isLight } = stringToColor(genre.name); + const path = generatePath(AppRoute.LIBRARY_GENRES_DETAIL, { genreId: genre.id }); + return { ...genre, color, isLight, path }; + }); + }, [genres]); + + if (!genresWithStyle.length) return <> ; + + return ( + + {genresWithStyle.map((genre) => ( + + {genre.name} + + ))} + + ); +}; diff --git a/src/renderer/components/item-list/item-detail-list/columns/genre-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/genre-column.tsx new file mode 100644 index 000000000..6c3406910 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/genre-column.tsx @@ -0,0 +1,40 @@ +import { Fragment } from 'react'; +import { generatePath, Link } from 'react-router'; + +import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types'; +import { AppRoute } from '/@/renderer/router/routes'; +import { Text } from '/@/shared/components/text/text'; + +const TEXT_PROPS = { isMuted: true, isNoSelect: true, size: 'sm' as const } as const; + +export const GenreColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => { + const genres = song.genres ?? []; + if (!genres.length) return <> ; + + return ( + <> + {genres.map((genre, index) => ( + + {isRowHovered ? ( + + {genre.name} + + ) : ( + + {genre.name} + + )} + {index < genres.length - 1 && ', '} + + ))} + + ); +}; diff --git a/src/renderer/components/item-list/item-detail-list/columns/image-column.module.css b/src/renderer/components/item-list/item-detail-list/columns/image-column.module.css new file mode 100644 index 000000000..3256d3d15 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/image-column.module.css @@ -0,0 +1,50 @@ +.image-container { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.compact-container { + flex: 1 1 0; + width: 100%; + min-width: 0; + height: 100%; + min-height: 0; + max-height: 100%; + aspect-ratio: unset; + padding-top: var(--theme-spacing-xs); + padding-bottom: var(--theme-spacing-xs); + overflow: hidden; + border-radius: var(--theme-radius-md); +} + +.play-button-overlay { + position: absolute; + top: 50%; + left: 50%; + z-index: 10; + opacity: 0.6; + transform: translate(-50%, -50%); + transition: opacity 0.2s ease-in-out; +} + +.play-button-overlay:hover { + opacity: 1; +} + +.play-button-overlay button { + width: 24px; + height: 24px; +} + +.compact-image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + border-radius: var(--theme-radius-md); +} diff --git a/src/renderer/components/item-list/item-detail-list/columns/image-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/image-column.tsx new file mode 100644 index 000000000..96ce777b7 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/image-column.tsx @@ -0,0 +1,71 @@ +import clsx from 'clsx'; +import { useState } from 'react'; + +import styles from './image-column.module.css'; +import { ItemDetailListCellProps } from './types'; + +import { ItemImage } from '/@/renderer/components/item-image/item-image'; +import { PlayButton } from '/@/renderer/features/shared/components/play-button'; +import { + LONG_PRESS_PLAY_BEHAVIOR, + PlayTooltip, +} from '/@/renderer/features/shared/components/play-button-group'; +import { usePlayButtonBehavior } from '/@/renderer/store'; +import { LibraryItem } from '/@/shared/types/domain-types'; +import { Play } from '/@/shared/types/types'; + +export const ImageColumn = ({ + controls, + internalState, + rowIndex = 0, + song, +}: ItemDetailListCellProps) => { + const playButtonBehavior = usePlayButtonBehavior(); + const [isHovered, setIsHovered] = useState(false); + + const handlePlay = (playType: Play) => { + if (!song || !controls?.onDoubleClick) { + return; + } + + controls.onDoubleClick({ + event: null, + index: rowIndex, + internalState, + item: song, + itemType: LibraryItem.SONG, + meta: { playType, singleSongOnly: true }, + }); + }; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + {isHovered && ( +
+ + handlePlay(playButtonBehavior)} + onLongPress={() => + handlePlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior]) + } + /> + +
+ )} +
+ ); +}; diff --git a/src/renderer/components/item-list/item-detail-list/columns/index.ts b/src/renderer/components/item-list/item-detail-list/columns/index.ts new file mode 100644 index 000000000..db5bb5285 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/index.ts @@ -0,0 +1,127 @@ +import React, { type ReactNode } from 'react'; + +import type { ItemDetailListCellProps } from './types'; + +import { ActionsColumn } from './actions-column'; +import { AlbumArtistColumn } from './album-artist-column'; +import { AlbumColumn } from './album-column'; +import { ArtistColumn } from './artist-column'; +import { BitDepthColumn } from './bit-depth-column'; +import { BitRateColumn } from './bit-rate-column'; +import { BpmColumn } from './bpm-column'; +import { ChannelsColumn } from './channels-column'; +import { CodecColumn } from './codec-column'; +import { CommentColumn } from './comment-column'; +import { ComposerColumn } from './composer-column'; +import { DateAddedColumn } from './date-added-column'; +import { DefaultColumn } from './default-column'; +import { DiscNumberColumn } from './disc-number-column'; +import { DurationColumn } from './duration-column'; +import { FavoriteColumn } from './favorite-column'; +import { GenreBadgeColumn } from './genre-badge-column'; +import { GenreColumn } from './genre-column'; +import { ImageColumn } from './image-column'; +import { LastPlayedColumn } from './last-played-column'; +import { PathColumn } from './path-column'; +import { PlayCountColumn } from './play-count-column'; +import { RatingColumn } from './rating-column'; +import { ReleaseDateColumn } from './release-date-column'; +import { RowIndexColumn } from './row-index-column'; +import { SampleRateColumn } from './sample-rate-column'; +import { SizeColumn } from './size-column'; +import { TitleArtistColumn } from './title-artist-column'; +import { TitleColumn } from './title-column'; +import { TitleCombinedColumn } from './title-combined-column'; +import { TrackNumberColumn } from './track-number-column'; +import { YearColumn } from './year-column'; + +import { TableColumn } from '/@/shared/types/types'; + +type CellComponent = (props: ItemDetailListCellProps) => ReactNode; + +const COLUMN_MAP: Partial> = { + [TableColumn.ACTIONS]: ActionsColumn, + [TableColumn.ALBUM]: AlbumColumn, + [TableColumn.ALBUM_ARTIST]: AlbumArtistColumn, + [TableColumn.ARTIST]: ArtistColumn, + [TableColumn.BIT_DEPTH]: BitDepthColumn, + [TableColumn.BIT_RATE]: BitRateColumn, + [TableColumn.BPM]: BpmColumn, + [TableColumn.CHANNELS]: ChannelsColumn, + [TableColumn.CODEC]: CodecColumn, + [TableColumn.COMMENT]: CommentColumn, + [TableColumn.COMPOSER]: ComposerColumn, + [TableColumn.DATE_ADDED]: DateAddedColumn, + [TableColumn.DISC_NUMBER]: DiscNumberColumn, + [TableColumn.DURATION]: DurationColumn, + [TableColumn.GENRE]: GenreColumn, + [TableColumn.GENRE_BADGE]: GenreBadgeColumn, + [TableColumn.IMAGE]: ImageColumn, + [TableColumn.LAST_PLAYED]: LastPlayedColumn, + [TableColumn.PATH]: PathColumn, + [TableColumn.PLAY_COUNT]: PlayCountColumn, + [TableColumn.RELEASE_DATE]: ReleaseDateColumn, + [TableColumn.ROW_INDEX]: RowIndexColumn, + [TableColumn.SAMPLE_RATE]: SampleRateColumn, + [TableColumn.SIZE]: SizeColumn, + [TableColumn.TITLE]: TitleColumn, + [TableColumn.TITLE_ARTIST]: TitleArtistColumn, + [TableColumn.TITLE_COMBINED]: TitleCombinedColumn, + [TableColumn.TRACK_NUMBER]: TrackNumberColumn, + [TableColumn.USER_FAVORITE]: FavoriteColumn, + [TableColumn.USER_RATING]: RatingColumn, + [TableColumn.YEAR]: YearColumn, +}; + +export type DetailListCellComponentProps = ItemDetailListCellProps & { columnId?: string }; + +export function getDetailListCellComponent( + columnId: string | TableColumn, +): (props: DetailListCellComponentProps) => ReactNode { + const Component = COLUMN_MAP[columnId as TableColumn]; + if (Component) { + return Component as (props: DetailListCellComponentProps) => ReactNode; + } + return (props: DetailListCellComponentProps) => + React.createElement(DefaultColumn, { + columnId: props.columnId ?? (columnId as string), + song: props.song, + }); +} + +export type { ItemDetailListCellProps } from './types'; + +export { + ActionsColumn, + AlbumArtistColumn, + AlbumColumn, + ArtistColumn, + BitDepthColumn, + BitRateColumn, + BpmColumn, + ChannelsColumn, + CodecColumn, + CommentColumn, + ComposerColumn, + DateAddedColumn, + DefaultColumn, + DiscNumberColumn, + DurationColumn, + FavoriteColumn, + GenreBadgeColumn, + GenreColumn, + ImageColumn, + LastPlayedColumn, + PathColumn, + PlayCountColumn, + RatingColumn, + ReleaseDateColumn, + RowIndexColumn, + SampleRateColumn, + SizeColumn, + TitleArtistColumn, + TitleColumn, + TitleCombinedColumn, + TrackNumberColumn, + YearColumn, +}; diff --git a/src/renderer/components/item-list/item-detail-list/columns/last-played-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/last-played-column.tsx new file mode 100644 index 000000000..c538b0378 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/last-played-column.tsx @@ -0,0 +1,6 @@ +import { ItemDetailListCellProps } from './types'; + +import { formatDateRelative } from '/@/renderer/utils/format'; + +export const LastPlayedColumn = ({ song }: ItemDetailListCellProps) => + song.lastPlayedAt ? formatDateRelative(song.lastPlayedAt) : <> ; diff --git a/src/renderer/components/item-list/item-detail-list/columns/path-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/path-column.tsx new file mode 100644 index 000000000..df244597d --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/path-column.tsx @@ -0,0 +1,3 @@ +import { ItemDetailListCellProps } from './types'; + +export const PathColumn = ({ song }: ItemDetailListCellProps) => song.path ?? <> ; diff --git a/src/renderer/components/item-list/item-detail-list/columns/play-count-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/play-count-column.tsx new file mode 100644 index 000000000..e7c507ce4 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/play-count-column.tsx @@ -0,0 +1,4 @@ +import { ItemDetailListCellProps } from './types'; + +export const PlayCountColumn = ({ song }: ItemDetailListCellProps) => + song.playCount ? String(song.playCount) : <> ; diff --git a/src/renderer/components/item-list/item-detail-list/columns/rating-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/rating-column.tsx new file mode 100644 index 000000000..db871fb9a --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/rating-column.tsx @@ -0,0 +1,29 @@ +import { ItemDetailListCellProps } from './types'; + +import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation'; +import { Rating } from '/@/shared/components/rating/rating'; +import { LibraryItem } from '/@/shared/types/domain-types'; + +export const RatingColumn = ({ controls, internalState, song }: ItemDetailListCellProps) => { + const isMutatingRating = useIsMutatingRating(); + const value = song.userRating ?? 0; + + return ( + { + const index = internalState?.findItemIndex(song.id) ?? -1; + controls?.onRating?.({ + event: null, + index, + internalState: internalState ?? undefined, + item: song, + itemType: LibraryItem.SONG, + rating, + }); + }} + readOnly={isMutatingRating} + size="xs" + value={value} + /> + ); +}; diff --git a/src/renderer/components/item-list/item-detail-list/columns/release-date-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/release-date-column.tsx new file mode 100644 index 000000000..a893fe32b --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/release-date-column.tsx @@ -0,0 +1,6 @@ +import { ItemDetailListCellProps } from './types'; + +import { formatDateAbsoluteUTC } from '/@/renderer/utils/format'; + +export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) => + song.releaseDate ? formatDateAbsoluteUTC(song.releaseDate) : <> ; diff --git a/src/renderer/components/item-list/item-detail-list/columns/row-index-column.module.css b/src/renderer/components/item-list/item-detail-list/columns/row-index-column.module.css new file mode 100644 index 000000000..3b0048ae7 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/row-index-column.module.css @@ -0,0 +1,5 @@ +.icon-wrapper { + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/renderer/components/item-list/item-detail-list/columns/row-index-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/row-index-column.tsx new file mode 100644 index 000000000..817f0acec --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/row-index-column.tsx @@ -0,0 +1,23 @@ +import styles from './row-index-column.module.css'; +import { ItemDetailListCellProps } from './types'; + +import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song'; +import { usePlayerStatus } from '/@/renderer/store'; +import { Icon } from '/@/shared/components/icon/icon'; +import { PlayerStatus } from '/@/shared/types/types'; + +export const RowIndexColumn = ({ rowIndex, song }: ItemDetailListCellProps) => { + const status = usePlayerStatus(); + const { isActive } = useIsCurrentSong(song); + const isPlaying = isActive && status === PlayerStatus.PLAYING; + + if (isActive) { + return ( +
+ +
+ ); + } + + return <>{String((rowIndex ?? 0) + 1)}; +}; diff --git a/src/renderer/components/item-list/item-detail-list/columns/sample-rate-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/sample-rate-column.tsx new file mode 100644 index 000000000..317c85603 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/sample-rate-column.tsx @@ -0,0 +1,4 @@ +import { ItemDetailListCellProps } from './types'; + +export const SampleRateColumn = ({ song }: ItemDetailListCellProps) => + song.sampleRate ? `${song.sampleRate} Hz` : <> ; diff --git a/src/renderer/components/item-list/item-detail-list/columns/size-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/size-column.tsx new file mode 100644 index 000000000..c15b3d556 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/size-column.tsx @@ -0,0 +1,6 @@ +import { ItemDetailListCellProps } from './types'; + +import { formatSizeString } from '/@/renderer/utils/format'; + +export const SizeColumn = ({ song }: ItemDetailListCellProps) => + song.size ? formatSizeString(song.size) : <> ; diff --git a/src/renderer/components/item-list/item-detail-list/columns/title-artist-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/title-artist-column.tsx new file mode 100644 index 000000000..bfd60def1 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/title-artist-column.tsx @@ -0,0 +1,18 @@ +import clsx from 'clsx'; + +import styles from './title-column.module.css'; + +import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types'; +import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song'; +import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator'; + +export const TitleArtistColumn = ({ song }: ItemDetailListCellProps) => { + const { isActive } = useIsCurrentSong(song); + + return ( + + + {[song.name, song.artistName].filter(Boolean).join(' — ') ?? <> } + + ); +}; diff --git a/src/renderer/components/item-list/item-detail-list/columns/title-column.module.css b/src/renderer/components/item-list/item-detail-list/columns/title-column.module.css new file mode 100644 index 000000000..9b1d26b7a --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/title-column.module.css @@ -0,0 +1,3 @@ +.active { + color: var(--theme-colors-primary); +} diff --git a/src/renderer/components/item-list/item-detail-list/columns/title-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/title-column.tsx new file mode 100644 index 000000000..c46697fc8 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/title-column.tsx @@ -0,0 +1,18 @@ +import clsx from 'clsx'; + +import styles from './title-column.module.css'; + +import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types'; +import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song'; +import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator'; + +export const TitleColumn = ({ song }: ItemDetailListCellProps) => { + const { isActive } = useIsCurrentSong(song); + + return ( + + + {song.name ?? <> } + + ); +}; diff --git a/src/renderer/components/item-list/item-detail-list/columns/title-combined-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/title-combined-column.tsx new file mode 100644 index 000000000..17e6de49c --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/title-combined-column.tsx @@ -0,0 +1,18 @@ +import clsx from 'clsx'; + +import styles from './title-column.module.css'; + +import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types'; +import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song'; +import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator'; + +export const TitleCombinedColumn = ({ song }: ItemDetailListCellProps) => { + const { isActive } = useIsCurrentSong(song); + + return ( + + + {[song.name, song.artistName].filter(Boolean).join(' — ') ?? <> } + + ); +}; diff --git a/src/renderer/components/item-list/item-detail-list/columns/track-number-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/track-number-column.tsx new file mode 100644 index 000000000..b2ee3f7f4 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/track-number-column.tsx @@ -0,0 +1,7 @@ +import { ItemDetailListCellProps } from './types'; + +export const TrackNumberColumn = ({ song }: ItemDetailListCellProps) => { + const disc = song.discNumber ?? 1; + const track = song.trackNumber.toString().padStart(2, '0'); + return `${disc}-${track}`; +}; diff --git a/src/renderer/components/item-list/item-detail-list/columns/types.ts b/src/renderer/components/item-list/item-detail-list/columns/types.ts new file mode 100644 index 000000000..cad0801a0 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/types.ts @@ -0,0 +1,14 @@ +import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state'; +import { ItemControls } from '/@/renderer/components/item-list/types'; +import { Song } from '/@/shared/types/domain-types'; + +export interface ItemDetailListCellProps { + controls?: ItemControls; + internalState?: ItemListStateActions; + isMutatingFavorite?: boolean; + isRowHovered?: boolean; + onFavoriteClick?: (song: Song) => void; + rowIndex?: number; + size?: 'compact' | 'default' | 'large'; + song: Song; +} diff --git a/src/renderer/components/item-list/item-detail-list/columns/year-column.tsx b/src/renderer/components/item-list/item-detail-list/columns/year-column.tsx new file mode 100644 index 000000000..9cb90c173 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/columns/year-column.tsx @@ -0,0 +1,4 @@ +import { ItemDetailListCellProps } from './types'; + +export const YearColumn = ({ song }: ItemDetailListCellProps) => + song.releaseYear ? String(song.releaseYear) : <> ; diff --git a/src/renderer/components/item-list/item-detail-list/item-detail-list.module.css b/src/renderer/components/item-list/item-detail-list/item-detail-list.module.css new file mode 100644 index 000000000..987b1df4d --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/item-detail-list.module.css @@ -0,0 +1,556 @@ +.container { + position: relative; + flex: 1 1 auto; + width: 100%; + min-height: 0; + overflow: auto; +} + +.placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.wrapper { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; +} + +.detail-list-header { + display: grid; + flex-shrink: 0; + grid-template-columns: 240px 1fr; + gap: var(--theme-spacing-md); + padding: 0 var(--theme-spacing-md); + font-size: var(--theme-font-size-sm); + user-select: none; + background-color: var(--theme-colors-background); + border-bottom: 1px solid var(--theme-colors-border); +} + +.header-left { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; +} + +.header-left-album-name { + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--theme-font-size-sm); + font-weight: 500; + color: var(--theme-colors-foreground); + white-space: nowrap; +} + +.header-right { + min-width: 0; + overflow: hidden; +} + +.tracks-table-header { + display: flex; + flex-wrap: nowrap; + align-items: center; + width: 100%; + min-width: 0; +} + +.tracks-table-header-size-compact { + height: 32px; + min-height: 32px; +} + +.tracks-table-header-size-default { + height: 40px; + min-height: 40px; +} + +.tracks-table-header-size-large { + height: 48px; + min-height: 48px; +} + +.track-header-cell { + position: relative; + display: flex; + align-items: center; + min-width: 0; + min-height: 60%; + padding-right: var(--theme-spacing-sm); + padding-left: var(--theme-spacing-sm); + overflow: visible; +} + +.track-header-cell-no-h-padding { + padding-right: 0; + padding-left: 0; +} + +.track-header-cell-with-vertical-border { + border-right: 1px solid var(--theme-colors-border); +} + +.track-header-cell-dragging { + cursor: grabbing; + opacity: 0.5; +} + +.track-header-cell-dragged-over-left::before { + position: absolute; + top: 0; + bottom: 0; + left: 0; + z-index: 10; + width: 3px; + content: ''; + background-color: var(--theme-colors-primary); +} + +.track-header-cell-dragged-over-right::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 10; + width: 3px; + content: ''; + background-color: var(--theme-colors-primary); +} + +.track-header-cell:hover .resize-handle { + opacity: 1; +} + +.track-header-cell:hover .resize-handle::before { + background-color: var(--theme-colors-border); +} + +.resize-handle { + position: absolute; + top: 0; + bottom: 0; + z-index: 10; + width: 2px; + margin-right: -4px; + cursor: col-resize; + background: var(--theme-colors-border); + opacity: 0; + transition: opacity 0.3s ease; +} + +/* .resize-handle::before { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 2px; + content: ''; + background-color: transparent; + transition: background-color 0.15s ease; +} */ + +.resize-handle-left { + left: 0; + margin-right: 0; + margin-left: -4px; +} + +.resize-handle-left::before { + right: auto; + left: 0; +} + +.resize-handle-right { + right: 0; + margin-right: 0; +} + +.resize-handle-dragging { + opacity: 1; +} + +.resize-handle:hover { + opacity: 1; +} + +.row { + display: grid; + grid-template-columns: 240px 1fr; + gap: var(--theme-spacing-md); + padding: var(--theme-spacing-md); + border-bottom: 1px solid var(--theme-colors-border); +} + +.skeleton-column-wrapper { + box-sizing: border-box; + min-width: 0; +} + +.image-wrapper { + position: relative; + display: block; + width: 100%; + aspect-ratio: 1; + overflow: hidden; + color: inherit; + text-decoration: none; + border-radius: var(--theme-radius-md); + + &::before { + position: absolute; + top: 0; + left: 0; + z-index: 5; + width: 100%; + height: 100%; + pointer-events: none; + content: ''; + background-color: rgb(0 0 0); + opacity: 0; + transition: opacity 0.2s ease-in-out; + } + + &:hover { + @mixin dark { + &::before { + opacity: 0.7; + } + } + + @mixin light { + &::before { + opacity: 0.5; + } + } + } + + &: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 { + object-fit: var(--theme-image-fit); + border-radius: var(--theme-radius-md); +} + +.row .metadata { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-xs); + align-items: center; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--theme-font-size-md); + text-align: center; +} + +.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 { + color: var(--theme-colors-foreground); + text-decoration: underline; +} + +.row .metadata-extra { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-xs); + align-items: center; + width: 100%; + 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 { + min-width: 0; + overflow: hidden; +} + +.row .tracks-table { + display: flex; + flex-direction: column; + width: 100%; + font-size: var(--theme-font-size-sm); +} + +.row .track-row { + display: flex; + flex-wrap: nowrap; + align-items: center; + min-width: 0; + overflow: hidden; +} + +.row .track-header-cell { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row .track-cell { + min-width: 0; + padding-right: var(--theme-spacing-sm); + padding-left: var(--theme-spacing-sm); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row .track-row-size-compact { + height: 32px; + min-height: 32px; + max-height: 32px; +} + +.row .track-row-size-default { + height: 40px; + min-height: 40px; + max-height: 40px; +} + +.row .track-row-size-large { + height: 48px; + min-height: 48px; + max-height: 48px; +} + +.row .track-cell-muted { + color: var(--theme-colors-foreground-muted); +} + +.row .track-cell-with-vertical-border { + border-right: 1px solid transparent; +} + +.row .track-cell-vertical-border-visible { + border-right-color: var(--theme-colors-border); +} + +.row .track-cell-image { + display: flex; + align-self: stretch; + min-height: 0; + max-height: 100%; + padding-right: var(--theme-spacing-sm); + padding-left: var(--theme-spacing-sm); + overflow: hidden; +} + +.row .track-cell-no-h-padding { + padding-right: 0; + padding-left: 0; +} + +.track-row-dragging { + opacity: 0.5; +} + +.track-row.track-row-alternate-even { + background-color: var(--theme-colors-background); +} + +.track-row.track-row-alternate-odd { + @mixin dark { + background-color: darken(var(--theme-colors-background), 30%); + } + + @mixin light { + background-color: darken(var(--theme-colors-background), 2%); + } +} + +.track-row.track-row-selected { + @mixin dark { + background-color: lighten(var(--theme-colors-surface), 5%); + } + + @mixin light { + background-color: darken(var(--theme-colors-surface), 5%); + } +} + +.track-row.track-row-with-horizontal-border { + border-top: 1px solid transparent; +} + +.track-row.track-row-horizontal-border-visible { + border-top-color: var(--theme-colors-border); +} + +.track-row.track-row-hover-highlight-enabled { + position: relative; +} + +.track-row.track-row-hover-highlight-enabled .track-cell { + position: relative; + z-index: 2; +} + +.track-row.track-row-hover-highlight-enabled:hover::before { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + pointer-events: none; + content: ''; + background-color: var(--theme-colors-surface); + opacity: 0.7; +} + +.skeleton-image-container { + justify-content: center; +} + +.skeleton-image { + width: 100%; + aspect-ratio: 1; + border-radius: var(--theme-radius-md); +} + +.skeleton-title-container { + justify-content: center; +} + +.skeleton-title { + width: 75%; + height: 1.25rem; +} + +.skeleton-artist-container { + justify-content: center; +} + +.skeleton-artist { + width: 50%; + height: 1rem; +} + +.skeleton-tracks { + display: flex; + flex-direction: column; + gap: 0; +} + +.skeleton-track-row { + display: grid; + grid-template-columns: 40px 1fr 8rem; + gap: var(--theme-spacing-sm); + align-items: center; + padding-right: var(--theme-spacing-sm); + padding-left: var(--theme-spacing-sm); +} + +.skeleton-tracks-size-compact .skeleton-track-row { + height: 32px; + padding-top: 0; + padding-bottom: 0; +} + +.skeleton-tracks-size-default .skeleton-track-row { + height: 40px; + padding-top: 0; + padding-bottom: 0; +} + +.skeleton-tracks-size-large .skeleton-track-row { + height: 48px; + padding-top: 0; + padding-bottom: 0; +} + +.skeleton-track-cell { + width: 100%; + height: 1rem; +} + +.skeleton-track-cell-title { + width: 100%; + min-width: 0; + height: 1rem; +} diff --git a/src/renderer/components/item-list/item-detail-list/item-detail-list.tsx b/src/renderer/components/item-list/item-detail-list/item-detail-list.tsx new file mode 100644 index 000000000..38bf9eb2a --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/item-detail-list.tsx @@ -0,0 +1,1380 @@ +import { + attachClosestEdge, + type Edge, + extractClosestEdge, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { + draggable, + dropTargetForElements, +} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import clsx from 'clsx'; +import throttle from 'lodash/throttle'; +import { AnimatePresence } from 'motion/react'; +import { useOverlayScrollbars } from 'overlayscrollbars-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, useListRef } from 'react-window-v2'; + +import styles from './item-detail-list.module.css'; + +import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls'; +import { ItemImage } from '/@/renderer/components/item-image/item-image'; +import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls'; +import { + ItemListStateActions, + ItemListStateItemWithRequiredProperties, + useItemListState, + useItemSelectionState, +} from '/@/renderer/components/item-list/helpers/item-list-state'; +import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns'; +import { getDetailListCellComponent } from '/@/renderer/components/item-list/item-detail-list/columns'; +import { + getTrackColumnFixed, + isNoHorizontalPaddingColumn, + shouldShowHoverOnlyColumnContent, +} from '/@/renderer/components/item-list/item-detail-list/utils'; +import { + pickTableColumns, + SONG_TABLE_COLUMNS, +} from '/@/renderer/components/item-list/item-table-list/default-columns'; +import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state'; +import { columnLabelMap } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; +import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types'; +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 { songsQueries } from '/@/renderer/features/songs/api/songs-api'; +import { AppRoute } from '/@/renderer/router/routes'; +import { useSettingsStore, useShowRatings } from '/@/renderer/store'; +import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils'; +import { SEPARATOR_STRING } from '/@/shared/api/utils'; +import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator'; +import { Skeleton } from '/@/shared/components/skeleton/skeleton'; +import { useDoubleClick } from '/@/shared/hooks/use-double-click'; +import { Album, LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types'; +import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; +import { ItemListKey, Play, TableColumn } from '/@/shared/types/types'; + +const DEFAULT_ROW_HEIGHT = 300; + +const SKELETON_TRACK_ROW_COUNT = 6; + +interface ItemDetailListProps { + currentPage?: number; + data?: unknown[]; + enableHeader?: boolean; + getItem?: (index: number) => unknown; + internalState?: ItemListStateActions; + itemCount?: number; + items?: unknown[]; + onColumnReordered?: ( + columnIdFrom: TableColumn, + columnIdTo: TableColumn, + edge: 'bottom' | 'left' | 'right' | 'top' | null, + ) => void; + onColumnResized?: (columnId: TableColumn, width: number) => void; + onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => Promise | void; + onScrollEnd?: (rowIndex: number) => void; + rowHeight?: number; + scrollOffset?: number; + tableId?: string; +} + +interface RowData { + columnWidthPercents: number[]; + controls?: ItemControls; + data: unknown[]; + defaultRowHeight: number; + enableAlternateRowColors: boolean; + enableHorizontalBorders: boolean; + enableRowHoverHighlight: boolean; + enableVerticalBorders: boolean; + getItem?: (index: number) => unknown; + internalState: ItemListStateActions; + isMutatingFavorite: boolean; + registerSongs: (albumId: string, songs: Song[]) => void; + trackColumns: ItemTableListColumnConfig[]; + trackTableSize: 'compact' | 'default' | 'large'; +} + +interface TrackRowProps { + albumSongs: Song[]; + columns: ItemTableListColumnConfig[]; + columnWidthPercents: number[]; + controls?: ItemControls; + enableAlternateRowColors: boolean; + enableHorizontalBorders: boolean; + enableRowHoverHighlight: boolean; + enableVerticalBorders: boolean; + internalState: ItemListStateActions; + isMutatingFavorite: boolean; + isSongsLoading?: boolean; + rowIndex: number; + size: 'compact' | 'default' | 'large'; + song: Song; +} + +const textAlignFromAlign = (align: ItemTableListColumnConfig['align']) => + align === 'start' ? 'left' : align === 'end' ? 'right' : 'center'; + +const TrackRow = memo( + ({ + albumSongs, + columns, + columnWidthPercents, + controls, + enableAlternateRowColors, + enableHorizontalBorders, + enableRowHoverHighlight, + enableVerticalBorders, + internalState, + isMutatingFavorite, + isSongsLoading, + rowIndex, + size, + song, + }: TrackRowProps) => { + const playerContext = usePlayer(); + const { dragRef, isDragging } = useItemDragDropState({ + enableDrag: true, + internalState, + isDataRow: true, + item: song, + itemType: LibraryItem.SONG, + playerContext, + }); + const [isRowHovered, setIsRowHovered] = useState(false); + const isSelected = useItemSelectionState(internalState, song.id); + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (isSongsLoading || albumSongs.length === 0) return; + internalState.setSelected([song]); + playerContext.addToQueueByData(albumSongs, Play.NOW, song.id); + }, + [albumSongs, internalState, isSongsLoading, playerContext, song], + ); + + const handleRowClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.ctrlKey || e.metaKey) { + internalState.toggleSelected(song); + } else if (e.shiftKey) { + const selectedItems = internalState.getSelected(); + const lastSelectedItem = selectedItems[selectedItems.length - 1]; + + if ( + lastSelectedItem && + typeof lastSelectedItem === 'object' && + lastSelectedItem !== null + ) { + const data = internalState.getData(); + const validData = data.filter((d) => d && typeof d === 'object'); + const lastRowId = internalState.extractRowId(lastSelectedItem); + if (!lastRowId) { + internalState.setSelected([song]); + return; + } + const lastIndex = internalState.findItemIndex(lastRowId); + const currentIndex = internalState.findItemIndex(song.id); + + if (lastIndex !== -1 && currentIndex !== -1) { + const startIndex = Math.min(lastIndex, currentIndex); + const stopIndex = Math.max(lastIndex, currentIndex); + const rangeItems: ItemListStateItemWithRequiredProperties[] = []; + for (let i = startIndex; i <= stopIndex; i++) { + const rangeItem = validData[i]; + if ( + rangeItem && + typeof rangeItem === 'object' && + '_serverId' in rangeItem && + '_itemType' in rangeItem + ) { + const rangeRowId = internalState.extractRowId(rangeItem); + if (rangeRowId) { + rangeItems.push( + rangeItem as ItemListStateItemWithRequiredProperties, + ); + } + } + } + const currentSelected = internalState.getSelected(); + const newSelected = [ + ...currentSelected.filter( + ( + selectedItem, + ): selectedItem is ItemListStateItemWithRequiredProperties => + typeof selectedItem === 'object' && selectedItem !== null, + ), + ]; + rangeItems.forEach((rangeItem) => { + const rangeRowId = internalState.extractRowId(rangeItem); + if ( + rangeRowId && + !newSelected.some( + (selected) => + internalState.extractRowId(selected) === rangeRowId, + ) + ) { + newSelected.push(rangeItem); + } + }); + internalState.setSelected(newSelected); + } else { + internalState.setSelected([song]); + } + } else { + internalState.setSelected([song]); + } + } else { + const selected = internalState.getSelected(); + const onlyThisSelected = + selected.length === 1 && + internalState.extractRowId(selected[0]) === song.id; + internalState.setSelected(onlyThisSelected ? [] : [song]); + } + }, + [internalState, song], + ); + + const handleClick = useDoubleClick({ + onDoubleClick: handleDoubleClick, + onSingleClick: handleRowClick, + }); + + const handleContextMenu = useCallback( + (event: React.MouseEvent) => { + if (isSongsLoading || !controls?.onMore) return; + event.preventDefault(); + const index = internalState.findItemIndex(song.id); + controls.onMore({ + event, + index, + internalState, + item: song, + itemType: LibraryItem.SONG, + }); + }, + [controls, internalState, isSongsLoading, song], + ); + + return ( +
0, + [styles.trackRowHoverHighlightEnabled]: enableRowHoverHighlight, + [styles.trackRowSelected]: isSelected, + [styles.trackRowSizeCompact]: size === 'compact', + [styles.trackRowSizeDefault]: size === 'default', + [styles.trackRowSizeLarge]: size === 'large', + [styles.trackRowWithHorizontalBorder]: rowIndex > 0, + })} + onClick={handleClick} + onContextMenu={handleContextMenu} + onMouseEnter={() => setIsRowHovered(true)} + onMouseLeave={() => setIsRowHovered(false)} + ref={dragRef ?? undefined} + role="row" + > + {columns.map((col, colIndex) => { + const percent = columnWidthPercents[colIndex] ?? 0; + const { fixedWidth, isFixedColumn } = getTrackColumnFixed(col.id); + const style: React.CSSProperties = { + flex: isFixedColumn ? `0 0 ${fixedWidth}px` : `${percent} 1 0`, + minWidth: isFixedColumn ? fixedWidth : 0, + textAlign: textAlignFromAlign(col.align), + }; + const CellComponent = getDetailListCellComponent(col.id); + const isTitleColumn = col.id === TableColumn.TITLE; + const isImageColumn = col.id === TableColumn.IMAGE; + const isIconActionColumn = isNoHorizontalPaddingColumn(col.id); + const showHoverContent = shouldShowHoverOnlyColumnContent( + col.id, + isRowHovered, + song, + ); + + const content = isSongsLoading ? null : showHoverContent ? ( + + ) : ( + '\u00A0' + ); + + const isLastColumn = colIndex === columns.length - 1; + return ( +
+ {content} +
+ ); + })} +
+ ); + }, +); + +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 }> = []; + let releaseStr = ''; + if (item.releaseDate) { + if (item.originalDate && item.originalDate !== item.releaseDate) { + releaseStr = `${formatDateAbsoluteUTC(item.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(item.releaseDate)}`; + } else { + releaseStr = formatDateAbsoluteUTC(item.releaseDate); + } + } else if (item.releaseYear != null) { + releaseStr = String(item.releaseYear); + } + if (releaseStr) parts.push({ content: releaseStr, key: 'release' }); + 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(SEPARATOR_STRING); + if (tracksAndDuration) { + parts.push({ content: tracksAndDuration, key: 'tracks' }); + } + 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', + }); + } + 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 ? ( + <>  + ) : ( + + )} +
+ {metadataExtra && metadataExtra.length > 0 && ( +
+ {metadataExtra.map((part) => ( +
+ {part.content} +
+ ))} +
+ )} +
+ ); + }, + (prev, next) => prev.item === next.item, +); + +MetadataSection.displayName = 'MetadataSection'; + +interface ItemDetailSkeletonRowProps { + defaultRowHeight: number; + enableAlternateRowColors: boolean; + enableHorizontalBorders: boolean; + enableVerticalBorders: boolean; + trackTableSize: 'compact' | 'default' | 'large'; +} + +const ItemDetailSkeletonRow = memo( + ({ + defaultRowHeight, + enableAlternateRowColors, + enableHorizontalBorders, + enableVerticalBorders, + trackTableSize, + }: ItemDetailSkeletonRowProps) => { + const heightStyle = { + height: defaultRowHeight, + minHeight: defaultRowHeight, + overflow: 'hidden' as const, + }; + return ( + <> +
+
+
+ + + +
+
+
+
+
+
+ {Array.from({ length: SKELETON_TRACK_ROW_COUNT }).map((_, i) => ( +
0, + [styles.trackRowSizeCompact]: trackTableSize === 'compact', + [styles.trackRowSizeDefault]: trackTableSize === 'default', + [styles.trackRowSizeLarge]: trackTableSize === 'large', + [styles.trackRowWithHorizontalBorder]: i > 0, + })} + key={i} + role="row" + > +
+
+ ))} +
+
+
+ + ); + }, +); + +ItemDetailSkeletonRow.displayName = 'ItemDetailSkeletonRow'; + +type RowContentProps = Omit, 'style'>; + +const RowContent = memo( + ({ + columnWidthPercents, + controls, + data, + defaultRowHeight, + enableAlternateRowColors, + enableHorizontalBorders, + enableRowHoverHighlight, + enableVerticalBorders, + getItem, + index, + internalState, + isMutatingFavorite, + registerSongs, + trackColumns, + trackTableSize, + }: RowContentProps) => { + const item = useMemo(() => { + if (getItem) { + return getItem(index) as Album | undefined; + } + + return (data?.[index] as Album | undefined) || undefined; + }, [data, getItem, index]); + + const songListQuery = useMemo(() => { + if (!item?.id || !item?._serverId) return null; + return { + query: { + albumIds: [item.id], + limit: -1, + sortBy: SongListSort.ALBUM, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId: item?._serverId || '', + }; + }, [item]); + + const { data: songListData, isLoading: isSongsQueryLoading } = useQuery({ + enabled: !!songListQuery, + ...(songListQuery + ? songsQueries.list(songListQuery) + : { + queryFn: async () => ({ items: [], startIndex: 0, totalRecordCount: 0 }), + queryKey: ['item-detail', 'list', 'disabled'], + }), + }); + + const songItems = songListData?.items; + const isSongsLoading = !!item && isSongsQueryLoading && !songItems?.length; + + const songs = useMemo(() => { + return ( + songItems || + Array.from({ length: item?.songCount || 0 }, (_, i) => ({ + duration: 0, + id: `${item?.id}-${i}`, + name: '', + trackNumber: i + 1, + })) + ); + }, [songItems, item?.id, item?.songCount]); + + useEffect(() => { + if (item?.id && songItems?.length) { + registerSongs(item.id, songItems as Song[]); + } + }, [item?.id, registerSongs, songItems]); + + if (!item) { + return ( + + ); + } + + return ( + <> +
+ +
+ +
+
+ {songs.map((song, rowIndex) => ( + + ))} +
+
+ + ); + }, + (prev, next) => + prev.index === next.index && + prev.data === next.data && + prev.columnWidthPercents === next.columnWidthPercents && + prev.defaultRowHeight === next.defaultRowHeight && + prev.enableAlternateRowColors === next.enableAlternateRowColors && + prev.enableHorizontalBorders === next.enableHorizontalBorders && + prev.enableRowHoverHighlight === next.enableRowHoverHighlight && + prev.enableVerticalBorders === next.enableVerticalBorders && + prev.getItem === next.getItem && + prev.internalState === next.internalState && + prev.isMutatingFavorite === next.isMutatingFavorite && + prev.controls === next.controls && + prev.registerSongs === next.registerSongs && + prev.trackColumns === next.trackColumns && + prev.trackTableSize === next.trackTableSize, +); + +RowContent.displayName = 'RowContent'; + +const RowComponent = memo((props: RowComponentProps): ReactElement => { + const { style, ...rowContentProps } = props; + return ( +
+ +
+ ); +}); + +RowComponent.displayName = 'ItemDetailRow'; + +interface DetailListHeaderCellProps { + columnId: TableColumn; + columnWidthPercents: number[]; + enableColumnResize?: boolean; + enableVerticalBorders: boolean; + isLastColumn: boolean; + onColumnReordered?: (args: { + columnIdFrom: TableColumn; + columnIdTo: TableColumn; + edge: Edge | null; + }) => void; + onColumnResized?: (columnId: TableColumn, width: number) => void; + tableId: string; + trackColumns: ItemTableListColumnConfig[]; +} + +const DetailListHeaderCell = memo( + ({ + columnId, + columnWidthPercents, + enableColumnResize, + onColumnReordered, + onColumnResized, + tableId, + trackColumns, + }: DetailListHeaderCellProps) => { + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [isDraggedOver, setIsDraggedOver] = useState(null); + const colIndex = trackColumns.findIndex((c) => c.id === columnId); + const col = colIndex >= 0 ? trackColumns[colIndex] : null; + const percent = col ? (columnWidthPercents[colIndex] ?? 0) : 0; + const { fixedWidth, isFixedColumn } = getTrackColumnFixed(columnId); + const currentWidth = col?.width ?? (fixedWidth || 100); + const showResizeHandle = + enableColumnResize && !isFixedColumn && !col?.autoSize && onColumnResized; + + useEffect(() => { + if (!containerRef.current || !onColumnReordered) { + return; + } + + const handleReorder = ( + columnIdFrom: TableColumn, + columnIdTo: TableColumn, + edge: Edge | null, + ) => { + onColumnReordered({ columnIdFrom, columnIdTo, edge }); + }; + + return combine( + draggable({ + element: containerRef.current, + getInitialData: () => { + const data = dndUtils.generateDragData( + { + id: [columnId], + operation: [DragOperation.REORDER], + type: DragTarget.TABLE_COLUMN, + }, + { tableId }, + ); + return data; + }, + onDragStart: () => setIsDragging(true), + onDrop: () => setIsDragging(false), + onGenerateDragPreview: (data) => { + disableNativeDragPreview({ nativeSetDragImage: data.nativeSetDragImage }); + }, + }), + dropTargetForElements({ + canDrop: (args) => { + const data = args.source.data as unknown as DragData; + const sourceTableId = (data.metadata as { tableId?: string })?.tableId; + const isSelf = (args.source.data.id as string[])[0] === columnId; + const isSameTable = sourceTableId === tableId; + return ( + dndUtils.isDropTarget(data.type, [DragTarget.TABLE_COLUMN]) && + !isSelf && + isSameTable + ); + }, + element: containerRef.current, + getData: ({ element, input }) => { + const data = dndUtils.generateDragData( + { + id: [columnId], + operation: [DragOperation.REORDER], + type: DragTarget.TABLE_COLUMN, + }, + { tableId }, + ); + return attachClosestEdge(data, { + allowedEdges: ['left', 'right'], + element, + input, + }); + }, + onDrag: (args) => { + const closestEdgeOfTarget = extractClosestEdge(args.self.data); + setIsDraggedOver(closestEdgeOfTarget); + }, + onDragLeave: () => setIsDraggedOver(null), + onDrop: (args) => { + const closestEdgeOfTarget = extractClosestEdge(args.self.data); + const from = args.source.data.id as string[]; + const to = args.self.data.id as string[]; + + handleReorder( + from[0] as TableColumn, + to[0] as TableColumn, + closestEdgeOfTarget, + ); + setIsDraggedOver(null); + }, + }), + ); + }, [columnId, onColumnReordered, tableId]); + + const style: React.CSSProperties = { + flex: isFixedColumn ? `0 0 ${fixedWidth}px` : `${percent} 1 0`, + justifyContent: colTypeToJustifyContentMap[col?.align ?? 'start'], + minWidth: isFixedColumn ? fixedWidth : 0, + textAlign: colTypeToAlignMap[col?.align ?? 'start'] as 'center' | 'left' | 'right', + }; + + const handleResize = useCallback( + (id: TableColumn, width: number) => { + onColumnResized?.(id, width); + }, + [onColumnResized], + ); + + return ( +
+ {columnLabelMap[columnId] ?? ''} + {showResizeHandle && ( + + )} +
+ ); + }, +); + +DetailListHeaderCell.displayName = 'DetailListHeaderCell'; + +interface DetailListColumnResizeHandleProps { + columnId: TableColumn; + initialWidth: number; + onResize: (columnId: TableColumn, width: number) => void; + side: 'left' | 'right'; +} + +const DetailListColumnResizeHandle = ({ + columnId, + initialWidth, + onResize, + side, +}: DetailListColumnResizeHandleProps) => { + const [isDragging, setIsDragging] = useState(false); + const handleRef = useRef(null); + const startWidthRef = useRef(initialWidth); + const startXRef = useRef(0); + const finalWidthRef = useRef(initialWidth); + + useEffect(() => { + if (!isDragging) { + startWidthRef.current = initialWidth; + } + }, [initialWidth, isDragging]); + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (event: MouseEvent) => { + const deltaX = event.clientX - startXRef.current; + const newWidth = Math.min(Math.max(10, startWidthRef.current + deltaX), 1000); + finalWidthRef.current = newWidth; + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + onResize(columnId, finalWidthRef.current); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging, columnId, onResize]); + + const handleMouseDown = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(true); + startWidthRef.current = initialWidth; + startXRef.current = event.clientX; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }; + + return ( +
+ ); +}; + +interface DetailListHeaderProps { + columnWidthPercents: number[]; + enableColumnReorder?: boolean; + enableColumnResize?: boolean; + enableVerticalBorders: boolean; + headerLeftRef: React.RefObject; + onColumnReordered?: (args: { + columnIdFrom: TableColumn; + columnIdTo: TableColumn; + edge: Edge | null; + }) => void; + onColumnResized?: (columnId: TableColumn, width: number) => void; + tableId: string; + trackColumns: ItemTableListColumnConfig[]; + trackTableSize: 'compact' | 'default' | 'large'; +} + +const colTypeToAlignMap = { + center: 'center', + end: 'right', + start: 'left', +}; + +const colTypeToJustifyContentMap = { + center: 'center', + end: 'flex-end', + start: 'flex-start', +}; + +const DetailListHeader = memo( + ({ + columnWidthPercents, + enableColumnReorder, + enableColumnResize, + enableVerticalBorders, + headerLeftRef, + onColumnReordered, + onColumnResized, + tableId, + trackColumns, + trackTableSize, + }: DetailListHeaderProps) => { + return ( +
+
+ +
+
+
+ {trackColumns.map((col, colIndex) => { + const isLastColumn = colIndex === trackColumns.length - 1; + + if ( + (enableColumnResize && onColumnResized) || + (enableColumnReorder && onColumnReordered) + ) { + return ( + + ); + } + + const percent = columnWidthPercents[colIndex] ?? 0; + const { fixedWidth, isFixedColumn } = getTrackColumnFixed(col.id); + const style: React.CSSProperties = { + flex: isFixedColumn ? `0 0 ${fixedWidth}px` : `${percent} 1 0`, + justifyContent: colTypeToJustifyContentMap[col.align], + minWidth: isFixedColumn ? fixedWidth : 0, + textAlign: colTypeToAlignMap[col.align] as + | 'center' + | 'left' + | 'right', + }; + + return ( +
+ + {columnLabelMap[col.id] ?? ''} + +
+ ); + })} +
+
+
+ ); + }, +); + +DetailListHeader.displayName = 'DetailListHeader'; + +const SCROLL_END_DEBOUNCE_MS = 150; + +const DEFAULT_DETAIL_TABLE_ID = 'album-detail'; + +export const ItemDetailList = ({ + currentPage, + data, + enableHeader = true, + getItem, + itemCount: externalItemCount, + items, + onColumnReordered, + onColumnResized, + onRangeChanged, + onScrollEnd, + tableId = DEFAULT_DETAIL_TABLE_ID, +}: ItemDetailListProps) => { + const containerRef = useRef(null); + const listRef = useListRef(null); + const lastVisibleStartIndexRef = useRef(0); + const queryClient = useQueryClient(); + + const controls = useDefaultItemListControls({ + onColumnReordered, + onColumnResized, + }); + const isMutatingCreateFavorite = useIsMutatingCreateFavorite(); + const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite(); + const isMutatingFavorite = isMutatingCreateFavorite || isMutatingDeleteFavorite; + + const rowHeight = useDynamicRowHeight({ + defaultRowHeight: DEFAULT_ROW_HEIGHT, + }); + + const isInfinite = data !== undefined || getItem !== undefined; + const isPaginated = items !== undefined || currentPage !== undefined; + + const dataSource = useMemo(() => { + if (isInfinite && data) { + return data; + } + if (isPaginated && items) { + return items; + } + return []; + }, [data, isInfinite, isPaginated, items]); + + const itemCount = useMemo(() => { + if (externalItemCount !== undefined) { + return externalItemCount; + } + return dataSource.length; + }, [dataSource.length, externalItemCount]); + + // Accumulate songs from each row for selection/drag state (keyed by album id) + const songsByAlbumRef = useRef>(new Map()); + const registerSongs = useCallback((albumId: string, songs: Song[]) => { + songsByAlbumRef.current.set(albumId, songs); + }, []); + + // Flattened songs in album order for ItemListState (selection/drag are per-song) + const getDataFn = useCallback(() => { + const map = songsByAlbumRef.current; + return dataSource.flatMap((album) => map.get((album as Album).id) ?? []); + }, [dataSource]); + + const extractRowIdSong = useCallback((item: unknown) => (item as Song).id, []); + + const internalState = useItemListState(getDataFn, extractRowIdSong); + + const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.ALBUM]?.detail); + const trackColumns = useMemo((): ItemTableListColumnConfig[] => { + const raw = tableConfig?.columns; + if (raw && raw.length > 0) { + return parseTableColumns(raw); + } + return pickTableColumns({ + columns: SONG_TABLE_COLUMNS, + enabledColumns: [ + TableColumn.TRACK_NUMBER, + TableColumn.TITLE, + TableColumn.DURATION, + TableColumn.USER_FAVORITE, + TableColumn.USER_RATING, + ], + }); + }, [tableConfig?.columns]); + const trackTableSize = tableConfig?.size ?? 'default'; + const enableRowHoverHighlight = tableConfig?.enableRowHoverHighlight ?? true; + const enableAlternateRowColors = tableConfig?.enableAlternateRowColors ?? false; + const enableHorizontalBorders = tableConfig?.enableHorizontalBorders ?? false; + const enableVerticalBorders = tableConfig?.enableVerticalBorders ?? false; + + const columnWidthPercents = useMemo(() => { + const total = trackColumns.reduce((sum, c) => sum + c.width, 0); + if (total <= 0) { + return trackColumns.map(() => 100 / Math.max(1, trackColumns.length)); + } + return trackColumns.map((c) => (c.width / total) * 100); + }, [trackColumns]); + + const headerLeftRef = useRef(null); + const dataSourceRef = useRef(dataSource); + dataSourceRef.current = dataSource; + const lastHeaderNameRef = useRef(''); + + const handleRowsRendered = useCallback( + (range: { startIndex: number; stopIndex: number }) => { + lastVisibleStartIndexRef.current = range.startIndex; + const el = headerLeftRef.current; + if (el) { + const album = ( + getItem ? getItem(range.startIndex) : dataSourceRef.current[range.startIndex] + ) as Album | undefined; + const name = album?.name ?? ''; + if (name) { + lastHeaderNameRef.current = name; + el.textContent = name; + el.setAttribute('data-title', name); + el.title = name; + } else { + el.textContent = lastHeaderNameRef.current; + el.setAttribute('data-title', lastHeaderNameRef.current); + el.title = lastHeaderNameRef.current; + } + } + if (onRangeChanged) { + onRangeChanged(range); + } + }, + [getItem, onRangeChanged], + ); + + const throttledHandleRowsRendered = useMemo( + () => + throttle(handleRowsRendered, 150, { + leading: true, + trailing: true, + }), + [handleRowsRendered], + ); + + useEffect(() => { + return () => { + throttledHandleRowsRendered.cancel(); + }; + }, [throttledHandleRowsRendered]); + + const rowProps = useMemo( + () => ({ + columnWidthPercents, + controls, + data: dataSource, + defaultRowHeight: DEFAULT_ROW_HEIGHT, + enableAlternateRowColors, + enableHorizontalBorders, + enableRowHoverHighlight, + enableVerticalBorders, + getItem, + internalState, + isMutatingFavorite, + queryClient, + registerSongs, + trackColumns, + trackTableSize, + }), + [ + columnWidthPercents, + controls, + dataSource, + enableAlternateRowColors, + enableHorizontalBorders, + enableRowHoverHighlight, + enableVerticalBorders, + getItem, + internalState, + isMutatingFavorite, + queryClient, + registerSongs, + trackColumns, + trackTableSize, + ], + ); + + const [initialize, osInstance] = useOverlayScrollbars({ + defer: false, + events: { + initialized(osInstance) { + const { viewport } = osInstance.elements(); + viewport.style.overflowX = `var(--os-viewport-overflow-x)`; + }, + }, + options: { + overflow: { x: 'hidden', y: 'scroll' }, + paddingAbsolute: true, + scrollbars: { + autoHide: 'leave', + autoHideDelay: 500, + pointers: ['mouse', 'pen', 'touch'], + theme: 'feishin-os-scrollbar', + visibility: 'visible', + }, + }, + }); + + useEffect(() => { + const { current: container } = containerRef; + + if (!container || !container.firstElementChild) { + return; + } + + const viewport = container.firstElementChild as HTMLElement; + + initialize({ + elements: { viewport }, + target: container, + }); + + let scrollEndTimeoutId: null | ReturnType = null; + const handleScroll = () => { + if (scrollEndTimeoutId) clearTimeout(scrollEndTimeoutId); + scrollEndTimeoutId = setTimeout(() => { + scrollEndTimeoutId = null; + onScrollEnd?.(lastVisibleStartIndexRef.current); + }, SCROLL_END_DEBOUNCE_MS); + }; + + if (onScrollEnd) { + viewport.addEventListener('scroll', handleScroll, { passive: true }); + } + + return () => { + if (onScrollEnd) { + viewport.removeEventListener('scroll', handleScroll); + if (scrollEndTimeoutId) clearTimeout(scrollEndTimeoutId); + } + osInstance()?.destroy(); + }; + }, [initialize, onScrollEnd, osInstance]); + + return ( +
+ {enableHeader && ( + controls.onColumnResized?.({ columnId, width }) + : undefined + } + tableId={tableId} + trackColumns={trackColumns} + trackTableSize={trackTableSize} + /> + )} +
+ ) => ReactElement + } + rowCount={itemCount} + rowHeight={rowHeight} + rowProps={rowProps} + /> +
+
+ ); +}; diff --git a/src/renderer/components/item-list/item-detail-list/utils.ts b/src/renderer/components/item-list/item-detail-list/utils.ts new file mode 100644 index 000000000..4b52ea975 --- /dev/null +++ b/src/renderer/components/item-list/item-detail-list/utils.ts @@ -0,0 +1,65 @@ +import { TableColumn } from '/@/shared/types/types'; + +const FIXED_TRACK_COLUMN_WIDTHS: Partial> = { + [TableColumn.ACTIONS]: 32, + [TableColumn.BIT_DEPTH]: 80, + [TableColumn.BIT_RATE]: 80, + [TableColumn.BPM]: 56, + [TableColumn.CHANNELS]: 80, + [TableColumn.CODEC]: 80, + [TableColumn.DATE_ADDED]: 128, + [TableColumn.DISC_NUMBER]: 36, + [TableColumn.DURATION]: 72, + [TableColumn.RELEASE_DATE]: 128, + [TableColumn.SAMPLE_RATE]: 90, + [TableColumn.TRACK_NUMBER]: 56, + [TableColumn.USER_FAVORITE]: 32, + [TableColumn.USER_RATING]: 64, + [TableColumn.YEAR]: 56, +}; + +const HOVER_ONLY_COLUMNS: TableColumn[] = [ + TableColumn.ACTIONS, + TableColumn.USER_FAVORITE, + TableColumn.USER_RATING, +]; + +const NO_HORIZONTAL_PADDING_COLUMNS: TableColumn[] = [ + TableColumn.ACTIONS, + TableColumn.USER_FAVORITE, + TableColumn.USER_RATING, +]; + +export function getTrackColumnFixed(columnId: TableColumn): { + fixedWidth: number; + isFixedColumn: boolean; +} { + const width = FIXED_TRACK_COLUMN_WIDTHS[columnId]; + return width !== undefined + ? { fixedWidth: width, isFixedColumn: true } + : { fixedWidth: 0, isFixedColumn: false }; +} + +export function isNoHorizontalPaddingColumn(columnId: TableColumn): boolean { + return NO_HORIZONTAL_PADDING_COLUMNS.includes(columnId); +} + +export function isTrackColumnHoverOnly(columnId: TableColumn): boolean { + return HOVER_ONLY_COLUMNS.includes(columnId); +} + +export function shouldShowHoverOnlyColumnContent( + columnId: TableColumn, + isRowHovered: boolean, + song: { userFavorite?: boolean | null; userRating?: null | number }, +): boolean { + if (!HOVER_ONLY_COLUMNS.includes(columnId)) { + return true; + } + + return ( + isRowHovered || + (columnId === TableColumn.USER_FAVORITE && song.userFavorite !== false) || + (columnId === TableColumn.USER_RATING && song.userRating != null) + ); +} diff --git a/src/renderer/components/item-list/item-table-list/columns/image-column.tsx b/src/renderer/components/item-list/item-table-list/columns/image-column.tsx index 4b8b36670..d38d41ae7 100644 --- a/src/renderer/components/item-list/item-table-list/columns/image-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/image-column.tsx @@ -54,6 +54,7 @@ const ImageColumnBase = (props: ItemTableListInnerColumn) => { itemType: props.itemType, meta: { playType, + singleSongOnly: true, }, }); return; diff --git a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx index 2acb9e032..2dea51347 100644 --- a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx @@ -58,6 +58,7 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => { itemType: props.itemType, meta: { playType, + singleSongOnly: true, }, }); return; @@ -200,6 +201,7 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) => itemType: props.itemType, meta: { playType, + singleSongOnly: true, }, }); return; diff --git a/src/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state.tsx b/src/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state.tsx index 3f42a6551..ee586cfb8 100644 --- a/src/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state.tsx +++ b/src/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state.tsx @@ -7,8 +7,8 @@ import { useDragDrop } from '/@/renderer/hooks/use-drag-drop'; import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types'; import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop'; -interface DragDropState { - dragRef: null | React.Ref; +interface DragDropState { + dragRef: null | React.Ref; isDraggedOver: 'bottom' | 'top' | null; isDragging: boolean; } @@ -23,7 +23,7 @@ interface UseItemDragDropStateProps { playlistId?: string; } -export const useItemDragDropState = ({ +export const useItemDragDropState = ({ enableDrag, internalState, isDataRow, @@ -31,14 +31,14 @@ export const useItemDragDropState = ({ itemType, playerContext, playlistId, -}: UseItemDragDropStateProps): DragDropState => { +}: UseItemDragDropStateProps): DragDropState => { const shouldEnableDrag = enableDrag && isDataRow && !!item; const { isDraggedOver, isDragging: isDraggingLocal, ref: dragRef, - } = useDragDrop({ + } = useDragDrop({ drag: { getId: () => { if (!item || !isDataRow) { diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css index 58d0846f3..2306b8ab6 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css @@ -26,6 +26,11 @@ padding: var(--theme-spacing-xs) var(--theme-spacing-xl); } +.container.no-horizontal-padding { + padding-right: 0; + padding-left: 0; +} + .container.center { align-items: center; text-align: center; @@ -205,6 +210,11 @@ padding: 0 var(--theme-spacing-xl); } +.header-container.no-horizontal-padding { + padding-right: 0; + padding-left: 0; +} + .header-dragging { cursor: grabbing; opacity: 0.5; diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index 7d1923e3e..54ca49524 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -26,6 +26,7 @@ import styles from './item-table-list-column.module.css'; import i18n from '/@/i18n/i18n'; import { useItemSelectionState } from '/@/renderer/components/item-list/helpers/item-list-state'; +import { isNoHorizontalPaddingColumn } from '/@/renderer/components/item-list/item-detail-list/utils'; import { ActionsColumn } from '/@/renderer/components/item-list/item-table-list/columns/actions-column'; import { AlbumArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-artists-column'; import { AlbumColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-column'; @@ -479,6 +480,7 @@ export const TableColumnTextContainer = ( [styles.dragging]: isDataRow && isDragging, [styles.large]: props.size === 'large', [styles.left]: props.columns[props.columnIndex].align === 'start', + [styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type), [styles.paddingLg]: props.cellPadding === 'lg', [styles.paddingMd]: props.cellPadding === 'md', [styles.paddingSm]: props.cellPadding === 'sm', @@ -632,6 +634,7 @@ export const TableColumnContainer = ( [styles.dragging]: isDataRow && isDragging, [styles.large]: props.size === 'large', [styles.left]: props.columns[props.columnIndex].align === 'start', + [styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type), [styles.paddingLg]: props.cellPadding === 'lg', [styles.paddingMd]: props.cellPadding === 'md', [styles.paddingSm]: props.cellPadding === 'sm', @@ -850,6 +853,7 @@ export const TableColumnHeaderContainer = ( [styles.headerDraggedOverLeft]: isDraggedOver === 'left', [styles.headerDraggedOverRight]: isDraggedOver === 'right', [styles.headerDragging]: isDragging, + [styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type), [styles.paddingLg]: props.cellPadding === 'lg', [styles.paddingMd]: props.cellPadding === 'md', [styles.paddingSm]: props.cellPadding === 'sm', @@ -881,7 +885,7 @@ export const TableColumnHeaderContainer = ( ); }; -const columnLabelMap: Record = { +export const columnLabelMap: Record = { [TableColumn.ACTIONS]: ( diff --git a/src/renderer/components/item-list/types.ts b/src/renderer/components/item-list/types.ts index cb7c8565f..53d27fd17 100644 --- a/src/renderer/components/item-list/types.ts +++ b/src/renderer/components/item-list/types.ts @@ -98,7 +98,7 @@ export interface ItemListTableComponentProps extends ItemListComponentPr enableRowHoverHighlight?: boolean; enableSelection?: boolean; enableVerticalBorders?: boolean; - size?: 'compact' | 'default'; + size?: 'compact' | 'default' | 'large'; } export interface ItemTableListColumnConfig { diff --git a/src/renderer/features/albums/components/album-detail-header.tsx b/src/renderer/features/albums/components/album-detail-header.tsx index 9d3bd8331..b15ff85c3 100644 --- a/src/renderer/features/albums/components/album-detail-header.tsx +++ b/src/renderer/features/albums/components/album-detail-header.tsx @@ -233,8 +233,8 @@ export const AlbumDetailHeader = forwardRef((_props, ref) => { {metadataItems.map((item, index) => ( {index > 0 && ( - - • + + )} {item.value} diff --git a/src/renderer/features/albums/components/album-list-content.tsx b/src/renderer/features/albums/components/album-list-content.tsx index e530772d8..18f29b7f5 100644 --- a/src/renderer/features/albums/components/album-list-content.tsx +++ b/src/renderer/features/albums/components/album-list-content.tsx @@ -36,6 +36,18 @@ const AlbumListPaginatedTable = lazy(() => })), ); +const AlbumListInfiniteDetail = lazy(() => + import('/@/renderer/features/albums/components/album-list-infinite-detail').then((module) => ({ + default: module.AlbumListInfiniteDetail, + })), +); + +const AlbumListPaginatedDetail = lazy(() => + import('/@/renderer/features/albums/components/album-list-paginated-detail').then((module) => ({ + default: module.AlbumListPaginatedDetail, + })), +); + const AlbumListFilters = () => { return ( @@ -62,13 +74,16 @@ export const AlbumListContent = () => { }; const AlbumListSuspenseContainer = () => { - const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ALBUM); + const { detail, display, grid, itemsPerPage, pagination, table } = useListSettings( + ItemListKey.ALBUM, + ); const { customFilters } = useListContext(); return ( }> { export type OverrideAlbumListQuery = Omit, 'limit' | 'startIndex'>; export const AlbumListView = ({ + detail, display, grid, itemsPerPage, overrideQuery, pagination, table, -}: ItemListSettings & { overrideQuery?: OverrideAlbumListQuery }) => { +}: ItemListSettings & { + detail?: ItemListSettings['detail']; + overrideQuery?: OverrideAlbumListQuery; +}) => { const server = useCurrentServer(); const { pageKey } = useListContext(); @@ -179,6 +198,32 @@ export const AlbumListView = ({ return null; } } + case ListDisplayType.DETAIL: { + switch (pagination) { + case ListPaginationType.INFINITE: { + return ( + + ); + } + case ListPaginationType.PAGINATED: { + return ( + + ); + } + default: + return null; + } + } } return null; diff --git a/src/renderer/features/albums/components/album-list-header-filters.tsx b/src/renderer/features/albums/components/album-list-header-filters.tsx index 207a6f7aa..b3d2c165a 100644 --- a/src/renderer/features/albums/components/album-list-header-filters.tsx +++ b/src/renderer/features/albums/components/album-list-header-filters.tsx @@ -1,7 +1,10 @@ import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; +import { + ALBUM_TABLE_COLUMNS, + SONG_TABLE_COLUMNS, +} from '/@/renderer/components/item-list/item-table-list/default-columns'; import { useListContext } from '/@/renderer/context/list-context'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; @@ -92,8 +95,15 @@ export const AlbumListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarge - + diff --git a/src/renderer/features/albums/components/album-list-infinite-detail.tsx b/src/renderer/features/albums/components/album-list-infinite-detail.tsx new file mode 100644 index 000000000..ed3a029d0 --- /dev/null +++ b/src/renderer/features/albums/components/album-list-infinite-detail.tsx @@ -0,0 +1,69 @@ +import { UseSuspenseQueryOptions } from '@tanstack/react-query'; + +import { api } from '/@/renderer/api'; +import { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader'; +import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder'; +import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize'; +import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list'; +import { ItemListComponentProps } from '/@/renderer/components/item-list/types'; +import { albumQueries } from '/@/renderer/features/albums/api/album-api'; +import { + AlbumListQuery, + AlbumListSort, + LibraryItem, + SortOrder, +} from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +interface AlbumListInfiniteDetailProps extends ItemListComponentProps { + enableHeader?: boolean; +} + +export const AlbumListInfiniteDetail = ({ + enableHeader = true, + itemsPerPage = 100, + query = { + sortBy: AlbumListSort.NAME, + sortOrder: SortOrder.ASC, + }, + serverId, +}: AlbumListInfiniteDetailProps) => { + const listCountQuery = albumQueries.listCount({ + query: { ...query }, + serverId: serverId, + }) as UseSuspenseQueryOptions; + + const listQueryFn = api.controller.getAlbumList; + + const { handleColumnReordered } = useItemListColumnReorder({ + itemListKey: ItemListKey.ALBUM, + tableKey: 'detail', + }); + + const { handleColumnResized } = useItemListColumnResize({ + itemListKey: ItemListKey.ALBUM, + tableKey: 'detail', + }); + + const { getItem, itemCount, loadedItems, onRangeChanged } = useItemListInfiniteLoader({ + eventKey: ItemListKey.ALBUM, + itemsPerPage, + itemType: LibraryItem.ALBUM, + listCountQuery, + listQueryFn, + query, + serverId, + }); + + return ( + + ); +}; diff --git a/src/renderer/features/albums/components/album-list-paginated-detail.tsx b/src/renderer/features/albums/components/album-list-paginated-detail.tsx new file mode 100644 index 000000000..763e9e396 --- /dev/null +++ b/src/renderer/features/albums/components/album-list-paginated-detail.tsx @@ -0,0 +1,80 @@ +import { UseSuspenseQueryOptions } from '@tanstack/react-query'; + +import { api } from '/@/renderer/api'; +import { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader'; +import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder'; +import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize'; +import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list'; +import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination'; +import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination'; +import { ItemListComponentProps } from '/@/renderer/components/item-list/types'; +import { albumQueries } from '/@/renderer/features/albums/api/album-api'; +import { + AlbumListQuery, + AlbumListSort, + LibraryItem, + SortOrder, +} from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +interface AlbumListPaginatedDetailProps extends ItemListComponentProps { + enableHeader?: boolean; +} + +export const AlbumListPaginatedDetail = ({ + enableHeader = true, + itemsPerPage = 100, + query = { + sortBy: AlbumListSort.NAME, + sortOrder: SortOrder.ASC, + }, + serverId, +}: AlbumListPaginatedDetailProps) => { + const listCountQuery = albumQueries.listCount({ + query: { ...query }, + serverId: serverId, + }) as UseSuspenseQueryOptions; + + const listQueryFn = api.controller.getAlbumList; + + const { handleColumnReordered } = useItemListColumnReorder({ + itemListKey: ItemListKey.ALBUM, + tableKey: 'detail', + }); + + const { handleColumnResized } = useItemListColumnResize({ + itemListKey: ItemListKey.ALBUM, + tableKey: 'detail', + }); + + const { currentPage, onChange } = useItemListPagination(); + + const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({ + currentPage, + eventKey: ItemListKey.ALBUM, + itemsPerPage, + itemType: LibraryItem.ALBUM, + listCountQuery, + listQueryFn, + query, + serverId, + }); + + return ( + + + + ); +}; diff --git a/src/renderer/features/albums/components/joined-artists.tsx b/src/renderer/features/albums/components/joined-artists.tsx index 4387d96b3..19c121df8 100644 --- a/src/renderer/features/albums/components/joined-artists.tsx +++ b/src/renderer/features/albums/components/joined-artists.tsx @@ -1,21 +1,28 @@ -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[]; linkProps?: Partial>; + readOnly?: boolean; rootTextProps?: Partial>; } -export const JoinedArtists = ({ +const JoinedArtistsComponent = ({ artistName, artists, linkProps, + readOnly = false, rootTextProps, }: JoinedArtistsProps) => { const parts: ( @@ -111,7 +118,7 @@ export const JoinedArtists = ({ {artists.map((artist, index) => ( {index > 0 && ', '} - {artist.id ? ( + {artist.id && !readOnly ? ( ) : ( - + {artist.name} )} @@ -152,7 +159,7 @@ export const JoinedArtists = ({ const { artist, text } = part; - if (artist.id) { + if (artist.id && !readOnly) { return ( + {text} ); @@ -180,7 +187,7 @@ export const JoinedArtists = ({ {unmatchedArtists.map((artist, index) => ( {index > 0 && ', '} - {artist.id ? ( + {artist.id && !readOnly ? ( {artist.name} + ) : artist.id ? ( + + {artist.name} + ) : ( {artist.name} @@ -205,6 +216,8 @@ export const JoinedArtists = ({ ); }; +export const JoinedArtists = memo(JoinedArtistsComponent); + function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } diff --git a/src/renderer/features/artists/components/album-artist-detail-header.tsx b/src/renderer/features/artists/components/album-artist-detail-header.tsx index d77cf55de..58599e8d3 100644 --- a/src/renderer/features/artists/components/album-artist-detail-header.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-header.tsx @@ -19,6 +19,7 @@ import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer, useShowRatings } from '/@/renderer/store'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { formatDurationString } from '/@/renderer/utils'; +import { SEPARATOR_STRING } from '/@/shared/api/utils'; import { Group } from '/@/shared/components/group/group'; import { Stack } from '/@/shared/components/stack/stack'; import { Text } from '/@/shared/components/text/text'; @@ -160,7 +161,11 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref i.enabled) .map((item, index) => ( - {index > 0 && } + {index > 0 && ( + + {SEPARATOR_STRING} + + )} {item.value} ))} diff --git a/src/renderer/features/player/hooks/use-is-current-song.ts b/src/renderer/features/player/hooks/use-is-current-song.ts index ae44297ee..4cb4396df 100644 --- a/src/renderer/features/player/hooks/use-is-current-song.ts +++ b/src/renderer/features/player/hooks/use-is-current-song.ts @@ -1,14 +1,20 @@ import { useMemo } from 'react'; import { usePlayerSong } from '/@/renderer/store'; -import { QueueSong } from '/@/shared/types/domain-types'; +import { QueueSong, Song } from '/@/shared/types/domain-types'; -export const useIsCurrentSong = (song: QueueSong) => { +export const useIsCurrentSong = (song: QueueSong | Song) => { const currentSong = usePlayerSong(); const isActive = useMemo(() => { - return song._uniqueId === currentSong?._uniqueId; - }, [song._uniqueId, currentSong?._uniqueId]); + const queueSong = song as QueueSong; + + if (queueSong._uniqueId != null && queueSong._uniqueId !== '') { + return queueSong._uniqueId === currentSong?._uniqueId; + } + + return song.id === currentSong?.id; + }, [song, currentSong?.id, currentSong?._uniqueId]); return { isActive }; }; diff --git a/src/renderer/features/shared/components/display-type-toggle-button.tsx b/src/renderer/features/shared/components/display-type-toggle-button.tsx index 6af54b68d..fa02e49fb 100644 --- a/src/renderer/features/shared/components/display-type-toggle-button.tsx +++ b/src/renderer/features/shared/components/display-type-toggle-button.tsx @@ -16,10 +16,11 @@ export const DisplayTypeToggleButton = ({ }: DisplayTypeToggleButtonProps) => { const { t } = useTranslation(); const isGrid = displayType === ListDisplayType.GRID; + const isDetail = displayType === ListDisplayType.DETAIL; return ( + + {i18n.t('table.config.view.detail', { postProcess: 'sentenceCase' }) as string} + + ), + value: ListDisplayType.DETAIL, + }, // { // disabled: true, // label: ( @@ -63,6 +72,12 @@ export const ListConfigBooleanControl = ({ ); }; +export interface ListConfigMenuDetailConfig { + optionsConfig?: ListConfigMenuOptionsConfig['detail']; + tableColumnsData: { label: string; value: string }[]; + tableKey: 'detail'; +} + export interface ListConfigMenuDisplayTypeConfig { disabled?: boolean; hidden?: boolean; @@ -75,6 +90,9 @@ export interface ListConfigMenuOptionConfig { } export interface ListConfigMenuOptionsConfig { + detail?: { + [key: string]: ListConfigMenuOptionConfig; + }; grid?: { [key: string]: ListConfigMenuOptionConfig; }; @@ -85,6 +103,7 @@ export interface ListConfigMenuOptionsConfig { interface ListConfigMenuProps { buttonProps?: ActionIconProps; + detailConfig?: ListConfigMenuDetailConfig; displayTypes?: ListConfigMenuDisplayTypeConfig[]; listKey: ItemListKey; optionsConfig?: ListConfigMenuOptionsConfig; @@ -172,6 +191,20 @@ const Config = ({ ...props }: ListConfigMenuProps & { displayType: ListDisplayType }) => { switch (displayType) { + case ListDisplayType.DETAIL: + if (props.detailConfig) { + return ( + + ); + } + return null; + case ListDisplayType.GRID: return ( { +export const ListDisplayTypeToggleButton = ({ + enableDetail = false, + listKey, +}: ListDisplayTypeToggleButtonProps) => { const displayType = useSettingsStore( (state) => state.lists[listKey]?.display, ) as ListDisplayType; const { setList } = useSettingsStoreActions(); const handleToggleDisplayType = () => { - const newDisplayType = - displayType === ListDisplayType.GRID ? ListDisplayType.TABLE : ListDisplayType.GRID; + let newDisplayType: ListDisplayType; + + if (enableDetail) { + if (displayType === ListDisplayType.DETAIL) { + newDisplayType = ListDisplayType.TABLE; + } else if (displayType === ListDisplayType.TABLE) { + newDisplayType = ListDisplayType.GRID; + } else if (displayType === ListDisplayType.GRID) { + newDisplayType = ListDisplayType.DETAIL; + } else { + newDisplayType = ListDisplayType.GRID; + } + } else { + if (displayType === ListDisplayType.GRID) { + newDisplayType = ListDisplayType.TABLE; + } else if (displayType === ListDisplayType.TABLE) { + newDisplayType = ListDisplayType.GRID; + } else { + newDisplayType = ListDisplayType.GRID; + } + } + setList(listKey, { display: newDisplayType, }); + + return; }; return ; diff --git a/src/renderer/features/shared/components/table-config.tsx b/src/renderer/features/shared/components/table-config.tsx index 126499142..4d2261a99 100644 --- a/src/renderer/features/shared/components/table-config.tsx +++ b/src/renderer/features/shared/components/table-config.tsx @@ -21,7 +21,12 @@ import { ListConfigBooleanControl, ListConfigTable, } from '/@/renderer/features/shared/components/list-config-menu'; -import { ItemListSettings, useSettingsStore, useSettingsStoreActions } from '/@/renderer/store'; +import { + type DataTableProps, + ItemListSettings, + useSettingsStore, + useSettingsStoreActions, +} from '/@/renderer/store'; import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon'; import { Badge } from '/@/shared/components/badge/badge'; import { Checkbox } from '/@/shared/components/checkbox/checkbox'; @@ -39,6 +44,7 @@ import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/d import { ItemListKey, ListPaginationType } from '/@/shared/types/types'; interface TableConfigProps { + enablePinColumnButtons?: boolean; extraOptions?: { component: React.ReactNode; id: string; @@ -52,19 +58,37 @@ interface TableConfigProps { }; }; tableColumnsData: { label: string; value: string }[]; + tableKey?: 'detail' | 'main'; } export const TableConfig = ({ + enablePinColumnButtons = true, extraOptions, listKey, optionsConfig, tableColumnsData, + tableKey = 'main', }: TableConfigProps) => { const { t } = useTranslation(); const list = useSettingsStore((state) => state.lists[listKey]) as ItemListSettings; const { setList } = useSettingsStoreActions(); + const table = tableKey === 'detail' ? (list?.detail ?? list?.table) : list?.table; + + const setTableUpdate = useCallback( + (patch: Partial) => { + if (tableKey === 'detail') { + setList(listKey, { detail: patch } as Parameters< + ReturnType['setList'] + >[1]); + } else { + setList(listKey, { table: patch }); + } + }, + [listKey, setList, tableKey], + ); + const advancedSettings = useMemo(() => { const allOptions = [ { @@ -152,12 +176,12 @@ export const TableConfig = ({ }, ]} onChange={(value) => - setList(listKey, { - table: { size: value as 'compact' | 'default' }, + setTableUpdate({ + size: value as 'compact' | 'default' | 'large', }) } size="sm" - value={list.table.size} + value={table?.size ?? 'default'} w="100%" /> ), @@ -169,8 +193,8 @@ export const TableConfig = ({ { component: ( setList(listKey, { table: { enableHeader: e } })} - value={list.table.enableHeader} + onChange={(e) => setTableUpdate({ enableHeader: e })} + value={table.enableHeader} /> ), id: 'enableHeader', @@ -181,10 +205,8 @@ export const TableConfig = ({ { component: ( - setList(listKey, { table: { enableRowHoverHighlight: e } }) - } - value={list.table.enableRowHoverHighlight} + onChange={(e) => setTableUpdate({ enableRowHoverHighlight: e })} + value={table.enableRowHoverHighlight} /> ), id: 'enableRowHoverHighlight', @@ -195,10 +217,8 @@ export const TableConfig = ({ { component: ( - setList(listKey, { table: { enableAlternateRowColors: e } }) - } - value={list.table.enableAlternateRowColors} + onChange={(e) => setTableUpdate({ enableAlternateRowColors: e })} + value={table.enableAlternateRowColors} /> ), id: 'enableAlternateRowColors', @@ -209,10 +229,8 @@ export const TableConfig = ({ { component: ( - setList(listKey, { table: { enableHorizontalBorders: e } }) - } - value={list.table.enableHorizontalBorders} + onChange={(e) => setTableUpdate({ enableHorizontalBorders: e })} + value={table.enableHorizontalBorders} /> ), id: 'enableHorizontalBorders', @@ -223,8 +241,8 @@ export const TableConfig = ({ { component: ( setList(listKey, { table: { enableVerticalBorders: e } })} - value={list.table.enableVerticalBorders} + onChange={(e) => setTableUpdate({ enableVerticalBorders: e })} + value={table.enableVerticalBorders} /> ), id: 'enableVerticalBorders', @@ -235,8 +253,10 @@ export const TableConfig = ({ { component: ( setList(listKey, { table: { autoFitColumns: e } })} - value={list.table.autoFitColumns} + onChange={(e) => setTableUpdate({ autoFitColumns: e })} + value={ + tableKey === 'main' ? (table as DataTableProps).autoFitColumns : false + } /> ), id: 'autoFitColumns', @@ -256,7 +276,18 @@ export const TableConfig = ({ return option; }) .filter((option): option is NonNullable => option !== null); - }, [extraOptions, listKey, optionsConfig, setList, t, list]); + }, [ + t, + list.pagination, + list.itemsPerPage, + table, + tableKey, + extraOptions, + setList, + listKey, + setTableUpdate, + optionsConfig, + ]); return ( <> @@ -264,8 +295,9 @@ export const TableConfig = ({ setList(listKey, { table: { columns } })} - value={list.table.columns} + enablePinColumnButtons={enablePinColumnButtons} + onChange={(columns) => setTableUpdate({ columns })} + value={table.columns} /> ); @@ -273,10 +305,12 @@ export const TableConfig = ({ const TableColumnConfig = ({ data, + enablePinColumnButtons, onChange, value, }: { data: { label: string; value: string }[]; + enablePinColumnButtons: boolean; onChange: (value: ItemTableListColumnConfig[]) => void; value: ItemTableListColumnConfig[]; }) => { @@ -473,6 +507,7 @@ const TableColumnConfig = ({
{filteredColumns.map(({ item, matches }) => ( void; handleAlignLeft: (item: ItemTableListColumnConfig) => void; handleAlignRight: (item: ItemTableListColumnConfig) => void; @@ -667,32 +704,34 @@ const TableColumnItem = memo( variant="subtle" /> - - handlePinToLeft(item)} - size="xs" - tooltip={{ - label: t('table.config.general.pinToLeft', { - postProcess: 'sentenceCase', - }), - }} - variant={item.pinned === 'left' ? 'filled' : 'subtle'} - /> - handlePinToRight(item)} - size="xs" - tooltip={{ - label: t('table.config.general.pinToRight', { - postProcess: 'sentenceCase', - }), - }} - variant={item.pinned === 'right' ? 'filled' : 'subtle'} - /> - + {enablePinColumnButtons && ( + + handlePinToLeft(item)} + size="xs" + tooltip={{ + label: t('table.config.general.pinToLeft', { + postProcess: 'sentenceCase', + }), + }} + variant={item.pinned === 'left' ? 'filled' : 'subtle'} + /> + handlePinToRight(item)} + size="xs" + tooltip={{ + label: t('table.config.general.pinToRight', { + postProcess: 'sentenceCase', + }), + }} + variant={item.pinned === 'right' ? 'filled' : 'subtle'} + /> + + )} { // Custom comparison function for better memoization return ( + prevProps.enablePinColumnButtons === nextProps.enablePinColumnButtons && prevProps.item.id === nextProps.item.id && prevProps.item.isEnabled === nextProps.item.isEnabled && prevProps.item.autoSize === nextProps.item.autoSize && diff --git a/src/renderer/features/shared/mutations/favorite-optimistic-updates.ts b/src/renderer/features/shared/mutations/favorite-optimistic-updates.ts index b416c3e39..51c7e6c65 100644 --- a/src/renderer/features/shared/mutations/favorite-optimistic-updates.ts +++ b/src/renderer/features/shared/mutations/favorite-optimistic-updates.ts @@ -520,6 +520,28 @@ export const applyFavoriteOptimisticUpdates = ( } }); + const songListQueryKey = queryKeys.songs.list(variables.apiClientProps.serverId); + const songListQueries = queryClient.getQueriesData({ + exact: false, + queryKey: songListQueryKey, + }); + + songListQueries.forEach(([queryKey, data]) => { + if (data) { + pendingUpdates.push({ + previousData: data, + queryKey, + updater: (prev: undefined | { items: Song[] }) => { + if (!prev) return prev; + const updatedItems = updateItemInArray(prev.items, itemIdSet, (item) => + createFavoriteUpdater(item), + ); + return updatedItems ? { ...prev, items: updatedItems } : prev; + }, + }); + } + }); + const topSongsQueryKey = queryKeys.albumArtists.topSongs( variables.apiClientProps.serverId, ); @@ -679,6 +701,7 @@ export const applyFavoriteOptimisticUpdatesDeferred = ( queryKeys.playlists.songList(variables.apiClientProps.serverId), 'playlist-song-list', ); + collectQueries(queryKeys.songs.list(variables.apiClientProps.serverId), 'song-list'); collectQueries( queryKeys.albumArtists.topSongs(variables.apiClientProps.serverId), 'top-songs', @@ -742,6 +765,7 @@ export const applyFavoriteOptimisticUpdatesDeferred = ( case 'album-list': case 'artist-list': case 'playlist-song-list': + case 'song-list': case 'top-songs': { const updatedItems = updateItemInArray( prev.items || [], diff --git a/src/renderer/features/shared/mutations/rating-optimistic-updates.ts b/src/renderer/features/shared/mutations/rating-optimistic-updates.ts index 83b92ccf9..22c404462 100644 --- a/src/renderer/features/shared/mutations/rating-optimistic-updates.ts +++ b/src/renderer/features/shared/mutations/rating-optimistic-updates.ts @@ -519,6 +519,28 @@ export const applyRatingOptimisticUpdates = ( } }); + const songListQueryKey = queryKeys.songs.list(variables.apiClientProps.serverId); + const songListQueries = queryClient.getQueriesData({ + exact: false, + queryKey: songListQueryKey, + }); + + songListQueries.forEach(([queryKey, data]) => { + if (data) { + pendingUpdates.push({ + previousData: data, + queryKey, + updater: (prev: undefined | { items: Song[] }) => { + if (!prev) return prev; + const updatedItems = updateItemInArray(prev.items, itemIdSet, (item) => + createRatingUpdater(item), + ); + return updatedItems ? { ...prev, items: updatedItems } : prev; + }, + }); + } + }); + const topSongsQueryKey = queryKeys.albumArtists.topSongs( variables.apiClientProps.serverId, ); @@ -652,6 +674,7 @@ export const applyRatingOptimisticUpdatesDeferred = ( queryKeys.songs.detail(variables.apiClientProps.serverId), 'song-detail', ); + collectQueries(queryKeys.songs.list(variables.apiClientProps.serverId), 'song-list'); collectQueries( queryKeys.albumArtists.topSongs(variables.apiClientProps.serverId), 'top-songs', @@ -712,6 +735,7 @@ export const applyRatingOptimisticUpdatesDeferred = ( case 'album-artist-list': case 'album-list': case 'artist-list': + case 'song-list': case 'top-songs': { const updatedItems = updateItemInArray( prev.items || [], diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 71c9d88c7..18fa2f4d3 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -2,5 +2,6 @@ export * from './app.store'; export * from './auth.store'; export * from './full-screen-player.store'; export * from './player.store'; +export * from './scroll.store'; export * from './settings.store'; export * from './timestamp.store'; diff --git a/src/renderer/store/scroll.store.ts b/src/renderer/store/scroll.store.ts new file mode 100644 index 000000000..ac4bbbcf5 --- /dev/null +++ b/src/renderer/store/scroll.store.ts @@ -0,0 +1,16 @@ +import { create } from 'zustand'; + +type ScrollState = { + getOffset: (key: string) => number | undefined; + offsets: Record; + setOffset: (key: string, offset: number) => void; +}; + +export const useScrollStore = create((set, get) => ({ + getOffset: (key) => get().offsets[key], + offsets: {}, + setOffset: (key, offset) => + set((s) => ({ + offsets: { ...s.offsets, [key]: offset }, + })), +})); diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 6beef0f09..baa7361cf 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -203,10 +203,21 @@ const ItemTableListPropsSchema = z.object({ enableHorizontalBorders: z.boolean(), enableRowHoverHighlight: z.boolean(), enableVerticalBorders: z.boolean(), - size: z.enum(['compact', 'default']), + size: z.enum(['compact', 'default', 'large']), +}); + +const ItemDetailListPropsSchema = z.object({ + columns: z.array(ItemTableListColumnConfigSchema), + enableAlternateRowColors: z.boolean(), + enableHeader: z.boolean(), + enableHorizontalBorders: z.boolean(), + enableRowHoverHighlight: z.boolean(), + enableVerticalBorders: z.boolean(), + size: z.enum(['compact', 'default', 'large']), }); const ItemListConfigSchema = z.object({ + detail: ItemDetailListPropsSchema.optional(), display: z.nativeEnum(ListDisplayType), grid: z.object({ itemGap: z.enum(['lg', 'md', 'sm', 'xl', 'xs']), @@ -790,7 +801,9 @@ export type DataGridProps = { }; export type DataTableProps = z.infer; +export type ItemDetailListProps = z.infer; export type ItemListSettings = { + detail?: ItemDetailListProps; display: ListDisplayType; grid: DataGridProps; itemsPerPage: number; @@ -1163,6 +1176,32 @@ const initialState: SettingsState = { }, }, [LibraryItem.ALBUM]: { + detail: { + columns: pickTableColumns({ + autoSizeColumns: [], + columns: SONG_TABLE_COLUMNS, + columnWidths: { + [TableColumn.ACTIONS]: 60, + [TableColumn.DURATION]: 100, + [TableColumn.TITLE]: 400, + [TableColumn.TRACK_NUMBER]: 50, + [TableColumn.USER_FAVORITE]: 60, + }, + enabledColumns: [ + TableColumn.TRACK_NUMBER, + TableColumn.TITLE, + TableColumn.DURATION, + TableColumn.USER_FAVORITE, + TableColumn.ACTIONS, + ], + }), + enableAlternateRowColors: false, + enableHeader: true, + enableHorizontalBorders: false, + enableRowHoverHighlight: true, + enableVerticalBorders: false, + size: 'compact', + }, display: ListDisplayType.GRID, grid: { itemGap: 'sm', @@ -1737,6 +1776,23 @@ export const useSettingsStore = createWithEqualityFn()( delete data.table; } + if (listState && data.detail) { + if (!listState.detail) { + const t = listState.table; + listState.detail = { + columns: t.columns, + enableAlternateRowColors: false, + enableHeader: t.enableHeader, + enableHorizontalBorders: t.enableHorizontalBorders, + enableRowHoverHighlight: t.enableRowHoverHighlight, + enableVerticalBorders: t.enableVerticalBorders, + size: t.size, + }; + } + Object.assign(listState.detail, data.detail); + delete data.detail; + } + if (listState && data.grid) { Object.assign(listState.grid, data.grid); delete data.grid; @@ -2092,7 +2148,7 @@ export const useSettingsStore = createWithEqualityFn()( return persistedState; }, name: 'store_settings', - version: 24, + version: 25, }, ), ); diff --git a/src/renderer/utils/format.tsx b/src/renderer/utils/format.tsx index cff6a160e..a455e1041 100644 --- a/src/renderer/utils/format.tsx +++ b/src/renderer/utils/format.tsx @@ -104,24 +104,23 @@ export const formatDurationString = (duration: number) => { return part.replace(/^0/, ''); }); - let string: string = ''; + const parts: string[] = []; + const len = rawDuration.length; - switch (rawDuration.length) { - case 1: - string = `${formattedDuration[0]}${i18n.t('datetime.secondShort')}`; - break; - case 2: - string = `${formattedDuration[0]}${i18n.t('datetime.minuteShort')} ${formattedDuration[1]}${i18n.t('datetime.secondShort')}`; - break; - case 3: - string = `${formattedDuration[0]}${i18n.t('datetime.hourShort')} ${formattedDuration[1]}${i18n.t('datetime.minuteShort')} ${formattedDuration[2]}${i18n.t('datetime.secondShort')}`; - break; - case 4: - string = `${formattedDuration[0]}${i18n.t('datetime.dayShort')} ${formattedDuration[1]}${i18n.t('datetime.hourShort')} ${formattedDuration[2]}${i18n.t('datetime.minuteShort')} ${formattedDuration[3]}${i18n.t('datetime.secondShort')}`; - break; + if (len >= 1 && formattedDuration[len - 1] !== undefined) { + parts.push(`${formattedDuration[len - 1]}${i18n.t('datetime.secondShort')}`); + } + if (len >= 2 && formattedDuration[len - 2]) { + parts.unshift(`${formattedDuration[len - 2]}${i18n.t('datetime.minuteShort')}`); + } + if (len >= 3 && formattedDuration[len - 3]) { + parts.unshift(`${formattedDuration[len - 3]}${i18n.t('datetime.hourShort')}`); + } + if (len >= 4 && formattedDuration[len - 4]) { + parts.unshift(`${formattedDuration[len - 4]}${i18n.t('datetime.dayShort')}`); } - return string; + return parts.join(' '); }; export const formatDurationStringShort = (duration: number) => { diff --git a/src/shared/api/subsonic/subsonic-normalize.ts b/src/shared/api/subsonic/subsonic-normalize.ts index c50e1261c..8c431ede2 100644 --- a/src/shared/api/subsonic/subsonic-normalize.ts +++ b/src/shared/api/subsonic/subsonic-normalize.ts @@ -137,14 +137,19 @@ const normalizeSong = ( discTitleMap?: Map, ): Song => { const participants = getParticipants(item); + const albumArtistsList = getArtistList(item.albumArtists, item.artistId, item.artist); + const albumArtistName = + item.albumArtists?.length > 0 + ? item.albumArtists.map((a) => a.name).join(', ') + : item.artist || ''; return { _itemType: LibraryItem.SONG, _serverId: server?.id || 'unknown', _serverType: ServerType.SUBSONIC, album: item.album || '', - albumArtistName: item.artist || '', - albumArtists: getArtistList(item.albumArtists, item.artistId, item.artist), + albumArtistName, + albumArtists: albumArtistsList, albumId: item.albumId?.toString() || '', artistName: item.artist || '', artists: getArtistList(item.artists, item.artistId, item.artist, participants), diff --git a/src/shared/api/utils.ts b/src/shared/api/utils.ts index cdb1d396f..88936e1b0 100644 --- a/src/shared/api/utils.ts +++ b/src/shared/api/utils.ts @@ -139,7 +139,7 @@ export const getClientType = (): string => { } }; -export const SEPARATOR_STRING = ' · '; +export const SEPARATOR_STRING = ' • '; export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: SortOrder) => { let results: Song[] = songs; diff --git a/src/shared/components/icon/icon.tsx b/src/shared/components/icon/icon.tsx index 4097ea999..38415a192 100644 --- a/src/shared/components/icon/icon.tsx +++ b/src/shared/components/icon/icon.tsx @@ -58,6 +58,7 @@ import { LuInfo, LuKeyboard, LuLayoutGrid, + LuLayoutList, LuLibrary, LuList, LuListFilter, @@ -186,6 +187,7 @@ export const AppIcon = { itemSong: LuMusic, keyboard: LuKeyboard, lastPlayed: LuHeadphones, + layoutDetail: LuLayoutList, layoutGrid: LuLayoutGrid, layoutList: LuList, layoutTable: LuTable, diff --git a/src/shared/components/read-only-rating/read-only-rating.module.css b/src/shared/components/read-only-rating/read-only-rating.module.css new file mode 100644 index 000000000..6b8141710 --- /dev/null +++ b/src/shared/components/read-only-rating/read-only-rating.module.css @@ -0,0 +1,31 @@ +.root { + display: inline-flex; + gap: 0.125rem; + align-items: center; + line-height: 1; +} + +.root.interactive { + cursor: pointer; +} + +.root.xs { + font-size: var(--theme-font-size-xs); +} + +.root.sm { + font-size: var(--theme-font-size-sm); +} + +.root.md { + font-size: var(--theme-font-size-md); +} + +.filled { + color: var(--theme-colors-primary); +} + +.empty { + color: var(--theme-colors-foreground-muted); + opacity: 0.6; +} diff --git a/src/shared/components/read-only-rating/read-only-rating.tsx b/src/shared/components/read-only-rating/read-only-rating.tsx new file mode 100644 index 000000000..1c1319f79 --- /dev/null +++ b/src/shared/components/read-only-rating/read-only-rating.tsx @@ -0,0 +1,81 @@ +import clsx from 'clsx'; +import { memo, useCallback, useState } from 'react'; + +import styles from './read-only-rating.module.css'; + +const MAX_STARS = 5; + +interface ReadOnlyRatingProps { + className?: string; + onChange?: (value: number) => void; + size?: 'md' | 'sm' | 'xs'; + value?: null | number; +} + +function ReadOnlyRatingComponent({ className, onChange, size = 'sm', value }: ReadOnlyRatingProps) { + const [hoverIndex, setHoverIndex] = useState(null); + const rating = Math.min(MAX_STARS, Math.max(0, value ?? 0)); + const displayCount = hoverIndex !== null ? hoverIndex : Math.floor(rating); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!onChange) return; + const el = e.currentTarget; + const width = (el as HTMLElement).offsetWidth; + if (width <= 0) return; + const x = e.clientX - el.getBoundingClientRect().left; + const segment = Math.floor((x / width) * MAX_STARS); + const filled = segment < 0 ? 0 : Math.min(MAX_STARS, segment + 1); + setHoverIndex(filled); + }, + [onChange], + ); + + const handlePointerLeave = useCallback(() => { + setHoverIndex(null); + }, []); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!onChange) return; + e.preventDefault(); + e.stopPropagation(); + const el = e.currentTarget; + const width = (el as HTMLElement).offsetWidth; + if (width <= 0) return; + const x = e.clientX - el.getBoundingClientRect().left; + const segment = Math.floor((x / width) * MAX_STARS); + const clicked = segment < 0 ? 0 : Math.min(MAX_STARS, segment + 1); + onChange(clicked === rating ? 0 : clicked); + }, + [onChange, rating], + ); + + const isInteractive = typeof onChange === 'function'; + + return ( + + {Array.from({ length: MAX_STARS }, (_, i) => ( + + ★ + + ))} + + ); +} + +export const ReadOnlyRating = memo(ReadOnlyRatingComponent); + +ReadOnlyRating.displayName = 'ReadOnlyRating'; diff --git a/src/shared/types/types.ts b/src/shared/types/types.ts index 11d58b146..cb687f08f 100644 --- a/src/shared/types/types.ts +++ b/src/shared/types/types.ts @@ -34,6 +34,7 @@ export enum ItemListKey { } export enum ListDisplayType { + DETAIL = 'detail', GRID = 'poster', LIST = 'list', TABLE = 'table',