Add dynamic gradient background for artist page

This commit is contained in:
jeffvli
2021-11-04 16:56:44 -07:00
committed by Jeff
parent 5aedb3b29a
commit 0118abb6ec
5 changed files with 294 additions and 195 deletions

View File

@@ -260,6 +260,7 @@
"electron-redux": "^1.5.4",
"electron-settings": "^4.0.2",
"electron-updater": "^4.3.4",
"fast-average-color": "^7.0.1",
"format-duration": "^1.4.0",
"history": "^5.0.0",
"image-downloader": "^4.0.3",

View File

@@ -48,7 +48,7 @@ const GenericPage = ({ header, children, hideDivider, ...rest }: any) => {
{header}
</PageHeader>
{!hideDivider && <Divider />}
<PageContent id="page-content" padding={rest.padding}>
<PageContent id="page-content" padding={rest.padding} $zIndex={rest.contentZIndex}>
{children}
</PageContent>
</PageContainer>

View File

@@ -153,10 +153,10 @@ export const PageHeader = styled(Header)<{ padding?: string }>`
z-index: 1;
`;
export const PageContent = styled(Content)<{ padding?: string }>`
export const PageContent = styled(Content)<{ padding?: string; $zIndex?: number }>`
position: relative;
padding: ${(props) => (props.padding ? props.padding : '10px')};
z-index: 1;
z-index: ${(props) => props.$zIndex};
`;
// Sidebar.tsx
@@ -213,12 +213,16 @@ export const PageHeaderTitle = styled.h1`
}
`;
export const PageHeaderWrapper = styled.div<{ $hasImage: boolean; $imageHeight: string }>`
display: ${(props) => (props.$hasImage ? 'inline-block' : 'undefined')};
width: ${(props) => (props.$hasImage ? `calc(100% - ${props.$imageHeight + 15}px)` : '100%')};
margin-left: ${(props) => (props.$hasImage ? '15px' : '0px')};
export const PageHeaderWrapper = styled.div<{
hasImage: boolean;
imageHeight: string;
isDark?: boolean;
}>`
display: ${(props) => (props.hasImage ? 'inline-block' : 'undefined')};
width: ${(props) => (props.hasImage ? `calc(100% - ${props.imageHeight + 15}px)` : '100%')};
margin-left: ${(props) => (props.hasImage ? '15px' : '0px')};
vertical-align: top;
color: ${(props) => (props.$hasImage ? '#D8D8D8' : props.theme.colors.layout.page.color)};
color: ${(props) => (props.isDark ? '#D8D8D8' : props.theme.colors.layout.page.color)};
`;
export const PageHeaderSubtitleWrapper = styled.span`
@@ -228,7 +232,15 @@ export const PageHeaderSubtitleWrapper = styled.span`
`;
export const PageHeaderSubtitleDataLine = styled.div<{ $top?: boolean }>`
margin-top: ${(props) => (props.$top ? '0px' : '10px')};
margin-top: ${(props) => (props.$top ? '0px' : '7px')};
white-space: nowrap;
overflow: visible;
::-webkit-scrollbar {
height: 0px;
}
scroll-behavior: smooth;
`;
export const FlatBackground = styled.div<{ $expanded: boolean; $color: string }>`
@@ -238,29 +250,29 @@ export const FlatBackground = styled.div<{ $expanded: boolean; $color: string }>
height: 200px;
position: absolute;
width: ${(props) => (props.$expanded ? `calc(100% - 165px)` : 'calc(100% - 56px)')};
z-index: 1;
user-select: none;
pointer-events: none;
`;
export const BlurredBackgroundWrapper = styled.div<{ $expanded: boolean }>`
export const BlurredBackgroundWrapper = styled.div<{ expanded: boolean; image: string }>`
clip: rect(0, auto, auto, 0);
-webkit-clip-path: inset(0 0);
clip-path: inset(0 0);
position: absolute;
left: ${(props) => (props.$expanded ? '165px' : '56px')};
width: ${(props) => (props.$expanded ? `calc(100% - 165px)` : 'calc(100% - 56px)')};
left: ${(props) => (props.expanded ? '165px' : '56px')};
width: ${(props) => (props.expanded ? `calc(100% - 165px)` : 'calc(100% - 56px)')};
top: 32px;
z-index: 1;
display: block;
background: #0b0908;
background: ${(props) => (props.image ? '#0b0908' : '#00395A')};
filter: ${(props) => (props.image ? 'none' : 'brightness(0.3)')};
`;
export const BlurredBackground = styled.img<{ $expanded: boolean; $image: string }>`
background-image: ${(props) => `url(${props.$image})`};
export const BlurredBackground = styled.img<{ expanded: boolean; image: string }>`
background-image: ${(props) => (props.image ? `url(${props.image})` : 'none')};
background-position: center 30%;
background-size: cover;
filter: blur(10px) brightness(0.4);
filter: blur(10px) brightness(0.3);
outline: none !important;
border: none !important;

View File

@@ -1,6 +1,7 @@
/* eslint-disable import/no-cycle */
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import _ from 'lodash';
import FastAverageColor from 'fast-average-color';
import { shell } from 'electron';
import settings from 'electron-settings';
import { ButtonToolbar, Whisper, TagGroup } from 'rsuite';
@@ -35,9 +36,17 @@ import {
setPlayQueue,
} from '../../redux/playQueueSlice';
import { notifyToast } from '../shared/toast';
import { filterPlayQueue, getPlayedSongsNotification, isCached } from '../../shared/utils';
import {
filterPlayQueue,
formatDuration,
getPlayedSongsNotification,
isCached,
} from '../../shared/utils';
import { StyledButton, StyledPopover, StyledTag } from '../shared/styled';
import { setStatus } from '../../redux/playerSlice';
import { GradientBackground, PageHeaderSubtitleDataLine } from '../layout/styled';
const fac = new FastAverageColor();
interface ArtistParams {
id: string;
@@ -50,6 +59,10 @@ const ArtistView = ({ ...rest }: any) => {
const misc = useAppSelector((state) => state.misc);
const config = useAppSelector((state) => state.config);
const [viewType, setViewType] = useState(settings.getSync('albumViewType') || 'list');
const [imageAverageColor, setImageAverageColor] = useState({ color: '', loaded: false });
const [artistDurationTotal, setArtistDurationTotal] = useState('');
const [artistSongTotal, setArtistSongTotal] = useState(0);
const { id } = useParams<ArtistParams>();
const artistId = rest.id ? rest.id : id;
const { isLoading, isError, data, error }: any = useQuery(['artist', artistId], () =>
@@ -131,18 +144,6 @@ const ArtistView = ({ ...rest }: any) => {
notifyToast('info', getPlayedSongsNotification({ ...songs.count, type: 'add' }));
};
if (isLoading || isLoadingAI) {
return <PageLoader />;
}
if (isError || isErrorAI) {
return (
<span>
Error: {error?.message} {errorAI?.message}
</span>
);
}
const handleRowFavorite = async (rowData: any) => {
if (!rowData.starred) {
await star(rowData.id, 'album');
@@ -167,168 +168,248 @@ const ArtistView = ({ ...rest }: any) => {
}
};
useEffect(() => {
if (!isLoading) {
const img = isCached(`${misc.imageCachePath}artist_${data?.id}.jpg`)
? `${misc.imageCachePath}artist_${data?.id}.jpg`
: data?.image.includes('placeholder')
? artistInfo?.largeImageUrl &&
!artistInfo?.largeImageUrl?.match('2a96cbd8b46e442fc41c2b86b821562f')
? artistInfo?.largeImageUrl
: data?.image
: data?.image;
const setAvgColor = (imgUrl: string) => {
if (
data?.image.match('placeholder') ||
(data?.image.match('placeholder') &&
artistInfo?.largeImageUrl?.match('2a96cbd8b46e442fc41c2b86b821562f'))
) {
setImageAverageColor({ color: 'rgba(0, 57, 90, .4)', loaded: true });
} else {
fac
.getColorAsync(imgUrl, {
ignoredColor: [
[255, 255, 255, 255], // White
[0, 0, 0, 255], // Black
],
mode: 'precision',
algorithm: 'dominant',
})
.then((color) => {
return setImageAverageColor({
color: color.rgba.replace(',1)', ',0.4)'),
loaded: true,
});
})
.catch(() => setAvgColor(imgUrl));
}
};
setAvgColor(img);
}
}, [artistInfo?.largeImageUrl, data?.id, data?.image, isLoading, misc.imageCachePath]);
useEffect(() => {
const allAlbumDurations = _.sum(_.map(data?.album, 'duration'));
const allSongCount = _.sum(_.map(data?.album, 'songCount'));
setArtistDurationTotal(formatDuration(allAlbumDurations) || 'N/a');
setArtistSongTotal(allSongCount);
}, [data?.album]);
if (isLoading || isLoadingAI || imageAverageColor.loaded === false) {
return <PageLoader />;
}
if (isError || isErrorAI) {
return (
<span>
Error: {error?.message} {errorAI?.message}
</span>
);
}
return (
<GenericPage
hideDivider
header={
<GenericPageHeader
image={
isCached(`${misc.imageCachePath}artist_${data.id}.jpg`)
? `${misc.imageCachePath}artist_${data.id}.jpg`
: data.image.includes('placeholder')
? artistInfo?.largeImageUrl &&
!artistInfo?.largeImageUrl?.match('2a96cbd8b46e442fc41c2b86b821562f')
? artistInfo.largeImageUrl
<>
<GradientBackground $expanded={misc.expandSidebar} $color={imageAverageColor.color} />
<GenericPage
contentZIndex={1}
hideDivider
header={
<GenericPageHeader
image={
isCached(`${misc.imageCachePath}artist_${data.id}.jpg`)
? `${misc.imageCachePath}artist_${data.id}.jpg`
: data.image.includes('placeholder')
? artistInfo?.largeImageUrl &&
!artistInfo?.largeImageUrl?.match('2a96cbd8b46e442fc41c2b86b821562f')
? artistInfo.largeImageUrl
: data.image
: data.image
: data.image
}
cacheImages={{
enabled: settings.getSync('cacheImages'),
cacheType: 'artist',
id: data.id,
}}
imageHeight={145}
title={data.name}
showTitleTooltip
subtitle={
<>
<CustomTooltip
text={artistInfo?.biography
?.replace(/<[^>]*>/, '')
.replace('Read more on Last.fm</a>', '')}
placement="bottomStart"
>
<span>
{artistInfo?.biography
?.replace(/<[^>]*>/, '')
.replace('Read more on Last.fm</a>', '')
?.trim()
? `${artistInfo?.biography
?.replace(/<[^>]*>/, '')
.replace('Read more on Last.fm</a>', '')}`
: 'No artist biography found'}
</span>
</CustomTooltip>
<div style={{ marginTop: '10px' }}>
<ButtonToolbar>
<PlayButton appearance="primary" size="md" onClick={handlePlay} />
<PlayAppendNextButton
appearance="primary"
size="md"
onClick={() => handlePlayAppend('next')}
/>
<PlayAppendButton
appearance="primary"
size="md"
onClick={() => handlePlayAppend('later')}
/>
<FavoriteButton size="md" isFavorite={data.starred} onClick={handleFavorite} />
<Whisper
placement="auto"
trigger="hover"
enterable
speaker={
<StyledPopover style={{ width: '400px' }}>
<div>
<h6>Related artists</h6>
<TagGroup>
{artistInfo.similarArtist?.map((artist: any) => (
<StyledTag
key={artist.id}
onClick={() => {
if (!rest.isModal) {
history.push(`/library/artist/${artist.id}`);
} else {
dispatch(
addModalPage({
pageType: 'artist',
id: artist.id,
})
);
}
}}
>
{artist.name}
</StyledTag>
))}
</TagGroup>
</div>
<br />
<StyledButton
appearance="primary"
disabled={!artistInfo?.lastFmUrl}
onClick={() => shell.openExternal(artistInfo?.lastFmUrl)}
>
View on Last.FM
</StyledButton>
</StyledPopover>
}
>
<StyledButton size="md">Info</StyledButton>
</Whisper>
</ButtonToolbar>
</div>
</>
}
searchQuery={searchQuery}
handleSearch={(e: any) => setSearchQuery(e)}
clearSearchQuery={() => setSearchQuery('')}
showSearchBar
showViewTypeButtons
viewTypeSetting="album"
handleListClick={() => setViewType('list')}
handleGridClick={() => setViewType('grid')}
/>
}
>
<>
{viewType === 'list' && (
<ListViewType
data={searchQuery !== '' ? filteredData : data.album}
tableColumns={config.lookAndFeel.listView.album.columns}
handleRowClick={handleRowClick}
handleRowDoubleClick={handleRowDoubleClick}
virtualized
rowHeight={config.lookAndFeel.listView.album.rowHeight}
fontSize={config.lookAndFeel.listView.album.fontSize}
}
cacheImages={{
enabled: settings.getSync('cacheImages'),
cacheType: 'album',
cacheIdProperty: 'albumId',
cacheType: 'artist',
id: data.id,
}}
listType="album"
isModal={rest.isModal}
disabledContextMenuOptions={[
'removeSelected',
'moveSelectedTo',
'deletePlaylist',
'viewInFolder',
]}
handleFavorite={handleRowFavorite}
/>
)}
imageHeight={185}
title={data.name}
showTitleTooltip
subtitle={
<>
<PageHeaderSubtitleDataLine $top>
<strong>ARTIST</strong> {data.albumCount} albums {artistSongTotal} songs {' '}
{artistDurationTotal}
</PageHeaderSubtitleDataLine>
<PageHeaderSubtitleDataLine
style={{
minHeight: '2.5rem',
maxHeight: '2.5rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'pre-wrap',
}}
>
<CustomTooltip
text={artistInfo?.biography
?.replace(/<[^>]*>/, '')
.replace('Read more on Last.fm</a>', '')}
placement="bottomStart"
>
<span>
{artistInfo?.biography
?.replace(/<[^>]*>/, '')
.replace('Read more on Last.fm</a>', '')
?.trim()
? `${artistInfo?.biography
?.replace(/<[^>]*>/, '')
.replace('Read more on Last.fm</a>', '')}`
: 'No artist biography found'}
</span>
</CustomTooltip>
</PageHeaderSubtitleDataLine>
{viewType === 'grid' && (
<GridViewType
data={searchQuery === '' ? data.album : filteredData}
cardTitle={{
prefix: '/library/album',
property: 'name',
urlProperty: 'albumId',
}}
cardSubtitle={{
property: 'songCount',
unit: ' tracks',
}}
playClick={{ type: 'album', idProperty: 'id' }}
size={config.lookAndFeel.gridView.cardSize}
cacheType="album"
isModal={rest.isModal}
handleFavorite={handleRowFavorite}
<div style={{ marginTop: '10px' }}>
<ButtonToolbar>
<PlayButton appearance="primary" size="lg" onClick={handlePlay} />
<PlayAppendNextButton
appearance="primary"
size="lg"
onClick={() => handlePlayAppend('next')}
/>
<PlayAppendButton
appearance="primary"
size="lg"
onClick={() => handlePlayAppend('later')}
/>
<FavoriteButton size="lg" isFavorite={data.starred} onClick={handleFavorite} />
<Whisper
placement="auto"
trigger="hover"
enterable
speaker={
<StyledPopover style={{ width: '400px' }}>
<div>
<h6>Related artists</h6>
<TagGroup>
{artistInfo.similarArtist?.map((artist: any) => (
<StyledTag
key={artist.id}
onClick={() => {
if (!rest.isModal) {
history.push(`/library/artist/${artist.id}`);
} else {
dispatch(
addModalPage({
pageType: 'artist',
id: artist.id,
})
);
}
}}
>
{artist.name}
</StyledTag>
))}
</TagGroup>
</div>
<br />
<StyledButton
appearance="primary"
disabled={!artistInfo?.lastFmUrl}
onClick={() => shell.openExternal(artistInfo?.lastFmUrl)}
>
View on Last.FM
</StyledButton>
</StyledPopover>
}
>
<StyledButton size="lg">Info</StyledButton>
</Whisper>
</ButtonToolbar>
</div>
</>
}
searchQuery={searchQuery}
handleSearch={(e: any) => setSearchQuery(e)}
clearSearchQuery={() => setSearchQuery('')}
showSearchBar
showViewTypeButtons
viewTypeSetting="album"
handleListClick={() => setViewType('list')}
handleGridClick={() => setViewType('grid')}
/>
)}
</>
</GenericPage>
}
>
<>
{viewType === 'list' && (
<ListViewType
data={searchQuery !== '' ? filteredData : data.album}
tableColumns={config.lookAndFeel.listView.album.columns}
handleRowClick={handleRowClick}
handleRowDoubleClick={handleRowDoubleClick}
virtualized
rowHeight={config.lookAndFeel.listView.album.rowHeight}
fontSize={config.lookAndFeel.listView.album.fontSize}
cacheImages={{
enabled: settings.getSync('cacheImages'),
cacheType: 'album',
cacheIdProperty: 'albumId',
}}
listType="album"
isModal={rest.isModal}
disabledContextMenuOptions={[
'removeSelected',
'moveSelectedTo',
'deletePlaylist',
'viewInFolder',
]}
handleFavorite={handleRowFavorite}
/>
)}
{viewType === 'grid' && (
<GridViewType
data={searchQuery === '' ? data.album : filteredData}
cardTitle={{
prefix: '/library/album',
property: 'name',
urlProperty: 'albumId',
}}
cardSubtitle={{
property: 'songCount',
unit: ' tracks',
}}
playClick={{ type: 'album', idProperty: 'id' }}
size={config.lookAndFeel.gridView.cardSize}
cacheType="album"
isModal={rest.isModal}
handleFavorite={handleRowFavorite}
/>
)}
</>
</GenericPage>
</>
);
};

View File

@@ -1375,14 +1375,7 @@
"@types/yargs" "^15.0.0"
chalk "^4.0.0"
"@malept/cross-spawn-promise@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.0.tgz#258fde4098f5004a56db67c35f33033af64810f6"
integrity sha512-GeIK5rfU1Yd7BZJQPTGZMMmcZy5nhRToPXZcjaDwQDRSewdhp648GT2E4dh+L7+Io7AOW6WQ+GR44QSzja4qxg==
dependencies:
cross-spawn "^7.0.1"
"@malept/cross-spawn-promise@^1.1.1":
"@malept/cross-spawn-promise@^1.1.0", "@malept/cross-spawn-promise@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz#504af200af6b98e198bce768bc1730c6936ae01d"
integrity sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==
@@ -1766,6 +1759,11 @@
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==
"@types/offscreencanvas@^2019.6.4":
version "2019.6.4"
resolved "https://registry.yarnpkg.com/@types/offscreencanvas/-/offscreencanvas-2019.6.4.tgz#64f6d120b53925028299c744fcdd32d2cd525963"
integrity sha512-u8SAgdZ8ROtkTF+mfZGOscl0or6BSj9A4g37e6nvxDc+YB/oDut0wHkK2PBBiC2bNR8TS0CPV+1gAk4fNisr1Q==
"@types/parse-json@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
@@ -5722,6 +5720,13 @@ extsprintf@^1.2.0:
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
fast-average-color@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/fast-average-color/-/fast-average-color-7.0.1.tgz#2304cc62578f4427c1bc48844faa1c5178835b67"
integrity sha512-M9Lg7mO/sSGezi7cB/i+I9ym33IBcdeREHk222//3sbtj2+Aq9Cv3DIVY1asn3fu4rX2ENQ2iv2mCSaVpC68SQ==
dependencies:
"@types/offscreencanvas" "^2019.6.4"
fast-deep-equal@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz#c053477817c86b51daa853c81e059b733d023614"
@@ -11396,7 +11401,7 @@ source-map-resolve@^0.6.0:
atob "^2.1.2"
decode-uri-component "^0.2.0"
source-map-support@^0.5.11, source-map-support@^0.5.16, source-map-support@^0.5.19, source-map-support@^0.5.6, source-map-support@~0.5.19:
source-map-support@^0.5.16, source-map-support@^0.5.19, source-map-support@^0.5.6, source-map-support@~0.5.19:
version "0.5.20"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.20.tgz#12166089f8f5e5e8c56926b377633392dd2cb6c9"
integrity sha512-n1lZZ8Ve4ksRqizaBQgxXDgKwttHDhyfQjA6YZZn8+AroHbsIz+JjwxQDxbp+7y5OYCI8t1Yk7etjD9CRd2hIw==