Merge pull request #450 from kgarner7/more-metadata
[feature]: Show album comment, Last.fm/MusicBrainz links
This commit is contained in:
commit
ccb0e14e48
18 changed files with 335 additions and 72 deletions
|
@ -16,7 +16,11 @@
|
||||||
"removeFromQueue": "remove from queue",
|
"removeFromQueue": "remove from queue",
|
||||||
"setRating": "set rating",
|
"setRating": "set rating",
|
||||||
"toggleSmartPlaylistEditor": "toggle $t(entity.smartPlaylist) editor",
|
"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": {
|
"common": {
|
||||||
"action_one": "action",
|
"action_one": "action",
|
||||||
|
@ -419,6 +423,8 @@
|
||||||
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
|
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
|
||||||
"enableRemote": "enable remote control server",
|
"enableRemote": "enable remote control server",
|
||||||
"enableRemote_description": "enables the remote control server to allow other devices to control the application",
|
"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": "exit to tray",
|
||||||
"exitToTray_description": "exit the application to the system tray",
|
"exitToTray_description": "exit the application to the system tray",
|
||||||
"floatingQueueArea": "show floating queue hover area",
|
"floatingQueueArea": "show floating queue hover area",
|
||||||
|
|
|
@ -202,6 +202,7 @@ const normalizeAlbum = (
|
||||||
imageSize?: number,
|
imageSize?: number,
|
||||||
): Album => {
|
): Album => {
|
||||||
return {
|
return {
|
||||||
|
albumArtist: item.AlbumArtist,
|
||||||
albumArtists:
|
albumArtists:
|
||||||
item.AlbumArtists.map((entry) => ({
|
item.AlbumArtists.map((entry) => ({
|
||||||
id: entry.Id,
|
id: entry.Id,
|
||||||
|
@ -214,6 +215,7 @@ const normalizeAlbum = (
|
||||||
name: entry.Name,
|
name: entry.Name,
|
||||||
})),
|
})),
|
||||||
backdropImageUrl: null,
|
backdropImageUrl: null,
|
||||||
|
comment: null,
|
||||||
createdAt: item.DateCreated,
|
createdAt: item.DateCreated,
|
||||||
duration: item.RunTimeTicks / 10000,
|
duration: item.RunTimeTicks / 10000,
|
||||||
genres: item.GenreItems?.map((entry) => ({
|
genres: item.GenreItems?.map((entry) => ({
|
||||||
|
@ -232,6 +234,7 @@ const normalizeAlbum = (
|
||||||
isCompilation: null,
|
isCompilation: null,
|
||||||
itemType: LibraryItem.ALBUM,
|
itemType: LibraryItem.ALBUM,
|
||||||
lastPlayedAt: null,
|
lastPlayedAt: null,
|
||||||
|
mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
|
||||||
name: item.Name,
|
name: item.Name,
|
||||||
playCount: item.UserData?.PlayCount || 0,
|
playCount: item.UserData?.PlayCount || 0,
|
||||||
releaseDate: item.PremiereDate?.split('T')[0] || null,
|
releaseDate: item.PremiereDate?.split('T')[0] || null,
|
||||||
|
@ -287,6 +290,7 @@ const normalizeAlbumArtist = (
|
||||||
}),
|
}),
|
||||||
itemType: LibraryItem.ALBUM_ARTIST,
|
itemType: LibraryItem.ALBUM_ARTIST,
|
||||||
lastPlayedAt: null,
|
lastPlayedAt: null,
|
||||||
|
mbz: item.ProviderIds?.MusicBrainzArtist || null,
|
||||||
name: item.Name,
|
name: item.Name,
|
||||||
playCount: item.UserData?.PlayCount || 0,
|
playCount: item.UserData?.PlayCount || 0,
|
||||||
serverId: server?.id || '',
|
serverId: server?.id || '',
|
||||||
|
|
|
@ -422,6 +422,11 @@ const song = z.object({
|
||||||
UserData: userData.optional(),
|
UserData: userData.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const providerIds = z.object({
|
||||||
|
MusicBrainzAlbum: z.string().optional(),
|
||||||
|
MusicBrainzArtist: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const albumArtist = z.object({
|
const albumArtist = z.object({
|
||||||
BackdropImageTags: z.array(z.string()),
|
BackdropImageTags: z.array(z.string()),
|
||||||
ChannelId: z.null(),
|
ChannelId: z.null(),
|
||||||
|
@ -435,6 +440,7 @@ const albumArtist = z.object({
|
||||||
LocationType: z.string(),
|
LocationType: z.string(),
|
||||||
Name: z.string(),
|
Name: z.string(),
|
||||||
Overview: z.string(),
|
Overview: z.string(),
|
||||||
|
ProviderIds: providerIds.optional(),
|
||||||
RunTimeTicks: z.number(),
|
RunTimeTicks: z.number(),
|
||||||
ServerId: z.string(),
|
ServerId: z.string(),
|
||||||
Type: z.string(),
|
Type: z.string(),
|
||||||
|
@ -466,6 +472,7 @@ const album = z.object({
|
||||||
ParentLogoItemId: z.string(),
|
ParentLogoItemId: z.string(),
|
||||||
PremiereDate: z.string().optional(),
|
PremiereDate: z.string().optional(),
|
||||||
ProductionYear: z.number(),
|
ProductionYear: z.number(),
|
||||||
|
ProviderIds: providerIds.optional(),
|
||||||
RunTimeTicks: z.number(),
|
RunTimeTicks: z.number(),
|
||||||
ServerId: z.string(),
|
ServerId: z.string(),
|
||||||
Songs: z.array(song).optional(), // This is not a native Jellyfin property -- this is used for combined album detail
|
Songs: z.array(song).optional(), // This is not a native Jellyfin property -- this is used for combined album detail
|
||||||
|
|
|
@ -151,9 +151,11 @@ const normalizeAlbum = (
|
||||||
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
|
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
albumArtist: item.albumArtist,
|
||||||
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
|
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
|
||||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||||
backdropImageUrl: imageBackdropUrl,
|
backdropImageUrl: imageBackdropUrl,
|
||||||
|
comment: item.comment || null,
|
||||||
createdAt: item.createdAt.split('T')[0],
|
createdAt: item.createdAt.split('T')[0],
|
||||||
duration: item.duration * 1000 || null,
|
duration: item.duration * 1000 || null,
|
||||||
genres: item.genres?.map((genre) => ({
|
genres: item.genres?.map((genre) => ({
|
||||||
|
@ -168,6 +170,7 @@ const normalizeAlbum = (
|
||||||
isCompilation: item.compilation,
|
isCompilation: item.compilation,
|
||||||
itemType: LibraryItem.ALBUM,
|
itemType: LibraryItem.ALBUM,
|
||||||
lastPlayedAt: normalizePlayDate(item),
|
lastPlayedAt: normalizePlayDate(item),
|
||||||
|
mbzId: item.mbzAlbumId || null,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
playCount: item.playCount,
|
playCount: item.playCount,
|
||||||
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
|
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
|
||||||
|
@ -216,6 +219,7 @@ const normalizeAlbumArtist = (
|
||||||
imageUrl: imageUrl || null,
|
imageUrl: imageUrl || null,
|
||||||
itemType: LibraryItem.ALBUM_ARTIST,
|
itemType: LibraryItem.ALBUM_ARTIST,
|
||||||
lastPlayedAt: normalizePlayDate(item),
|
lastPlayedAt: normalizePlayDate(item),
|
||||||
|
mbz: item.mbzArtistId || null,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
playCount: item.playCount,
|
playCount: item.playCount,
|
||||||
serverId: server?.id || 'unknown',
|
serverId: server?.id || 'unknown',
|
||||||
|
|
|
@ -111,6 +111,7 @@ const album = z.object({
|
||||||
allArtistIds: z.string(),
|
allArtistIds: z.string(),
|
||||||
artist: z.string(),
|
artist: z.string(),
|
||||||
artistId: z.string(),
|
artistId: z.string(),
|
||||||
|
comment: z.string().optional(),
|
||||||
compilation: z.boolean(),
|
compilation: z.boolean(),
|
||||||
coverArtId: z.string().optional(), // Removed after v0.48.0
|
coverArtId: z.string().optional(), // Removed after v0.48.0
|
||||||
coverArtPath: z.string().optional(), // Removed after v0.48.0
|
coverArtPath: z.string().optional(), // Removed after v0.48.0
|
||||||
|
|
|
@ -126,6 +126,7 @@ const normalizeAlbumArtist = (
|
||||||
imageUrl,
|
imageUrl,
|
||||||
itemType: LibraryItem.ALBUM_ARTIST,
|
itemType: LibraryItem.ALBUM_ARTIST,
|
||||||
lastPlayedAt: null,
|
lastPlayedAt: null,
|
||||||
|
mbz: null,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
playCount: null,
|
playCount: null,
|
||||||
serverId: server?.id || 'unknown',
|
serverId: server?.id || 'unknown',
|
||||||
|
@ -150,11 +151,13 @@ const normalizeAlbum = (
|
||||||
}) || null;
|
}) || null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
albumArtist: item.artist,
|
||||||
albumArtists: item.artistId
|
albumArtists: item.artistId
|
||||||
? [{ id: item.artistId, imageUrl: null, name: item.artist }]
|
? [{ id: item.artistId, imageUrl: null, name: item.artist }]
|
||||||
: [],
|
: [],
|
||||||
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
|
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
|
||||||
backdropImageUrl: null,
|
backdropImageUrl: null,
|
||||||
|
comment: null,
|
||||||
createdAt: item.created,
|
createdAt: item.created,
|
||||||
duration: item.duration,
|
duration: item.duration,
|
||||||
genres: item.genre
|
genres: item.genre
|
||||||
|
@ -173,6 +176,7 @@ const normalizeAlbum = (
|
||||||
isCompilation: null,
|
isCompilation: null,
|
||||||
itemType: LibraryItem.ALBUM,
|
itemType: LibraryItem.ALBUM,
|
||||||
lastPlayedAt: null,
|
lastPlayedAt: null,
|
||||||
|
mbzId: null,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
playCount: null,
|
playCount: null,
|
||||||
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
|
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
|
||||||
|
|
|
@ -144,9 +144,11 @@ export type Genre = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Album = {
|
export type Album = {
|
||||||
|
albumArtist: string;
|
||||||
albumArtists: RelatedArtist[];
|
albumArtists: RelatedArtist[];
|
||||||
artists: RelatedArtist[];
|
artists: RelatedArtist[];
|
||||||
backdropImageUrl: string | null;
|
backdropImageUrl: string | null;
|
||||||
|
comment: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
duration: number | null;
|
duration: number | null;
|
||||||
genres: Genre[];
|
genres: Genre[];
|
||||||
|
@ -156,6 +158,7 @@ export type Album = {
|
||||||
isCompilation: boolean | null;
|
isCompilation: boolean | null;
|
||||||
itemType: LibraryItem.ALBUM;
|
itemType: LibraryItem.ALBUM;
|
||||||
lastPlayedAt: string | null;
|
lastPlayedAt: string | null;
|
||||||
|
mbzId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
playCount: number | null;
|
playCount: number | null;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
|
@ -228,6 +231,7 @@ export type AlbumArtist = {
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
itemType: LibraryItem.ALBUM_ARTIST;
|
itemType: LibraryItem.ALBUM_ARTIST;
|
||||||
lastPlayedAt: string | null;
|
lastPlayedAt: string | null;
|
||||||
|
mbz: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
playCount: number | null;
|
playCount: number | null;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
|
|
@ -27,6 +27,7 @@ export * from './select';
|
||||||
export * from './skeleton';
|
export * from './skeleton';
|
||||||
export * from './slider';
|
export * from './slider';
|
||||||
export * from './spinner';
|
export * from './spinner';
|
||||||
|
export * from './spoiler';
|
||||||
export * from './switch';
|
export * from './switch';
|
||||||
export * from './tabs';
|
export * from './tabs';
|
||||||
export * from './text';
|
export * from './text';
|
||||||
|
|
39
src/renderer/components/spoiler/index.tsx
Normal file
39
src/renderer/components/spoiler/index.tsx
Normal file
|
@ -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<HTMLDivElement> {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={spoilerClassNames}
|
||||||
|
role="button"
|
||||||
|
style={{ maxHeight: maxHeight ?? '100px' }}
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleToggleExpand}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
31
src/renderer/components/spoiler/spoiler.module.scss
Normal file
31
src/renderer/components/spoiler/spoiler.module.scss
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -3,17 +3,7 @@ import { Skeleton } from '/@/renderer/components/skeleton';
|
||||||
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
|
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Text } from '/@/renderer/components/text';
|
import { Text } from '/@/renderer/components/text';
|
||||||
|
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||||
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) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NoteCell = ({ value }: ICellRendererParams) => {
|
export const NoteCell = ({ value }: ICellRendererParams) => {
|
||||||
const formattedValue = useMemo(() => {
|
const formattedValue = useMemo(() => {
|
||||||
|
@ -39,9 +29,10 @@ export const NoteCell = ({ value }: ICellRendererParams) => {
|
||||||
<CellContainer $position="left">
|
<CellContainer $position="left">
|
||||||
<Text
|
<Text
|
||||||
$secondary
|
$secondary
|
||||||
dangerouslySetInnerHTML={{ __html: formattedValue }}
|
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
/>
|
>
|
||||||
|
{formattedValue}
|
||||||
|
</Text>
|
||||||
</CellContainer>
|
</CellContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,13 +4,15 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
||||||
import { Box, Group, Stack } from '@mantine/core';
|
import { Box, Group, Stack } from '@mantine/core';
|
||||||
import { useSetState } from '@mantine/hooks';
|
import { useSetState } from '@mantine/hooks';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaLastfmSquare } from 'react-icons/fa';
|
||||||
import { RiHeartFill, RiHeartLine, RiMoreFill, RiSettings2Fill } from 'react-icons/ri';
|
import { RiHeartFill, RiHeartLine, RiMoreFill, RiSettings2Fill } from 'react-icons/ri';
|
||||||
|
import { SiMusicbrainz } from 'react-icons/si';
|
||||||
import { generatePath, useParams } from 'react-router';
|
import { generatePath, useParams } from 'react-router';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types';
|
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 { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
||||||
import {
|
import {
|
||||||
TableConfigDropdown,
|
TableConfigDropdown,
|
||||||
|
@ -36,11 +38,13 @@ import { useAppFocus, useContainerQuery } from '/@/renderer/hooks';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { useCurrentServer, useCurrentSong, useCurrentStatus } from '/@/renderer/store';
|
import { useCurrentServer, useCurrentSong, useCurrentStatus } from '/@/renderer/store';
|
||||||
import {
|
import {
|
||||||
|
useGeneralSettings,
|
||||||
usePlayButtonBehavior,
|
usePlayButtonBehavior,
|
||||||
useSettingsStoreActions,
|
useSettingsStoreActions,
|
||||||
useTableSettings,
|
useTableSettings,
|
||||||
} from '/@/renderer/store/settings.store';
|
} from '/@/renderer/store/settings.store';
|
||||||
import { Play } from '/@/renderer/types';
|
import { Play } from '/@/renderer/types';
|
||||||
|
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||||
|
|
||||||
const isFullWidthRow = (node: RowNode) => {
|
const isFullWidthRow = (node: RowNode) => {
|
||||||
return node.id?.startsWith('disc-');
|
return node.id?.startsWith('disc-');
|
||||||
|
@ -54,6 +58,7 @@ const ContentContainer = styled.div`
|
||||||
const DetailContainer = styled.div`
|
const DetailContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
padding: 1rem 2rem 5rem;
|
padding: 1rem 2rem 5rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
`;
|
`;
|
||||||
|
@ -75,6 +80,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||||
const status = useCurrentStatus();
|
const status = useCurrentStatus();
|
||||||
const isFocused = useAppFocus();
|
const isFocused = useAppFocus();
|
||||||
const currentSong = useCurrentSong();
|
const currentSong = useCurrentSong();
|
||||||
|
const { externalLinks } = useGeneralSettings();
|
||||||
|
|
||||||
const columnDefs = useMemo(
|
const columnDefs = useMemo(
|
||||||
() => getColumnDefs(tableConfig.columns, false, 'albumDetail'),
|
() => 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 showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
|
||||||
|
const comment = detailQuery?.data?.comment;
|
||||||
|
|
||||||
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
||||||
LibraryItem.ALBUM,
|
LibraryItem.ALBUM,
|
||||||
|
@ -313,6 +320,8 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||||
|
|
||||||
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
|
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
|
||||||
|
|
||||||
|
const mbzId = detailQuery?.data?.mbzId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentContainer>
|
<ContentContainer>
|
||||||
<LibraryBackgroundOverlay $backgroundColor={background} />
|
<LibraryBackgroundOverlay $backgroundColor={background} />
|
||||||
|
@ -320,7 +329,6 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||||
<Box component="section">
|
<Box component="section">
|
||||||
<Group
|
<Group
|
||||||
position="apart"
|
position="apart"
|
||||||
py="1rem"
|
|
||||||
spacing="sm"
|
spacing="sm"
|
||||||
>
|
>
|
||||||
<Group>
|
<Group>
|
||||||
|
@ -372,10 +380,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
{showGenres && (
|
{showGenres && (
|
||||||
<Box
|
<Box component="section">
|
||||||
component="section"
|
|
||||||
py="1rem"
|
|
||||||
>
|
|
||||||
<Group spacing="sm">
|
<Group spacing="sm">
|
||||||
{detailQuery?.data?.genres?.map((genre) => (
|
{detailQuery?.data?.genres?.map((genre) => (
|
||||||
<Button
|
<Button
|
||||||
|
@ -395,6 +400,51 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
{externalLinks ? (
|
||||||
|
<Box component="section">
|
||||||
|
<Group spacing="sm">
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
component="a"
|
||||||
|
href={`https://www.last.fm/music/${encodeURIComponent(
|
||||||
|
detailQuery?.data?.albumArtist || '',
|
||||||
|
)}/${encodeURIComponent(detailQuery.data?.name || '')}`}
|
||||||
|
radius="md"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size="md"
|
||||||
|
target="_blank"
|
||||||
|
tooltip={{
|
||||||
|
label: t('action.openIn.lastfm'),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<FaLastfmSquare size={25} />
|
||||||
|
</Button>
|
||||||
|
{mbzId ? (
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
component="a"
|
||||||
|
href={`https://musicbrainz.org/release/${mbzId}`}
|
||||||
|
radius="md"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size="md"
|
||||||
|
target="_blank"
|
||||||
|
tooltip={{
|
||||||
|
label: t('action.openIn.musicbrainz'),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<SiMusicbrainz size={25} />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
{comment && (
|
||||||
|
<Box component="section">
|
||||||
|
<Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box style={{ minHeight: '300px' }}>
|
<Box style={{ minHeight: '300px' }}>
|
||||||
<VirtualTable
|
<VirtualTable
|
||||||
key={`table-${tableConfig.rowHeight}`}
|
key={`table-${tableConfig.rowHeight}`}
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||||
import { Box, Group, Stack } from '@mantine/core';
|
import { Box, Group, Stack } from '@mantine/core';
|
||||||
import { useMemo } from 'react';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaLastfmSquare } from 'react-icons/fa';
|
||||||
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||||
|
import { SiMusicbrainz } from 'react-icons/si';
|
||||||
import { generatePath, useParams } from 'react-router';
|
import { generatePath, useParams } from 'react-router';
|
||||||
import { createSearchParams, Link } from 'react-router-dom';
|
import { createSearchParams, Link } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
@ -14,7 +17,7 @@ import {
|
||||||
ServerType,
|
ServerType,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { Button, Text, TextTitle } from '/@/renderer/components';
|
import { Button, Spoiler, TextTitle } from '/@/renderer/components';
|
||||||
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
||||||
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
|
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||||
|
@ -34,7 +37,7 @@ import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components
|
||||||
import { useContainerQuery } from '/@/renderer/hooks';
|
import { useContainerQuery } from '/@/renderer/hooks';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
import { useGeneralSettings, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||||
import { CardRow, Play, TableColumn } from '/@/renderer/types';
|
import { CardRow, Play, TableColumn } from '/@/renderer/types';
|
||||||
|
|
||||||
const ContentContainer = styled.div`
|
const ContentContainer = styled.div`
|
||||||
|
@ -45,7 +48,7 @@ const ContentContainer = styled.div`
|
||||||
const DetailContainer = styled.div`
|
const DetailContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 3rem;
|
gap: 2rem;
|
||||||
padding: 1rem 2rem 5rem;
|
padding: 1rem 2rem 5rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
@ -59,6 +62,8 @@ interface AlbumArtistDetailContentProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailContentProps) => {
|
export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailContentProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { externalLinks } = useGeneralSettings();
|
||||||
const { albumArtistId } = useParams() as { albumArtistId: string };
|
const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||||
const cq = useContainerQuery();
|
const cq = useContainerQuery();
|
||||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||||
|
@ -324,6 +329,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||||
detailQuery?.data?.biography !== undefined && detailQuery?.data?.biography !== null;
|
detailQuery?.data?.biography !== undefined && detailQuery?.data?.biography !== null;
|
||||||
const showTopSongs = topSongsQuery?.data?.items?.length;
|
const showTopSongs = topSongsQuery?.data?.items?.length;
|
||||||
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
|
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
|
||||||
|
const mbzId = detailQuery?.data?.mbz;
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
detailQuery?.isLoading ||
|
detailQuery?.isLoading ||
|
||||||
|
@ -335,61 +341,58 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||||
<ContentContainer ref={cq.ref}>
|
<ContentContainer ref={cq.ref}>
|
||||||
<LibraryBackgroundOverlay $backgroundColor={background} />
|
<LibraryBackgroundOverlay $backgroundColor={background} />
|
||||||
<DetailContainer>
|
<DetailContainer>
|
||||||
<Stack spacing="lg">
|
<Group spacing="md">
|
||||||
<Group spacing="md">
|
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||||
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
<Group spacing="xs">
|
||||||
<Group spacing="xs">
|
|
||||||
<Button
|
|
||||||
compact
|
|
||||||
loading={
|
|
||||||
createFavoriteMutation.isLoading ||
|
|
||||||
deleteFavoriteMutation.isLoading
|
|
||||||
}
|
|
||||||
variant="subtle"
|
|
||||||
onClick={handleFavorite}
|
|
||||||
>
|
|
||||||
{detailQuery?.data?.userFavorite ? (
|
|
||||||
<RiHeartFill
|
|
||||||
color="red"
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<RiHeartLine size={20} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
compact
|
|
||||||
variant="subtle"
|
|
||||||
onClick={(e) => {
|
|
||||||
if (!detailQuery?.data) return;
|
|
||||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RiMoreFill size={20} />
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<Group spacing="md">
|
|
||||||
<Button
|
<Button
|
||||||
compact
|
compact
|
||||||
uppercase
|
loading={
|
||||||
component={Link}
|
createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading
|
||||||
to={artistDiscographyLink}
|
}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
onClick={handleFavorite}
|
||||||
>
|
>
|
||||||
View discography
|
{detailQuery?.data?.userFavorite ? (
|
||||||
|
<RiHeartFill
|
||||||
|
color="red"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RiHeartLine size={20} />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
compact
|
compact
|
||||||
uppercase
|
|
||||||
component={Link}
|
|
||||||
to={artistSongsLink}
|
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!detailQuery?.data) return;
|
||||||
|
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
View all songs
|
<RiMoreFill size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Group>
|
||||||
|
<Group spacing="md">
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
uppercase
|
||||||
|
component={Link}
|
||||||
|
to={artistDiscographyLink}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
View discography
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
uppercase
|
||||||
|
component={Link}
|
||||||
|
to={artistSongsLink}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
View all songs
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
{showGenres ? (
|
{showGenres ? (
|
||||||
<Box component="section">
|
<Box component="section">
|
||||||
<Group spacing="sm">
|
<Group spacing="sm">
|
||||||
|
@ -411,6 +414,46 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
|
{externalLinks ? (
|
||||||
|
<Box component="section">
|
||||||
|
<Group spacing="sm">
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
component="a"
|
||||||
|
href={`https://www.last.fm/music/${encodeURIComponent(
|
||||||
|
detailQuery?.data?.name || '',
|
||||||
|
)}`}
|
||||||
|
radius="md"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size="md"
|
||||||
|
target="_blank"
|
||||||
|
tooltip={{
|
||||||
|
label: t('action.openIn.lastfm'),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<FaLastfmSquare size={25} />
|
||||||
|
</Button>
|
||||||
|
{mbzId ? (
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
component="a"
|
||||||
|
href={`https://musicbrainz.org/artist/${mbzId}`}
|
||||||
|
radius="md"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size="md"
|
||||||
|
target="_blank"
|
||||||
|
tooltip={{
|
||||||
|
label: t('action.openIn.musicbrainz'),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<SiMusicbrainz size={25} />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
{showBiography ? (
|
{showBiography ? (
|
||||||
<Box
|
<Box
|
||||||
component="section"
|
component="section"
|
||||||
|
@ -422,11 +465,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||||
>
|
>
|
||||||
About {detailQuery?.data?.name}
|
About {detailQuery?.data?.name}
|
||||||
</TextTitle>
|
</TextTitle>
|
||||||
<Text
|
<Spoiler
|
||||||
$secondary
|
|
||||||
component="p"
|
|
||||||
dangerouslySetInnerHTML={{ __html: detailQuery?.data?.biography || '' }}
|
dangerouslySetInnerHTML={{ __html: detailQuery?.data?.biography || '' }}
|
||||||
sx={{ textAlign: 'justify' }}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
@ -268,6 +268,26 @@ export const ControlSettings = () => {
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
title: t('setting.skipPlaylistPage', { postProcess: 'sentenceCase' }),
|
title: t('setting.skipPlaylistPage', { postProcess: 'sentenceCase' }),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={settings.externalLinks}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
...settings,
|
||||||
|
externalLinks: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.externalLinks', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
title: t('setting.externalLinks', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return <SettingsSection options={controlOptions} />;
|
return <SettingsSection options={controlOptions} />;
|
||||||
|
|
|
@ -5,3 +5,4 @@ export * from './use-container-query';
|
||||||
export * from './use-fast-average-color';
|
export * from './use-fast-average-color';
|
||||||
export * from './use-hide-scrollbar';
|
export * from './use-hide-scrollbar';
|
||||||
export * from './use-app-focus';
|
export * from './use-app-focus';
|
||||||
|
export * from './use-is-overflow';
|
||||||
|
|
20
src/renderer/hooks/use-is-overflow.ts
Normal file
20
src/renderer/hooks/use-is-overflow.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { MutableRefObject, useState, useLayoutEffect } from 'react';
|
||||||
|
|
||||||
|
export const useIsOverflow = (ref: MutableRefObject<HTMLDivElement | null>) => {
|
||||||
|
const [isOverflow, setIsOverflow] = useState<Boolean | undefined>(undefined);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const { current } = ref;
|
||||||
|
|
||||||
|
const trigger = () => {
|
||||||
|
const hasOverflow = (current?.scrollHeight || 0) > (current?.clientHeight || 0);
|
||||||
|
setIsOverflow(hasOverflow);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
trigger();
|
||||||
|
}
|
||||||
|
}, [ref]);
|
||||||
|
|
||||||
|
return isOverflow;
|
||||||
|
};
|
|
@ -170,6 +170,7 @@ export interface SettingsState {
|
||||||
general: {
|
general: {
|
||||||
accent: string;
|
accent: string;
|
||||||
defaultFullPlaylist: boolean;
|
defaultFullPlaylist: boolean;
|
||||||
|
externalLinks: boolean;
|
||||||
followSystemTheme: boolean;
|
followSystemTheme: boolean;
|
||||||
language: string;
|
language: string;
|
||||||
playButtonBehavior: Play;
|
playButtonBehavior: Play;
|
||||||
|
@ -282,6 +283,7 @@ const initialState: SettingsState = {
|
||||||
general: {
|
general: {
|
||||||
accent: 'rgb(53, 116, 252)',
|
accent: 'rgb(53, 116, 252)',
|
||||||
defaultFullPlaylist: true,
|
defaultFullPlaylist: true,
|
||||||
|
externalLinks: true,
|
||||||
followSystemTheme: false,
|
followSystemTheme: false,
|
||||||
language: 'en',
|
language: 'en',
|
||||||
playButtonBehavior: Play.NOW,
|
playButtonBehavior: Play.NOW,
|
||||||
|
|
38
src/renderer/utils/linkify.tsx
Normal file
38
src/renderer/utils/linkify.tsx
Normal file
|
@ -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(
|
||||||
|
<a
|
||||||
|
key={lastIndex}
|
||||||
|
href={link}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{link}
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
|
||||||
|
lastIndex = position + link.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length > lastIndex) {
|
||||||
|
elements.push(text.substring(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
};
|
Reference in a new issue