mirror of
https://github.com/jeffvli/feishin.git
synced 2024-11-20 06:27:09 +01:00
Merge pull request #450 from kgarner7/more-metadata
[feature]: Show album comment, Last.fm/MusicBrainz links
This commit is contained in:
commit
ccb0e14e48
@ -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",
|
||||
|
@ -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 || '',
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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';
|
||||
|
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 { 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) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
|
||||
);
|
||||
};
|
||||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||
|
||||
export const NoteCell = ({ value }: ICellRendererParams) => {
|
||||
const formattedValue = useMemo(() => {
|
||||
@ -39,9 +29,10 @@ export const NoteCell = ({ value }: ICellRendererParams) => {
|
||||
<CellContainer $position="left">
|
||||
<Text
|
||||
$secondary
|
||||
dangerouslySetInnerHTML={{ __html: formattedValue }}
|
||||
overflow="hidden"
|
||||
/>
|
||||
>
|
||||
{formattedValue}
|
||||
</Text>
|
||||
</CellContainer>
|
||||
);
|
||||
};
|
||||
|
@ -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 (
|
||||
<ContentContainer>
|
||||
<LibraryBackgroundOverlay $backgroundColor={background} />
|
||||
@ -320,7 +329,6 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
<Box component="section">
|
||||
<Group
|
||||
position="apart"
|
||||
py="1rem"
|
||||
spacing="sm"
|
||||
>
|
||||
<Group>
|
||||
@ -372,10 +380,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
</Group>
|
||||
</Box>
|
||||
{showGenres && (
|
||||
<Box
|
||||
component="section"
|
||||
py="1rem"
|
||||
>
|
||||
<Box component="section">
|
||||
<Group spacing="sm">
|
||||
{detailQuery?.data?.genres?.map((genre) => (
|
||||
<Button
|
||||
@ -395,6 +400,51 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
|
||||
</Group>
|
||||
</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' }}>
|
||||
<VirtualTable
|
||||
key={`table-${tableConfig.rowHeight}`}
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/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 { SiMusicbrainz } from 'react-icons/si';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { createSearchParams, Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
@ -14,7 +17,7 @@ import {
|
||||
ServerType,
|
||||
SortOrder,
|
||||
} 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 { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
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 { AppRoute } from '/@/renderer/router/routes';
|
||||
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';
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
@ -45,7 +48,7 @@ const ContentContainer = styled.div`
|
||||
const DetailContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
gap: 2rem;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
|
||||
@ -59,6 +62,8 @@ interface AlbumArtistDetailContentProps {
|
||||
}
|
||||
|
||||
export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailContentProps) => {
|
||||
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
|
||||
<ContentContainer ref={cq.ref}>
|
||||
<LibraryBackgroundOverlay $backgroundColor={background} />
|
||||
<DetailContainer>
|
||||
<Stack spacing="lg">
|
||||
<Group spacing="md">
|
||||
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<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">
|
||||
<Group spacing="md">
|
||||
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<Group spacing="xs">
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
component={Link}
|
||||
to={artistDiscographyLink}
|
||||
loading={
|
||||
createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading
|
||||
}
|
||||
variant="subtle"
|
||||
onClick={handleFavorite}
|
||||
>
|
||||
View discography
|
||||
{detailQuery?.data?.userFavorite ? (
|
||||
<RiHeartFill
|
||||
color="red"
|
||||
size={20}
|
||||
/>
|
||||
) : (
|
||||
<RiHeartLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
component={Link}
|
||||
to={artistSongsLink}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
>
|
||||
View all songs
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</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 ? (
|
||||
<Box component="section">
|
||||
<Group spacing="sm">
|
||||
@ -411,6 +414,46 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
</Group>
|
||||
</Box>
|
||||
) : 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 ? (
|
||||
<Box
|
||||
component="section"
|
||||
@ -422,11 +465,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
>
|
||||
About {detailQuery?.data?.name}
|
||||
</TextTitle>
|
||||
<Text
|
||||
$secondary
|
||||
component="p"
|
||||
<Spoiler
|
||||
dangerouslySetInnerHTML={{ __html: detailQuery?.data?.biography || '' }}
|
||||
sx={{ textAlign: 'justify' }}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
@ -268,6 +268,26 @@ export const ControlSettings = () => {
|
||||
isHidden: false,
|
||||
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} />;
|
||||
|
@ -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';
|
||||
|
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: {
|
||||
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,
|
||||
|
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;
|
||||
};
|
Loading…
Reference in New Issue
Block a user