From b4b106222e440ecdcdb05a4e1c0dadc92d69d506 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 14 Dec 2025 02:33:19 -0800 Subject: [PATCH] optimize library headers (#1374) --- src/i18n/locales/en.json | 8 +- .../components/album-detail-content.tsx | 6 +- .../albums/components/album-detail-header.tsx | 4 +- .../routes/dummy-album-detail-route.tsx | 8 +- .../album-artist-detail-content.tsx | 113 +++--------------- .../album-artist-detail-header.module.css | 8 ++ .../components/album-artist-detail-header.tsx | 65 +++++++--- .../components/item-details-modal.tsx | 4 +- .../playlist-detail-song-list-header.tsx | 2 +- .../components/library-header.module.css | 4 +- .../shared/components/library-header.tsx | 69 ++++++++--- .../shared/components/play-button.module.css | 38 ++++-- .../shared/components/play-button.tsx | 64 +++++++++- .../components/button/button.module.css | 4 +- src/shared/components/spoiler/spoiler.tsx | 5 +- 15 files changed, 247 insertions(+), 155 deletions(-) create mode 100644 src/renderer/features/artists/components/album-artist-detail-header.module.css diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 837ffecbf..318d17925 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -578,10 +578,10 @@ } }, "player": { - "addLast": "add last", - "addNext": "add next", - "addLastShuffled": "add last (shuffled)", - "addNextShuffled": "add next (shuffled)", + "addLast": "last", + "addNext": "next", + "addLastShuffled": "last (shuffled)", + "addNextShuffled": "next (shuffled)", "holdToShuffle": "hold to shuffle", "favorite": "favorite", "lyrics": "lyrics", diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx index 798398806..72df8dfe3 100644 --- a/src/renderer/features/albums/components/album-detail-content.tsx +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -368,8 +368,10 @@ export const AlbumDetailContent = () => {
{comment && ( - - {replaceURLWithHTMLLinks(comment)} + + )}
diff --git a/src/renderer/features/albums/components/album-detail-header.tsx b/src/renderer/features/albums/components/album-detail-header.tsx index f5e6c940b..2905e713a 100644 --- a/src/renderer/features/albums/components/album-detail-header.tsx +++ b/src/renderer/features/albums/components/album-detail-header.tsx @@ -89,7 +89,7 @@ export const AlbumDetailHeader = forwardRef((_props, ref) => { item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }} title={detailQuery?.data?.name || ''} > - + {(firstAlbumArtist || releaseYear) && ( {firstAlbumArtist && ( @@ -121,7 +121,7 @@ export const AlbumDetailHeader = forwardRef((_props, ref) => { favorite={detailQuery?.data?.userFavorite} onFavorite={handleFavorite} onMore={handleMoreOptions} - onPlay={() => handlePlay(Play.NOW)} + onPlay={(type) => handlePlay(type)} onRating={handleUpdateRating} onShuffle={() => handlePlay(Play.SHUFFLE)} rating={detailQuery?.data?.userRating || 0} diff --git a/src/renderer/features/albums/routes/dummy-album-detail-route.tsx b/src/renderer/features/albums/routes/dummy-album-detail-route.tsx index 9401daedb..e7ccba4a0 100644 --- a/src/renderer/features/albums/routes/dummy-album-detail-route.tsx +++ b/src/renderer/features/albums/routes/dummy-album-detail-route.tsx @@ -211,7 +211,13 @@ const DummyAlbumDetailRoute = () => { )} {comment && (
- {replaceURLWithHTMLLinks(comment)} + + +
)}
diff --git a/src/renderer/features/artists/components/album-artist-detail-content.tsx b/src/renderer/features/artists/components/album-artist-detail-content.tsx index d44271bbf..72981d9ec 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -14,20 +14,14 @@ import { ItemControls } from '/@/renderer/components/item-list/types'; import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { AlbumArtistGridCarousel } from '/@/renderer/features/artists/components/album-artist-grid-carousel'; -import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; -import { DefaultPlayButton } from '/@/renderer/features/shared/components/play-button'; import { searchLibraryItems } from '/@/renderer/features/shared/utils'; import { useContainerQuery } from '/@/renderer/hooks'; import { useGenreRoute } from '/@/renderer/hooks/use-genre-route'; import { AppRoute } from '/@/renderer/router/routes'; import { ArtistItem, useCurrentServer, usePlayerSong } from '/@/renderer/store'; -import { - useGeneralSettings, - usePlayButtonBehavior, - useSettingsStore, -} from '/@/renderer/store/settings.store'; +import { useGeneralSettings, useSettingsStore } from '/@/renderer/store/settings.store'; import { sanitize } from '/@/renderer/utils/sanitize'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Button } from '/@/shared/components/button/button'; @@ -52,58 +46,35 @@ import { import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types'; interface AlbumArtistActionButtonsProps { - albumCount: null | number | undefined; artistDiscographyLink: string; artistSongsLink: string; - onFavorite: () => void; - onMoreOptions: (e: React.MouseEvent) => void; - onPlay: () => void; - userFavorite?: boolean; } const AlbumArtistActionButtons = ({ - albumCount, artistDiscographyLink, artistSongsLink, - onFavorite, - onMoreOptions, - onPlay, - userFavorite, }: AlbumArtistActionButtonsProps) => { const { t } = useTranslation(); return ( <> - - - - - - - - + - @@ -175,8 +146,8 @@ const AlbumArtistMetadataBiography = ({ artist: artistName, })} - -
+ +
); @@ -440,7 +411,6 @@ export const AlbumArtistDetailContent = () => { }; const routeId = (artistId || albumArtistId) as string; const { ref, ...cq } = useContainerQuery(); - const { addToQueueByFetch, setFavorite } = usePlayer(); const server = useCurrentServer(); const [enabledItem, itemOrder] = useMemo(() => { @@ -506,21 +476,11 @@ export const AlbumArtistDetailContent = () => { sortBy: AlbumListSort.RELEASE_DATE, sortOrder: SortOrder.DESC, title: ( - - - {t('page.albumArtistDetail.recentReleases', { - postProcess: 'sentenceCase', - })} - - - + + {t('page.albumArtistDetail.recentReleases', { + postProcess: 'sentenceCase', + })} + ), uniqueId: 'recentReleases', }, @@ -560,7 +520,6 @@ export const AlbumArtistDetailContent = () => { }, ]; }, [ - artistDiscographyLink, detailQuery.data?.similarArtists, enabledItem.compilations, enabledItem.recentAlbums, @@ -573,37 +532,6 @@ export const AlbumArtistDetailContent = () => { t, ]); - const playButtonBehavior = usePlayButtonBehavior(); - - const handlePlay = async (playType?: Play) => { - if (!server?.id) return; - addToQueueByFetch( - server.id, - [routeId], - albumArtistId ? LibraryItem.ALBUM_ARTIST : LibraryItem.ARTIST, - playType || playButtonBehavior, - ); - }; - - const handleFavorite = () => { - if (!detailQuery.data) return; - setFavorite( - detailQuery.data._serverId, - [detailQuery.data.id], - LibraryItem.ALBUM_ARTIST, - !detailQuery.data.userFavorite, - ); - }; - - const handleMoreOptions = (e: React.MouseEvent) => { - if (!detailQuery.data) return; - ContextMenuController.call({ - cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM_ARTIST }, - event: e, - }); - }; - - const albumCount = detailQuery.data?.albumCount; const biography = detailQuery.data?.biography && enabledItem.biography ? detailQuery.data.biography : null; const showGenres = detailQuery.data?.genres ? detailQuery.data.genres.length !== 0 : false; @@ -618,13 +546,8 @@ export const AlbumArtistDetailContent = () => {
handlePlay(playButtonBehavior)} - userFavorite={detailQuery.data?.userFavorite} /> {showGenres && ( diff --git a/src/renderer/features/artists/components/album-artist-detail-header.module.css b/src/renderer/features/artists/components/album-artist-detail-header.module.css new file mode 100644 index 000000000..ee4d7f95a --- /dev/null +++ b/src/renderer/features/artists/components/album-artist-detail-header.module.css @@ -0,0 +1,8 @@ +.metadata-group { + justify-content: center; + width: 100%; + + @container (min-width: $mantine-breakpoint-sm) { + justify-content: flex-start; + } +} 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 f91e4f864..7f90e41f8 100644 --- a/src/renderer/features/artists/components/album-artist-detail-header.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-header.tsx @@ -3,17 +3,24 @@ import { forwardRef, Fragment, Ref } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router'; +import styles from './album-artist-detail-header.module.css'; + import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; +import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { usePlayer } from '/@/renderer/features/player/context/player-context'; -import { LibraryHeader } from '/@/renderer/features/shared/components/library-header'; +import { + LibraryHeader, + LibraryHeaderMenu, +} from '/@/renderer/features/shared/components/library-header'; import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer } from '/@/renderer/store'; +import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { formatDurationString } from '/@/renderer/utils'; import { Group } from '/@/shared/components/group/group'; -import { Rating } from '/@/shared/components/rating/rating'; import { Stack } from '/@/shared/components/stack/stack'; import { Text } from '/@/shared/components/text/text'; import { LibraryItem, ServerType } from '/@/shared/types/domain-types'; +import { Play } from '/@/shared/types/types'; export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref) => { const { albumArtistId, artistId } = useParams() as { @@ -56,7 +63,28 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref { + if (!server?.id || !routeId) return; + addToQueueByFetch( + server.id, + [routeId], + LibraryItem.ALBUM_ARTIST, + type || playButtonBehavior, + ); + }; + + const handleFavorite = () => { + if (!detailQuery?.data) return; + setFavorite( + detailQuery.data._serverId, + [detailQuery.data.id], + LibraryItem.ALBUM_ARTIST, + !detailQuery.data.userFavorite, + ); + }; const handleUpdateRating = (rating: number) => { if (!detailQuery?.data) return; @@ -78,6 +106,14 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref) => { + if (!detailQuery?.data) return; + ContextMenuController.call({ + cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM_ARTIST }, + event: e, + }); + }; + const showRating = detailQuery?.data?._serverType === ServerType.NAVIDROME; return ( @@ -87,8 +123,8 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref - - + + {metadataItems .filter((i) => i.enabled) .map((item, index) => ( @@ -97,17 +133,16 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref{item.value} ))} - {showRating && ( - <> - - - - )} + handlePlay(type)} + onRating={showRating ? handleUpdateRating : undefined} + onShuffle={() => handlePlay(Play.SHUFFLE)} + rating={detailQuery?.data?.userRating || 0} + /> ); diff --git a/src/renderer/features/item-details/components/item-details-modal.tsx b/src/renderer/features/item-details/components/item-details-modal.tsx index 15db71561..6001a55a7 100644 --- a/src/renderer/features/item-details/components/item-details-modal.tsx +++ b/src/renderer/features/item-details/components/item-details-modal.tsx @@ -228,8 +228,8 @@ const AlbumArtistPropertyMapping: ItemDetailRow[] = [ label: 'common.biography', render: (artist) => artist.biography ? ( - -
+ + ) : null, }, diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx index 028ef14ac..4c19580ff 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx @@ -91,7 +91,7 @@ export const PlaylistDetailSongListHeader = ({ title={detailQuery?.data?.name} > handlePlay(Play.NOW)} + onPlay={(type) => handlePlay(type)} onShuffle={() => handlePlay(Play.SHUFFLE)} /> diff --git a/src/renderer/features/shared/components/library-header.module.css b/src/renderer/features/shared/components/library-header.module.css index 269b56cf4..fe59d5276 100644 --- a/src/renderer/features/shared/components/library-header.module.css +++ b/src/renderer/features/shared/components/library-header.module.css @@ -26,7 +26,7 @@ grid-template-areas: 'image info'; grid-template-rows: auto; grid-template-columns: 225px minmax(0, 1fr); - align-items: center; + align-items: flex-end; justify-items: start; height: auto; min-height: 340px; @@ -98,7 +98,7 @@ .title { display: flex; margin: var(--theme-spacing-sm) 0; - font-size: clamp(2rem, 3.5dvw, 3.25rem); + font-size: clamp(1.75rem, 3dvw, 2.75rem); line-height: 1.2; } diff --git a/src/renderer/features/shared/components/library-header.tsx b/src/renderer/features/shared/components/library-header.tsx index 3a31ff1a8..6478eb4f7 100644 --- a/src/renderer/features/shared/components/library-header.tsx +++ b/src/renderer/features/shared/components/library-header.tsx @@ -7,9 +7,12 @@ import { Link } from 'react-router'; import styles from './library-header.module.css'; import { + PlayLastTextButton, + PlayNextTextButton, PlayTextButton, - WideShuffleButton, } from '/@/renderer/features/shared/components/play-button'; +import { LONG_PRESS_PLAY_BEHAVIOR } from '/@/renderer/features/shared/components/play-button-group'; +import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click'; import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation'; @@ -20,6 +23,7 @@ import { Image } from '/@/shared/components/image/image'; import { Rating } from '/@/shared/components/rating/rating'; import { Text } from '/@/shared/components/text/text'; import { LibraryItem } from '/@/shared/types/domain-types'; +import { Play } from '/@/shared/types/types'; interface LibraryHeaderProps { children?: ReactNode; @@ -145,36 +149,36 @@ export const LibraryHeader = forwardRef( const calculateTitleSize = (title: string) => { const titleLength = title.length; - let baseSize = '3.5dvw'; + let baseSize = '3dvw'; if (titleLength > 20) { - baseSize = '3dvw'; - } - - if (titleLength > 30) { - baseSize = '2.75dvw'; - } - - if (titleLength > 40) { baseSize = '2.5dvw'; } - if (titleLength > 50) { + if (titleLength > 30) { baseSize = '2.25dvw'; } - if (titleLength > 60) { + if (titleLength > 40) { baseSize = '2dvw'; } - return `clamp(2rem, ${baseSize}, 3.25rem)`; + if (titleLength > 50) { + baseSize = '1.875dvw'; + } + + if (titleLength > 60) { + baseSize = '1.75dvw'; + } + + return `clamp(1.75rem, ${baseSize}, 2.75rem)`; }; interface LibraryHeaderMenuProps { favorite?: boolean; onFavorite?: (e: React.MouseEvent) => void; onMore?: (e: React.MouseEvent) => void; - onPlay?: (e: React.MouseEvent) => void; + onPlay?: (type: Play) => void; onRating?: (rating: number) => void; onShuffle?: (e: React.MouseEvent) => void; rating?: number; @@ -186,7 +190,6 @@ export const LibraryHeaderMenu = ({ onMore, onPlay, onRating, - onShuffle, rating, }: LibraryHeaderMenuProps) => { const isMutatingRating = useIsMutatingRating(); @@ -194,11 +197,43 @@ export const LibraryHeaderMenu = ({ const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite(); const isMutatingFavorite = isMutatingCreateFavorite || isMutatingDeleteFavorite; + const handlePlayNow = usePlayButtonClick({ + onClick: () => { + onPlay?.(Play.NOW); + }, + onLongPress: () => { + onPlay?.(LONG_PRESS_PLAY_BEHAVIOR[Play.NOW]); + }, + }); + + const handlePlayNext = usePlayButtonClick({ + onClick: () => { + onPlay?.(Play.NEXT); + }, + onLongPress: () => { + onPlay?.(LONG_PRESS_PLAY_BEHAVIOR[Play.NEXT]); + }, + }); + + const handlePlayLast = usePlayButtonClick({ + onClick: () => { + onPlay?.(Play.LAST); + }, + onLongPress: () => { + onPlay?.(LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]); + }, + }); + return (
- {onPlay && } - {onShuffle && } + {onPlay && } + {onPlay && ( + + )} + {onPlay && ( + + )} {onRating && ( diff --git a/src/renderer/features/shared/components/play-button.module.css b/src/renderer/features/shared/components/play-button.module.css index 1a6f647f3..5844abc04 100644 --- a/src/renderer/features/shared/components/play-button.module.css +++ b/src/renderer/features/shared/components/play-button.module.css @@ -50,10 +50,26 @@ padding-left: var(--theme-spacing-xl); background: white; border-radius: var(--theme-radius-xl); - transition: background-color 0.2s ease-in-out; + transition: background-color 0.2s ease-in-out !important; + + &[data-variant='subtle'] { + transition: background-color 0.2s ease-in-out !important; + + &:hover, + &:active, + &:focus-visible { + transition: background-color 0.2s ease-in-out !important; + } + } } .wide-text-button.unthemed { + transition: background-color 0.2s ease-in-out !important; + + &[data-variant='subtle'] { + transition: background-color 0.2s ease-in-out !important; + } + @mixin light { background: black; @@ -62,8 +78,11 @@ fill: white; } - &:hover { - background: lighten(black, 10%); + &[data-variant='subtle']:hover, + &[data-variant='subtle']:active, + &[data-variant='subtle']:focus-visible { + background: lighten(black, 10%) !important; + transition: background-color 0.2s ease-in-out !important; } } @@ -75,8 +94,11 @@ fill: black; } - &:hover { - background: darken(white, 20%); + &[data-variant='subtle']:hover, + &[data-variant='subtle']:active, + &[data-variant='subtle']:focus-visible { + background: darken(white, 20%) !important; + transition: background-color 0.2s ease-in-out !important; } } } @@ -90,14 +112,16 @@ color: white; } - - svg { color: black; fill: black; } } +.no-fill { + fill: none !important; +} + .play-button { all: unset; display: flex; diff --git a/src/renderer/features/shared/components/play-button.tsx b/src/renderer/features/shared/components/play-button.tsx index 84f3bfd89..a153eaa7f 100644 --- a/src/renderer/features/shared/components/play-button.tsx +++ b/src/renderer/features/shared/components/play-button.tsx @@ -4,12 +4,14 @@ import { forwardRef, memo } from 'react'; import styles from './play-button.module.css'; +import { PlayTooltip } from '/@/renderer/features/shared/components/play-button-group'; import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click'; import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon'; import { Button, ButtonProps } from '/@/shared/components/button/button'; import { Group } from '/@/shared/components/group/group'; import { AppIcon, Icon } from '/@/shared/components/icon/icon'; import { Spinner } from '/@/shared/components/spinner/spinner'; +import { Play } from '/@/shared/types/types'; export interface DefaultPlayButtonProps extends ActionIconProps { size?: number | string; @@ -36,14 +38,18 @@ export const DefaultPlayButton = forwardRef) => void; + showTooltip?: boolean; +} export const PlayTextButton = ({ className, + showTooltip = true, variant = 'default', ...props }: TextPlayButtonProps) => { - return ( + const button = ( ); + + const hasLongPress = Boolean( + props.onLongPress || (props as any).onMouseDown || (props as any).onTouchStart, + ); + + if (hasLongPress && showTooltip) { + return {button}; + } + + return button; +}; + +export const PlayNextTextButton = ({ ...props }: TextPlayButtonProps) => { + const button = ( + + + + {t('player.addNext', { postProcess: 'sentenceCase' })} + + + ); + + const hasLongPress = Boolean( + props.onLongPress || (props as any).onMouseDown || (props as any).onTouchStart, + ); + + if (hasLongPress) { + return {button}; + } + + return button; +}; + +export const PlayLastTextButton = ({ ...props }: TextPlayButtonProps) => { + const button = ( + + + + {t('player.addLast', { postProcess: 'sentenceCase' })} + + + ); + + const hasLongPress = Boolean( + props.onLongPress || (props as any).onMouseDown || (props as any).onTouchStart, + ); + + if (hasLongPress) { + return {button}; + } + + return button; }; export const WideShuffleButton = ({ ...props }: TextPlayButtonProps) => { diff --git a/src/shared/components/button/button.module.css b/src/shared/components/button/button.module.css index ef7521344..c8fc1a958 100644 --- a/src/shared/components/button/button.module.css +++ b/src/shared/components/button/button.module.css @@ -143,11 +143,11 @@ background-color: transparent; @mixin dark { - color: lighten(var(--theme-colors-foreground), 10%); + color: darken(var(--theme-colors-foreground), 15%); } @mixin light { - color: darken(var(--theme-colors-foreground), 10%); + color: lighten(var(--theme-colors-foreground), 10%); } } diff --git a/src/shared/components/spoiler/spoiler.tsx b/src/shared/components/spoiler/spoiler.tsx index 7f369ab82..2ff111312 100644 --- a/src/shared/components/spoiler/spoiler.tsx +++ b/src/shared/components/spoiler/spoiler.tsx @@ -5,17 +5,18 @@ import styles from './spoiler.module.css'; import { Icon } from '/@/shared/components/icon/icon'; -interface SpoilerProps extends MantineSpoilerProps { +interface SpoilerProps extends Omit { children?: ReactNode; } -export const Spoiler = ({ children, ...props }: SpoilerProps) => { +export const Spoiler = ({ children, maxHeight = 56, ...props }: SpoilerProps) => { const [expanded, setExpanded] = useState(false); return ( } onClick={() => setExpanded(!expanded)}