diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 681a1855..d491fad1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -16,7 +16,11 @@ "removeFromQueue": "remove from queue", "setRating": "set rating", "toggleSmartPlaylistEditor": "toggle $t(entity.smartPlaylist) editor", - "viewPlaylists": "view $t(entity.playlist_other)" + "viewPlaylists": "view $t(entity.playlist_other)", + "openIn": { + "lastfm": "Open in Last.fm", + "musicbrainz": "Open in MusicBrainz" + } }, "common": { "action_one": "action", @@ -419,6 +423,8 @@ "discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)", "enableRemote": "enable remote control server", "enableRemote_description": "enables the remote control server to allow other devices to control the application", + "externalLinks": "show external links", + "externalLinks_description": "enables showing external links (Last.fm, MusicBrainz) on artist/album pages", "exitToTray": "exit to tray", "exitToTray_description": "exit the application to the system tray", "floatingQueueArea": "show floating queue hover area", diff --git a/src/renderer/api/jellyfin/jellyfin-normalize.ts b/src/renderer/api/jellyfin/jellyfin-normalize.ts index 4d844174..a351d6c9 100644 --- a/src/renderer/api/jellyfin/jellyfin-normalize.ts +++ b/src/renderer/api/jellyfin/jellyfin-normalize.ts @@ -202,6 +202,7 @@ const normalizeAlbum = ( imageSize?: number, ): Album => { return { + albumArtist: item.AlbumArtist, albumArtists: item.AlbumArtists.map((entry) => ({ id: entry.Id, @@ -214,6 +215,7 @@ const normalizeAlbum = ( name: entry.Name, })), backdropImageUrl: null, + comment: null, createdAt: item.DateCreated, duration: item.RunTimeTicks / 10000, genres: item.GenreItems?.map((entry) => ({ @@ -232,6 +234,7 @@ const normalizeAlbum = ( isCompilation: null, itemType: LibraryItem.ALBUM, lastPlayedAt: null, + mbzId: item.ProviderIds?.MusicBrainzAlbum || null, name: item.Name, playCount: item.UserData?.PlayCount || 0, releaseDate: item.PremiereDate?.split('T')[0] || null, @@ -287,6 +290,7 @@ const normalizeAlbumArtist = ( }), itemType: LibraryItem.ALBUM_ARTIST, lastPlayedAt: null, + mbz: item.ProviderIds?.MusicBrainzArtist || null, name: item.Name, playCount: item.UserData?.PlayCount || 0, serverId: server?.id || '', diff --git a/src/renderer/api/jellyfin/jellyfin-types.ts b/src/renderer/api/jellyfin/jellyfin-types.ts index 9721ae8a..41ec5da3 100644 --- a/src/renderer/api/jellyfin/jellyfin-types.ts +++ b/src/renderer/api/jellyfin/jellyfin-types.ts @@ -422,6 +422,11 @@ const song = z.object({ UserData: userData.optional(), }); +const providerIds = z.object({ + MusicBrainzAlbum: z.string().optional(), + MusicBrainzArtist: z.string().optional(), +}); + const albumArtist = z.object({ BackdropImageTags: z.array(z.string()), ChannelId: z.null(), @@ -435,6 +440,7 @@ const albumArtist = z.object({ LocationType: z.string(), Name: z.string(), Overview: z.string(), + ProviderIds: providerIds.optional(), RunTimeTicks: z.number(), ServerId: z.string(), Type: z.string(), @@ -466,6 +472,7 @@ const album = z.object({ ParentLogoItemId: z.string(), PremiereDate: z.string().optional(), ProductionYear: z.number(), + ProviderIds: providerIds.optional(), RunTimeTicks: z.number(), ServerId: z.string(), Songs: z.array(song).optional(), // This is not a native Jellyfin property -- this is used for combined album detail diff --git a/src/renderer/api/navidrome/navidrome-normalize.ts b/src/renderer/api/navidrome/navidrome-normalize.ts index d5014698..8f07093e 100644 --- a/src/renderer/api/navidrome/navidrome-normalize.ts +++ b/src/renderer/api/navidrome/navidrome-normalize.ts @@ -151,9 +151,11 @@ const normalizeAlbum = ( const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null; return { + albumArtist: item.albumArtist, albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }], artists: [{ id: item.artistId, imageUrl: null, name: item.artist }], backdropImageUrl: imageBackdropUrl, + comment: item.comment || null, createdAt: item.createdAt.split('T')[0], duration: item.duration * 1000 || null, genres: item.genres?.map((genre) => ({ @@ -168,6 +170,7 @@ const normalizeAlbum = ( isCompilation: item.compilation, itemType: LibraryItem.ALBUM, lastPlayedAt: normalizePlayDate(item), + mbzId: item.mbzAlbumId || null, name: item.name, playCount: item.playCount, releaseDate: new Date(item.minYear, 0, 1).toISOString(), @@ -216,6 +219,7 @@ const normalizeAlbumArtist = ( imageUrl: imageUrl || null, itemType: LibraryItem.ALBUM_ARTIST, lastPlayedAt: normalizePlayDate(item), + mbz: item.mbzArtistId || null, name: item.name, playCount: item.playCount, serverId: server?.id || 'unknown', diff --git a/src/renderer/api/navidrome/navidrome-types.ts b/src/renderer/api/navidrome/navidrome-types.ts index d01174f6..b63b5442 100644 --- a/src/renderer/api/navidrome/navidrome-types.ts +++ b/src/renderer/api/navidrome/navidrome-types.ts @@ -111,6 +111,7 @@ const album = z.object({ allArtistIds: z.string(), artist: z.string(), artistId: z.string(), + comment: z.string().optional(), compilation: z.boolean(), coverArtId: z.string().optional(), // Removed after v0.48.0 coverArtPath: z.string().optional(), // Removed after v0.48.0 diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts index 881e7fef..58c30026 100644 --- a/src/renderer/api/subsonic/subsonic-normalize.ts +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -126,6 +126,7 @@ const normalizeAlbumArtist = ( imageUrl, itemType: LibraryItem.ALBUM_ARTIST, lastPlayedAt: null, + mbz: null, name: item.name, playCount: null, serverId: server?.id || 'unknown', @@ -150,11 +151,13 @@ const normalizeAlbum = ( }) || null; return { + albumArtist: item.artist, albumArtists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [], artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [], backdropImageUrl: null, + comment: null, createdAt: item.created, duration: item.duration, genres: item.genre @@ -173,6 +176,7 @@ const normalizeAlbum = ( isCompilation: null, itemType: LibraryItem.ALBUM, lastPlayedAt: null, + mbzId: null, name: item.name, playCount: null, releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 5165c7fb..8193dcd1 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -144,9 +144,11 @@ export type Genre = { }; export type Album = { + albumArtist: string; albumArtists: RelatedArtist[]; artists: RelatedArtist[]; backdropImageUrl: string | null; + comment: string | null; createdAt: string; duration: number | null; genres: Genre[]; @@ -156,6 +158,7 @@ export type Album = { isCompilation: boolean | null; itemType: LibraryItem.ALBUM; lastPlayedAt: string | null; + mbzId: string | null; name: string; playCount: number | null; releaseDate: string | null; @@ -228,6 +231,7 @@ export type AlbumArtist = { imageUrl: string | null; itemType: LibraryItem.ALBUM_ARTIST; lastPlayedAt: string | null; + mbz: string | null; name: string; playCount: number | null; serverId: string; diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts index 226c5883..7d3b252f 100644 --- a/src/renderer/components/index.ts +++ b/src/renderer/components/index.ts @@ -27,6 +27,7 @@ export * from './select'; export * from './skeleton'; export * from './slider'; export * from './spinner'; +export * from './spoiler'; export * from './switch'; export * from './tabs'; export * from './text'; diff --git a/src/renderer/components/spoiler/index.tsx b/src/renderer/components/spoiler/index.tsx new file mode 100644 index 00000000..9e015ae5 --- /dev/null +++ b/src/renderer/components/spoiler/index.tsx @@ -0,0 +1,39 @@ +import clsx from 'clsx'; +import { HTMLAttributes, ReactNode, useRef, useState } from 'react'; +import styles from './spoiler.module.scss'; +import { useIsOverflow } from '/@/renderer/hooks'; + +interface SpoilerProps extends HTMLAttributes { + children?: ReactNode; + defaultOpened?: boolean; + maxHeight?: number; +} + +export const Spoiler = ({ maxHeight, defaultOpened, children, ...props }: SpoilerProps) => { + const ref = useRef(null); + const isOverflow = useIsOverflow(ref); + const [isExpanded, setIsExpanded] = useState(!!defaultOpened); + + const spoilerClassNames = clsx(styles.spoiler, { + [styles.canExpand]: isOverflow, + [styles.isExpanded]: isExpanded, + }); + + const handleToggleExpand = () => { + setIsExpanded((val) => !val); + }; + + return ( +
+ {children} +
+ ); +}; diff --git a/src/renderer/components/spoiler/spoiler.module.scss b/src/renderer/components/spoiler/spoiler.module.scss new file mode 100644 index 00000000..36bd1796 --- /dev/null +++ b/src/renderer/components/spoiler/spoiler.module.scss @@ -0,0 +1,31 @@ +.control:hover { + color: var(--btn-subtle-fg-hover); + text-decoration: none; +} + +.spoiler { + position: relative; + text-align: justify; + width: 100%; + height: 100%; + overflow: hidden; +} + +.spoiler:not(.is-expanded).can-expand:after { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 100%; + content: ''; + background: linear-gradient(to top, var(--main-bg) 10%, transparent 60%); + pointer-events: none; +} + +.spoiler.can-expand { + cursor: pointer; +} + +.spoiler.is-expanded { + max-height: 2500px !important; +} diff --git a/src/renderer/components/virtual-table/cells/note-cell.tsx b/src/renderer/components/virtual-table/cells/note-cell.tsx index 1191d597..eaa1b5f7 100644 --- a/src/renderer/components/virtual-table/cells/note-cell.tsx +++ b/src/renderer/components/virtual-table/cells/note-cell.tsx @@ -3,17 +3,7 @@ import { Skeleton } from '/@/renderer/components/skeleton'; import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell'; import { useMemo } from 'react'; import { Text } from '/@/renderer/components/text'; - -const URL_REGEX = - /((?:https?:\/\/)?(?:[\w-]{1,32}(?:\.[\w-]{1,32})+)(?:\/[\w\-./?%&=][^.|^\s]*)?)/g; - -const replaceURLWithHTMLLinks = (text: string) => { - const urlRegex = new RegExp(URL_REGEX, 'g'); - return text.replaceAll( - urlRegex, - (url) => `${url}`, - ); -}; +import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify'; export const NoteCell = ({ value }: ICellRendererParams) => { const formattedValue = useMemo(() => { @@ -39,9 +29,10 @@ export const NoteCell = ({ value }: ICellRendererParams) => { + > + {formattedValue} + ); }; diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx index d158d908..d75216ee 100644 --- a/src/renderer/features/albums/components/album-detail-content.tsx +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -4,13 +4,15 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li import { Box, Group, Stack } from '@mantine/core'; import { useSetState } from '@mantine/hooks'; import { useTranslation } from 'react-i18next'; +import { FaLastfmSquare } from 'react-icons/fa'; import { RiHeartFill, RiHeartLine, RiMoreFill, RiSettings2Fill } from 'react-icons/ri'; +import { SiMusicbrainz } from 'react-icons/si'; import { generatePath, useParams } from 'react-router'; import { Link } from 'react-router-dom'; import styled from 'styled-components'; import { queryKeys } from '/@/renderer/api/query-keys'; import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types'; -import { Button, Popover } from '/@/renderer/components'; +import { Button, Popover, Spoiler } from '/@/renderer/components'; import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel'; import { TableConfigDropdown, @@ -36,11 +38,13 @@ import { useAppFocus, useContainerQuery } from '/@/renderer/hooks'; import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer, useCurrentSong, useCurrentStatus } from '/@/renderer/store'; import { + useGeneralSettings, usePlayButtonBehavior, useSettingsStoreActions, useTableSettings, } from '/@/renderer/store/settings.store'; import { Play } from '/@/renderer/types'; +import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify'; const isFullWidthRow = (node: RowNode) => { return node.id?.startsWith('disc-'); @@ -54,6 +58,7 @@ const ContentContainer = styled.div` const DetailContainer = styled.div` display: flex; flex-direction: column; + gap: 2rem; padding: 1rem 2rem 5rem; overflow: hidden; `; @@ -75,6 +80,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP const status = useCurrentStatus(); const isFocused = useAppFocus(); const currentSong = useCurrentSong(); + const { externalLinks } = useGeneralSettings(); const columnDefs = useMemo( () => getColumnDefs(tableConfig.columns, false, 'albumDetail'), @@ -279,6 +285,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP }; const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false; + const comment = detailQuery?.data?.comment; const handleGeneralContextMenu = useHandleGeneralContextMenu( LibraryItem.ALBUM, @@ -313,6 +320,8 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP const { rowClassRules } = useCurrentSongRowStyles({ tableRef }); + const mbzId = detailQuery?.data?.mbzId; + return ( @@ -320,7 +329,6 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP @@ -372,10 +380,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP {showGenres && ( - + {detailQuery?.data?.genres?.map((genre) => ( + {mbzId ? ( + + ) : null} + + + ) : null} + {comment && ( + + {replaceURLWithHTMLLinks(comment)} + + )} { + const { t } = useTranslation(); + const { externalLinks } = useGeneralSettings(); const { albumArtistId } = useParams() as { albumArtistId: string }; const cq = useContainerQuery(); const handlePlayQueueAdd = usePlayQueueAdd(); @@ -324,6 +329,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten detailQuery?.data?.biography !== undefined && detailQuery?.data?.biography !== null; const showTopSongs = topSongsQuery?.data?.items?.length; const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false; + const mbzId = detailQuery?.data?.mbz; const isLoading = detailQuery?.isLoading || @@ -335,61 +341,58 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten - - - handlePlay(playButtonBehavior)} /> - - - - - - + + handlePlay(playButtonBehavior)} /> + - + + + + + {showGenres ? ( @@ -411,6 +414,46 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten ) : null} + {externalLinks ? ( + + + + {mbzId ? ( + + ) : null} + + + ) : null} {showBiography ? ( About {detailQuery?.data?.name} - ) : null} diff --git a/src/renderer/features/settings/components/general/control-settings.tsx b/src/renderer/features/settings/components/general/control-settings.tsx index 8e9407d0..35e6f2fc 100644 --- a/src/renderer/features/settings/components/general/control-settings.tsx +++ b/src/renderer/features/settings/components/general/control-settings.tsx @@ -268,6 +268,26 @@ export const ControlSettings = () => { isHidden: false, title: t('setting.skipPlaylistPage', { postProcess: 'sentenceCase' }), }, + { + control: ( + { + setSettings({ + general: { + ...settings, + externalLinks: e.currentTarget.checked, + }, + }); + }} + /> + ), + description: t('setting.externalLinks', { + context: 'description', + postProcess: 'sentenceCase', + }), + title: t('setting.externalLinks', { postProcess: 'sentenceCase' }), + }, ]; return ; diff --git a/src/renderer/hooks/index.ts b/src/renderer/hooks/index.ts index 82dd033a..0bc18aeb 100644 --- a/src/renderer/hooks/index.ts +++ b/src/renderer/hooks/index.ts @@ -5,3 +5,4 @@ export * from './use-container-query'; export * from './use-fast-average-color'; export * from './use-hide-scrollbar'; export * from './use-app-focus'; +export * from './use-is-overflow'; diff --git a/src/renderer/hooks/use-is-overflow.ts b/src/renderer/hooks/use-is-overflow.ts new file mode 100644 index 00000000..a3c0b7d0 --- /dev/null +++ b/src/renderer/hooks/use-is-overflow.ts @@ -0,0 +1,20 @@ +import { MutableRefObject, useState, useLayoutEffect } from 'react'; + +export const useIsOverflow = (ref: MutableRefObject) => { + const [isOverflow, setIsOverflow] = useState(undefined); + + useLayoutEffect(() => { + const { current } = ref; + + const trigger = () => { + const hasOverflow = (current?.scrollHeight || 0) > (current?.clientHeight || 0); + setIsOverflow(hasOverflow); + }; + + if (current) { + trigger(); + } + }, [ref]); + + return isOverflow; +}; diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 08f6830e..023f10ec 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -170,6 +170,7 @@ export interface SettingsState { general: { accent: string; defaultFullPlaylist: boolean; + externalLinks: boolean; followSystemTheme: boolean; language: string; playButtonBehavior: Play; @@ -282,6 +283,7 @@ const initialState: SettingsState = { general: { accent: 'rgb(53, 116, 252)', defaultFullPlaylist: true, + externalLinks: true, followSystemTheme: false, language: 'en', playButtonBehavior: Play.NOW, diff --git a/src/renderer/utils/linkify.tsx b/src/renderer/utils/linkify.tsx new file mode 100644 index 00000000..e1bc794d --- /dev/null +++ b/src/renderer/utils/linkify.tsx @@ -0,0 +1,38 @@ +// Inspired by https://github.com/navidrome/navidrome/blob/c530ccf13854e3a840ddf63eef5e2323fbe2827d/ui/src/common/AnchorMe.js +const URL_REGEX = + /((?:https?:\/\/)?(?:[\w-]{1,32}(?:\.[\w-]{1,32})+)(?:\/[\w\-./?%&=][^.|^\s]*)?)/g; + +export const replaceURLWithHTMLLinks = (text: string) => { + const urlRegex = new RegExp(URL_REGEX, 'g'); + const matches = text.matchAll(urlRegex); + const elements = []; + let lastIndex = 0; + + for (const match of matches) { + const position = match.index!; + + if (position > lastIndex) { + elements.push(text.substring(lastIndex, position)); + } + + const link = match[0]; + elements.push( + + {link} + , + ); + + lastIndex = position + link.length; + } + + if (text.length > lastIndex) { + elements.push(text.substring(lastIndex)); + } + + return elements; +};