Migrate to Mantine v8 and Design Changes (#961)

* mantine v8 migration

* various design changes and improvements
This commit is contained in:
Jeff 2025-06-24 00:04:36 -07:00 committed by GitHub
parent bea55d48a8
commit c1330d92b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
473 changed files with 12469 additions and 11607 deletions

View file

@ -0,0 +1,47 @@
.buttons-container {
display: flex;
gap: 0.5rem;
align-items: center;
}
.slider-container {
display: flex;
width: 95%;
height: 20px;
}
.slider-value-wrapper {
display: flex;
flex: 1;
align-self: center;
justify-content: center;
max-width: 50px;
@media (width < 768px) {
display: none;
}
}
.slider-wrapper {
display: flex;
flex: 6;
align-items: center;
height: 100%;
}
.controls-container {
display: flex;
align-items: center;
justify-content: center;
height: 35px;
@media (width < 768px) {
.buttons-container {
gap: 0;
}
.slider-value-wrapper {
display: none;
}
}
}

View file

@ -4,22 +4,9 @@ import formatDuration from 'format-duration';
import isElectron from 'is-electron';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { BsDice3 } from 'react-icons/bs';
import { IoIosPause } from 'react-icons/io';
import {
RiPlayFill,
RiRepeat2Line,
RiRepeatOneLine,
RiRewindFill,
RiShuffleFill,
RiSkipBackFill,
RiSkipForwardFill,
RiSpeedFill,
RiStopFill,
} from 'react-icons/ri';
import styled from 'styled-components';
import { Text } from '/@/renderer/components';
import styles from './center-controls.module.css';
import { PlayerButton } from '/@/renderer/features/player/components/player-button';
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
import { openShuffleAllModal } from '/@/renderer/features/player/components/shuffle-all-modal';
@ -39,60 +26,14 @@ import {
usePlaybackType,
useSettingsStore,
} from '/@/renderer/store/settings.store';
import { Icon } from '/@/shared/components/icon/icon';
import { Text } from '/@/shared/components/text/text';
import { PlaybackType, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
interface CenterControlsProps {
playersRef: any;
}
const ButtonsContainer = styled.div`
display: flex;
gap: 0.5rem;
align-items: center;
`;
const SliderContainer = styled.div`
display: flex;
width: 95%;
height: 20px;
`;
const SliderValueWrapper = styled.div<{ $position: 'left' | 'right' }>`
display: flex;
flex: 1;
align-self: center;
justify-content: center;
max-width: 50px;
@media (width <= 768px) {
display: none;
}
`;
const SliderWrapper = styled.div`
display: flex;
flex: 6;
align-items: center;
height: 100%;
`;
const ControlsContainer = styled.div`
display: flex;
align-items: center;
justify-content: center;
height: 35px;
@media (width <= 768px) {
${ButtonsContainer} {
gap: 0;
}
${SliderValueWrapper} {
display: none;
}
}
`;
export const CenterControls = ({ playersRef }: CenterControlsProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
@ -171,10 +112,16 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
return (
<>
<ControlsContainer>
<ButtonsContainer>
<div className={styles.controlsContainer}>
<div className={styles.buttonsContainer}>
<PlayerButton
icon={<RiStopFill size={buttonSize} />}
icon={
<Icon
fill="default"
icon="mediaStop"
size={buttonSize}
/>
}
onClick={handleStop}
tooltip={{
label: t('player.stop', { postProcess: 'sentenceCase' }),
@ -182,8 +129,14 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
variant="tertiary"
/>
<PlayerButton
$isActive={shuffle !== PlayerShuffle.NONE}
icon={<RiShuffleFill size={buttonSize} />}
icon={
<Icon
fill={shuffle === PlayerShuffle.NONE ? 'default' : 'primary'}
icon="mediaShuffle"
size={buttonSize}
/>
}
isActive={shuffle !== PlayerShuffle.NONE}
onClick={handleToggleShuffle}
tooltip={{
label:
@ -197,7 +150,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
variant="tertiary"
/>
<PlayerButton
icon={<RiSkipBackFill size={buttonSize} />}
icon={
<Icon
fill="default"
icon="mediaPrevious"
size={buttonSize}
/>
}
onClick={handlePrevTrack}
tooltip={{
label: t('player.previous', { postProcess: 'sentenceCase' }),
@ -206,7 +165,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/>
{skip?.enabled && (
<PlayerButton
icon={<RiRewindFill size={buttonSize} />}
icon={
<Icon
fill="default"
icon="mediaStepBackward"
size={buttonSize}
/>
}
onClick={() => handleSkipBackward(skip?.skipBackwardSeconds)}
tooltip={{
label: t('player.skip', {
@ -221,9 +186,15 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
disabled={currentSong?.id === undefined}
icon={
status === PlayerStatus.PAUSED ? (
<RiPlayFill size={buttonSize} />
<Icon
icon="mediaPlay"
size={buttonSize}
/>
) : (
<IoIosPause size={buttonSize} />
<Icon
icon="mediaPause"
size={buttonSize}
/>
)
}
onClick={handlePlayPause}
@ -237,7 +208,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/>
{skip?.enabled && (
<PlayerButton
icon={<RiSpeedFill size={buttonSize} />}
icon={
<Icon
fill="default"
icon="mediaStepForward"
size={buttonSize}
/>
}
onClick={() => handleSkipForward(skip?.skipForwardSeconds)}
tooltip={{
label: t('player.skip', {
@ -249,7 +226,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/>
)}
<PlayerButton
icon={<RiSkipForwardFill size={buttonSize} />}
icon={
<Icon
fill="default"
icon="mediaNext"
size={buttonSize}
/>
}
onClick={handleNextTrack}
tooltip={{
label: t('player.next', { postProcess: 'sentenceCase' }),
@ -257,14 +240,22 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
variant="secondary"
/>
<PlayerButton
$isActive={repeat !== PlayerRepeat.NONE}
icon={
repeat === PlayerRepeat.ONE ? (
<RiRepeatOneLine size={buttonSize} />
<Icon
fill="primary"
icon="mediaRepeatOne"
size={buttonSize}
/>
) : (
<RiRepeat2Line size={buttonSize} />
<Icon
fill={repeat === PlayerRepeat.NONE ? 'default' : 'primary'}
icon="mediaRepeat"
size={buttonSize}
/>
)
}
isActive={repeat !== PlayerRepeat.NONE}
onClick={handleToggleRepeat}
tooltip={{
label: `${
@ -288,7 +279,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
/>
<PlayerButton
icon={<BsDice3 size={buttonSize} />}
icon={
<Icon
fill="default"
icon="mediaRandom"
size={buttonSize}
/>
}
onClick={() =>
openShuffleAllModal({
handlePlayQueueAdd,
@ -300,20 +297,20 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
}}
variant="tertiary"
/>
</ButtonsContainer>
</ControlsContainer>
<SliderContainer>
<SliderValueWrapper $position="left">
</div>
</div>
<div className={styles.sliderContainer}>
<div className={styles.sliderValueWrapper}>
<Text
$noSelect
$secondary
fw={600}
isMuted
isNoSelect
size="xs"
weight={600}
>
{formattedTime}
</Text>
</SliderValueWrapper>
<SliderWrapper>
</div>
<div className={styles.sliderWrapper}>
<PlayerbarSlider
label={(value) => formatDuration(value * 1000)}
max={songDuration}
@ -335,18 +332,18 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
value={!isSeeking ? currentTime : seekValue}
w="100%"
/>
</SliderWrapper>
<SliderValueWrapper $position="right">
</div>
<div className={styles.sliderValueWrapper}>
<Text
$noSelect
$secondary
fw={600}
isMuted
isNoSelect
size="xs"
weight={600}
>
{duration}
</Text>
</SliderValueWrapper>
</SliderContainer>
</div>
</div>
</>
);
};

View file

@ -0,0 +1,47 @@
.image {
position: absolute;
max-width: 100%;
height: 100%;
object-fit: var(--theme-image-fit);
object-position: 50% 100%;
border-radius: 5px;
filter: drop-shadow(0 0 5px rgb(0 0 0 / 40%)) drop-shadow(0 0 5px rgb(0 0 0 / 40%));
}
.image-container {
position: relative;
display: flex;
align-items: flex-end;
justify-content: center;
max-width: 100%;
height: 65%;
aspect-ratio: 1/1;
margin-bottom: 1rem;
}
.metadata-container {
display: flex;
justify-content: center;
padding: 1rem;
text-align: center;
border-radius: 5px;
h1 {
font-size: 3.5vh;
}
}
.player-container {
@media screen and (height < 640px) {
.full-screen-player-image-metadata {
display: none;
height: 100%;
margin-bottom: 0;
}
.image-container {
height: 100%;
margin-bottom: 0;
}
}
}

View file

@ -1,68 +1,27 @@
import { Center, Flex, Group, Stack } from '@mantine/core';
import { useSetState } from '@mantine/hooks';
import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'framer-motion';
import clsx from 'clsx';
import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'motion/react';
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { RiAlbumFill } from 'react-icons/ri';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { Badge, Text, TextTitle } from '/@/renderer/components';
import styles from './full-screen-player-image.module.css';
import { useFastAverageColor } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { useFullScreenPlayerStore, usePlayerData, usePlayerStore } from '/@/renderer/store';
import { usePlayerData, usePlayerStore } from '/@/renderer/store';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import { Badge } from '/@/shared/components/badge/badge';
import { Center } from '/@/shared/components/center/center';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { Stack } from '/@/shared/components/stack/stack';
import { TextTitle } from '/@/shared/components/text-title/text-title';
import { Text } from '/@/shared/components/text/text';
import { PlayerData, QueueSong } from '/@/shared/types/domain-types';
const Image = styled(motion.img)<any>`
position: absolute;
max-width: 100%;
height: 100%;
object-fit: ${({ $useAspectRatio }) => ($useAspectRatio ? 'contain' : 'cover')};
object-position: 50% 100%;
filter: drop-shadow(0 0 5px rgb(0 0 0 / 40%)) drop-shadow(0 0 5px rgb(0 0 0 / 40%));
border-radius: 5px;
`;
const ImageContainer = styled(motion.div)`
position: relative;
display: flex;
align-items: flex-end;
justify-content: center;
max-width: 100%;
height: 65%;
aspect-ratio: 1/1;
margin-bottom: 1rem;
`;
interface TransparentMetadataContainer {
opacity?: number;
}
const MetadataContainer = styled(Stack)<TransparentMetadataContainer>`
padding: 1rem;
border-radius: 5px;
h1 {
font-size: 3.5vh;
}
`;
const PlayerContainer = styled(Flex)`
@media screen and (height <= 640px) {
.full-screen-player-image-metadata {
display: none;
height: 100%;
margin-bottom: 0;
}
${ImageContainer} {
height: 100%;
margin-bottom: 0;
}
}
`;
const imageVariants: Variants = {
closed: {
opacity: 0,
@ -93,22 +52,22 @@ const scaleImageUrl = (imageSize: number, url?: null | string) => {
.replace(/&height=\d+/, `&height=${imageSize}`);
};
const ImageWithPlaceholder = ({
useAspectRatio,
...props
}: HTMLMotionProps<'img'> & { placeholder?: string; useAspectRatio: boolean }) => {
const MotionImage = motion.create(Image);
const ImageWithPlaceholder = ({ ...props }: HTMLMotionProps<'img'> & { placeholder?: string }) => {
if (!props.src) {
return (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
style={{
background: 'var(--theme-colors-surface)',
borderRadius: 'var(--theme-card-default-radius)',
height: '100%',
width: '100%',
}}
>
<RiAlbumFill
color="var(--placeholder-fg)"
<Icon
color="muted"
icon="itemAlbum"
size="25%"
/>
</Center>
@ -116,8 +75,8 @@ const ImageWithPlaceholder = ({
}
return (
<Image
$useAspectRatio={useAspectRatio}
<MotionImage
className={styles.image}
{...props}
/>
);
@ -130,7 +89,6 @@ export const FullScreenPlayerImage = () => {
const albumArtRes = useSettingsStore((store) => store.general.albumArtRes);
const { queue } = usePlayerData();
const { useImageAspectRatio } = useFullScreenPlayerStore();
const currentSong = queue.current;
const { background } = useFastAverageColor({
algorithm: 'dominant',
@ -195,14 +153,17 @@ export const FullScreenPlayerImage = () => {
}, [imageState, mainImageDimensions.idealSize, queue, setImageState]);
return (
<PlayerContainer
<Flex
align="center"
className="full-screen-player-image-container"
className={clsx(styles.playerContainer, 'full-screen-player-image-container')}
direction="column"
justify="flex-start"
p="1rem"
>
<ImageContainer ref={mainImageRef}>
<div
className={styles.imageContainer}
ref={mainImageRef}
>
<AnimatePresence
initial={false}
mode="sync"
@ -216,9 +177,8 @@ export const FullScreenPlayerImage = () => {
exit="closed"
initial="closed"
key={imageKey}
placeholder="var(--placeholder-bg)"
placeholder="var(--theme-colors-foreground-muted)"
src={imageState.topImage || ''}
useAspectRatio={useImageAspectRatio}
variants={imageVariants}
/>
)}
@ -232,62 +192,55 @@ export const FullScreenPlayerImage = () => {
exit="closed"
initial="closed"
key={imageKey}
placeholder="var(--placeholder-bg)"
placeholder="var(--theme-colors-foreground-muted)"
src={imageState.bottomImage || ''}
useAspectRatio={useImageAspectRatio}
variants={imageVariants}
/>
)}
</AnimatePresence>
</ImageContainer>
<MetadataContainer
className="full-screen-player-image-metadata"
</div>
<Stack
className={styles.metadataContainer}
gap="xs"
maw="100%"
spacing="xs"
>
<TextTitle
align="center"
fw={900}
order={1}
overflow="hidden"
style={{
textShadow: 'var(--fullscreen-player-text-shadow)',
}}
w="100%"
weight={900}
>
{currentSong?.name}
</TextTitle>
<TextTitle
$link
align="center"
component={Link}
fw={600}
isLink
order={3}
overflow="hidden"
style={{
textShadow: 'var(--fullscreen-player-text-shadow)',
textShadow: 'var(--theme-fullscreen-player-text-shadow)',
}}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: currentSong?.albumId || '',
})}
w="100%"
weight={600}
>
{currentSong?.album}{' '}
</TextTitle>
<TextTitle
align="center"
key="fs-artists"
order={3}
style={{
textShadow: 'var(--fullscreen-player-text-shadow)',
textShadow: 'var(--theme-fullscreen-player-text-shadow)',
}}
>
{currentSong?.artists?.map((artist, index) => (
<Fragment key={`fs-artist-${artist.id}`}>
{index > 0 && (
<Text
$secondary
sx={{
isMuted
style={{
display: 'inline-block',
padding: '0 0.5rem',
}}
@ -296,16 +249,16 @@ export const FullScreenPlayerImage = () => {
</Text>
)}
<Text
$link
$secondary
component={Link}
fw={600}
isLink
isMuted
style={{
textShadow: 'var(--fullscreen-player-text-shadow)',
textShadow: 'var(--theme-fullscreen-player-text-shadow)',
}}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
weight={600}
>
{artist.name}
</Text>
@ -313,8 +266,8 @@ export const FullScreenPlayerImage = () => {
))}
</TextTitle>
<Group
justify="center"
mt="sm"
position="center"
>
{currentSong?.container && (
<Badge size="lg">
@ -325,7 +278,7 @@ export const FullScreenPlayerImage = () => {
<Badge size="lg">{currentSong?.releaseYear}</Badge>
)}
</Group>
</MetadataContainer>
</PlayerContainer>
</Stack>
</Flex>
);
};

View file

@ -0,0 +1,62 @@
.queue-container {
position: relative;
display: flex;
height: 100%;
:global(.ag-header) {
display: none;
}
:global(.ag-theme-alpine-dark) {
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
--ag-background-color: rgb(0 0 0 / 0%) !important;
--ag-odd-row-background-color: rgb(0 0 0 / 0%) !important;
}
:global(.ag-row) {
&::before {
background: rgb(0 0 0 / 10%) !important;
border: none !important;
}
}
:global(.ag-row-hover) {
background: rgb(0 0 0 / 10%) !important;
}
}
.active-tab-indicator {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--theme-colors-foreground);
}
.header-item-wrapper {
position: relative;
z-index: 2;
display: flex;
gap: 0;
}
.grid-container {
position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
grid-template-columns: 1fr;
padding: 1rem;
&::before {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
content: '';
background: var(--theme-colors-background);
border-radius: 5px;
opacity: var(--opacity, 1);
}
}

View file

@ -1,12 +1,10 @@
import { Group } from '@mantine/core';
import { motion } from 'framer-motion';
import { lazy, Suspense, useMemo } from 'react';
import clsx from 'clsx';
import { motion } from 'motion/react';
import { CSSProperties, lazy, Suspense, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { HiOutlineQueueList } from 'react-icons/hi2';
import { RiFileMusicLine, RiFileTextLine } from 'react-icons/ri';
import styled from 'styled-components';
import { Button } from '/@/renderer/components';
import styles from './full-screen-player-queue.module.css';
import { Lyrics } from '/@/renderer/features/lyrics/lyrics';
import { PlayQueue } from '/@/renderer/features/now-playing';
import { FullScreenSimilarSongs } from '/@/renderer/features/player/components/full-screen-similar-songs';
@ -15,6 +13,8 @@ import {
useFullScreenPlayerStore,
useFullScreenPlayerStoreActions,
} from '/@/renderer/store/full-screen-player.store';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { PlaybackType } from '/@/shared/types/types';
const Visualizer = lazy(() =>
@ -23,50 +23,6 @@ const Visualizer = lazy(() =>
})),
);
const QueueContainer = styled.div`
position: relative;
display: flex;
height: 100%;
.ag-theme-alpine-dark {
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
--ag-background-color: rgb(0 0 0 / 0%) !important;
--ag-odd-row-background-color: rgb(0 0 0 / 0%) !important;
}
.ag-header {
display: none !important;
}
`;
const ActiveTabIndicator = styled(motion.div)`
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--main-fg);
`;
const HeaderItemWrapper = styled.div`
position: relative;
z-index: 2;
`;
interface TransparentGridContainerProps {
opacity: number;
}
const GridContainer = styled.div<TransparentGridContainerProps>`
display: grid;
grid-template-rows: auto minmax(0, 1fr);
grid-template-columns: 1fr;
padding: 1rem;
/* stylelint-disable-next-line color-function-notation */
background: rgb(var(--main-bg-transparent), ${({ opacity }) => opacity}%);
border-radius: 5px;
`;
export const FullScreenPlayerQueue = () => {
const { t } = useTranslation();
const { activeTab, opacity } = useFullScreenPlayerStore();
@ -77,19 +33,16 @@ export const FullScreenPlayerQueue = () => {
const items = [
{
active: activeTab === 'queue',
icon: <RiFileMusicLine size="1.5rem" />,
label: t('page.fullscreenPlayer.upNext'),
onClick: () => setStore({ activeTab: 'queue' }),
},
{
active: activeTab === 'related',
icon: <HiOutlineQueueList size="1.5rem" />,
label: t('page.fullscreenPlayer.related'),
onClick: () => setStore({ activeTab: 'related' }),
},
{
active: activeTab === 'lyrics',
icon: <RiFileTextLine size="1.5rem" />,
label: t('page.fullscreenPlayer.lyrics'),
onClick: () => setStore({ activeTab: 'lyrics' }),
},
@ -98,7 +51,6 @@ export const FullScreenPlayerQueue = () => {
if (type === PlaybackType.WEB && webAudio) {
items.push({
active: activeTab === 'visualizer',
icon: <RiFileTextLine size="1.5rem" />,
label: t('page.fullscreenPlayer.visualizer', { postProcess: 'titleCase' }),
onClick: () => setStore({ activeTab: 'visualizer' }),
});
@ -108,48 +60,54 @@ export const FullScreenPlayerQueue = () => {
}, [activeTab, setStore, t, type, webAudio]);
return (
<GridContainer
className="full-screen-player-queue-container"
opacity={opacity}
<div
className={clsx(styles.gridContainer, 'full-screen-player-queue-container')}
style={
{
'--opacity': opacity / 100,
} as CSSProperties
}
>
<Group
align="center"
className="full-screen-player-queue-header"
gap={0}
grow
position="center"
justify="center"
>
{headerItems.map((item) => (
<HeaderItemWrapper key={`tab-${item.label}`}>
<div
className={styles.headerItemWrapper}
key={`tab-${item.label}`}
>
<Button
fullWidth
flex={1}
fw="600"
onClick={item.onClick}
pos="relative"
size="lg"
sx={{
alignItems: 'center',
color: item.active
? 'var(--main-fg) !important'
: 'var(--main-fg-secondary) !important',
letterSpacing: '1px',
}}
uppercase
variant="subtle"
>
{item.label}
</Button>
{item.active ? <ActiveTabIndicator layoutId="underline" /> : null}
</HeaderItemWrapper>
{item.active ? (
<motion.div
className={styles.activeTabIndicator}
layoutId="underline"
/>
) : null}
</div>
))}
</Group>
{activeTab === 'queue' ? (
<QueueContainer>
<div className={styles.queueContainer}>
<PlayQueue type="fullScreen" />
</QueueContainer>
</div>
) : activeTab === 'related' ? (
<QueueContainer>
<div className={styles.queueContainer}>
<FullScreenSimilarSongs />
</QueueContainer>
</div>
) : activeTab === 'lyrics' ? (
<Lyrics />
) : activeTab === 'visualizer' && type === PlaybackType.WEB && webAudio ? (
@ -157,6 +115,6 @@ export const FullScreenPlayerQueue = () => {
<Visualizer />
</Suspense>
) : null}
</GridContainer>
</div>
);
};

View file

@ -0,0 +1,40 @@
.container {
position: absolute;
top: 0;
left: 0;
z-index: 200;
display: flex;
justify-content: center;
padding: 2rem;
@media screen and (orientation: portrait) {
padding: 2rem 2rem 1rem;
}
}
.responsive-container {
display: grid;
grid-template-rows: minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 2rem;
width: 100%;
max-width: 2560px;
margin-top: 5rem;
@media screen and (orientation: portrait) {
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
margin-top: 0;
}
}
.background-image-overlay {
position: absolute;
top: 0;
left: 0;
z-index: -1;
width: 100%;
height: 100%;
background: var(--theme-overlay-header);
backdrop-filter: blur(var(--image-blur));
}

View file

@ -1,21 +1,11 @@
import { Divider, Group } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import { motion, Variants } from 'framer-motion';
import { useLayoutEffect, useRef } from 'react';
import { motion, Variants } from 'motion/react';
import { CSSProperties, useLayoutEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { RiArrowDownSLine, RiSettings3Line } from 'react-icons/ri';
import { useLocation } from 'react-router';
import styled from 'styled-components';
import {
Button,
NumberInput,
Option,
Popover,
Select,
Slider,
Switch,
} from '/@/renderer/components';
import styles from './full-screen-player.module.css';
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
import { FullScreenPlayerImage } from '/@/renderer/features/player/components/full-screen-player-image';
import { FullScreenPlayerQueue } from '/@/renderer/features/player/components/full-screen-player-queue';
@ -29,54 +19,18 @@ import {
useSettingsStoreActions,
useWindowSettings,
} from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Option } from '/@/shared/components/option/option';
import { Popover } from '/@/shared/components/popover/popover';
import { Select } from '/@/shared/components/select/select';
import { Slider } from '/@/shared/components/slider/slider';
import { Switch } from '/@/shared/components/switch/switch';
import { Platform } from '/@/shared/types/types';
const Container = styled(motion.div)`
position: absolute;
top: 0;
left: 0;
z-index: 200;
display: flex;
justify-content: center;
padding: 2rem;
@media screen and (orientation: portrait) {
padding: 2rem 2rem 1rem;
}
`;
const ResponsiveContainer = styled.div`
display: grid;
grid-template-rows: minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 2rem 2rem;
width: 100%;
max-width: 2560px;
margin-top: 5rem;
@media screen and (orientation: portrait) {
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
margin-top: 0;
}
`;
interface BackgroundImageOverlayProps {
$blur: number;
}
const BackgroundImageOverlay = styled.div<BackgroundImageOverlayProps>`
position: absolute;
top: 0;
left: 0;
z-index: -1;
width: 100%;
height: 100%;
background: var(--bg-header-overlay);
backdrop-filter: blur(${({ $blur }) => $blur}rem);
`;
const mainBackground = 'var(--main-bg)';
const mainBackground = 'var(--theme-colors-background)';
const Controls = () => {
const { t } = useTranslation();
@ -109,34 +63,30 @@ const Controls = () => {
return (
<Group
gap="sm"
p="1rem"
pos="absolute"
spacing="sm"
sx={{
background: `rgb(var(--main-bg-transparent), ${opacity}%)`,
style={{
background: `rgb(var(--theme-colors-background-transparent), ${opacity}%)`,
left: 0,
top: 0,
}}
>
<Button
compact
<ActionIcon
icon="arrowDownS"
iconProps={{ size: 'lg' }}
onClick={handleToggleFullScreenPlayer}
size="sm"
tooltip={{ label: t('common.minimize', { postProcess: 'titleCase' }) }}
variant="subtle"
>
<RiArrowDownSLine size="2rem" />
</Button>
/>
<Popover position="bottom-start">
<Popover.Target>
<Button
compact
size="sm"
<ActionIcon
icon="settings"
iconProps={{ size: 'lg' }}
tooltip={{ label: t('common.configure', { postProcess: 'titleCase' }) }}
variant="subtle"
>
<RiSettings3Line size="1.5rem" />
</Button>
/>
</Popover.Target>
<Popover.Dropdown>
<Option>
@ -285,8 +235,8 @@ const Controls = () => {
</Option.Label>
<Option.Control>
<Group
noWrap
w="100%"
wrap="nowrap"
>
<Slider
defaultValue={lyricConfig.fontSize}
@ -325,8 +275,8 @@ const Controls = () => {
</Option.Label>
<Option.Control>
<Group
noWrap
w="100%"
wrap="nowrap"
>
<Slider
defaultValue={lyricConfig.gap}
@ -485,8 +435,9 @@ export const FullScreenPlayer = () => {
: mainBackground;
return (
<Container
<motion.div
animate="open"
className={styles.container}
custom={{ background, backgroundImage, dynamicBackground, windowBarStyle }}
exit="closed"
initial="closed"
@ -494,11 +445,20 @@ export const FullScreenPlayer = () => {
variants={containerVariants}
>
<Controls />
{dynamicBackground && <BackgroundImageOverlay $blur={dynamicImageBlur} />}
<ResponsiveContainer>
{dynamicBackground && (
<div
className={styles.backgroundImageOverlay}
style={
{
'--image-blur': `${dynamicImageBlur}`,
} as CSSProperties
}
/>
)}
<div className={styles.responsiveContainer}>
<FullScreenPlayerImage />
<FullScreenPlayerQueue />
</ResponsiveContainer>
</Container>
</div>
</motion.div>
);
};

View file

@ -0,0 +1,82 @@
.image-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: var(--theme-spacing-md) var(--theme-spacing-md) var(--theme-spacing-md) 0;
}
.metadata-stack {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-xs);
justify-content: center;
width: 100%;
overflow: hidden;
}
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.image {
position: relative;
width: 60px;
height: 60px;
cursor: pointer;
animation: fadein 0.2s ease-in-out;
button {
display: none;
}
&:hover button {
display: block;
}
}
.playerbar-image {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
object-fit: var(--theme-image-fit);
}
.line-item {
display: inline-block;
width: fit-content;
max-width: 20vw;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
white-space: nowrap;
}
.line-item.secondary {
color: var(--theme-colors-foreground-muted);
a {
color: var(--theme-colors-foreground-muted);
}
}
.left-controls-container {
display: flex;
width: 100%;
height: 100%;
padding-left: 1rem;
@media (width < 640px) {
.image-wrapper {
display: none;
}
}
}

View file

@ -1,14 +1,12 @@
import { Center, Group } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion';
import clsx from 'clsx';
import { AnimatePresence, LayoutGroup, motion } from 'motion/react';
import React, { MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { RiArrowUpSLine, RiDiscLine, RiMore2Fill } from 'react-icons/ri';
import { generatePath, Link } from 'react-router-dom';
import styled from 'styled-components';
import { Button, Text, Tooltip } from '/@/renderer/components';
import { Separator } from '/@/renderer/components/separator';
import styles from './left-controls.module.css';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
import { AppRoute } from '/@/renderer/router/routes';
@ -20,80 +18,14 @@ import {
useSetFullScreenPlayerStore,
useSidebarStore,
} from '/@/renderer/store';
import { fadeIn } from '/@/renderer/styles';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
import { Image } from '/@/shared/components/image/image';
import { Separator } from '/@/shared/components/separator/separator';
import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { LibraryItem } from '/@/shared/types/domain-types';
const ImageWrapper = styled.div`
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem 1rem 1rem 0;
`;
const MetadataStack = styled(motion.div)`
display: flex;
flex-direction: column;
gap: 0;
justify-content: center;
width: 100%;
overflow: hidden;
`;
const Image = styled(motion.div)`
position: relative;
width: 60px;
height: 60px;
cursor: pointer;
background-color: var(--placeholder-bg);
filter: drop-shadow(0 5px 6px rgb(0 0 0 / 50%));
${fadeIn};
animation: fadein 0.2s ease-in-out;
button {
display: none;
}
&:hover button {
display: block;
}
`;
const PlayerbarImage = styled.img`
width: 100%;
height: 100%;
object-fit: var(--image-fit);
`;
const LineItem = styled.div<{ $secondary?: boolean }>`
display: inline-block;
width: fit-content;
max-width: 20vw;
overflow: hidden;
line-height: 1.3;
color: ${(props) => props.$secondary && 'var(--main-fg-secondary)'};
text-overflow: ellipsis;
white-space: nowrap;
a {
color: ${(props) => props.$secondary && 'var(--text-secondary)'};
}
`;
const LeftControlsContainer = styled.div`
display: flex;
width: 100%;
height: 100%;
padding-left: 1rem;
@media (width <= 640px) {
${ImageWrapper} {
display: none;
}
}
`;
export const LeftControls = () => {
const { t } = useTranslation();
const { setSideBar } = useAppStoreActions();
@ -135,16 +67,17 @@ export const LeftControls = () => {
]);
return (
<LeftControlsContainer>
<div className={styles.leftControlsContainer}>
<LayoutGroup>
<AnimatePresence
initial={false}
mode="wait"
>
{!hideImage && (
<ImageWrapper>
<Image
<div className={styles.imageWrapper}>
<motion.div
animate={{ opacity: 1, scale: 1, x: 0 }}
className={styles.image}
exit={{ opacity: 0, x: -50 }}
initial={{ opacity: 0, x: -50 }}
key="playerbar-image"
@ -158,34 +91,21 @@ export const LeftControls = () => {
})}
openDelay={500}
>
{currentSong?.imageUrl ? (
<PlayerbarImage
loading="eager"
src={currentSong?.imageUrl}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
height: '100%',
}}
>
<RiDiscLine
color="var(--placeholder-fg)"
size={50}
/>
</Center>
)}
<Image
className={styles.playerbarImage}
loading="eager"
src={currentSong?.imageUrl ?? ''}
/>
</Tooltip>
{!collapsed && (
<Button
compact
<ActionIcon
icon="arrowUpS"
iconProps={{ size: 'xl' }}
onClick={handleToggleSidebarImage}
opacity={0.8}
radius={50}
size="md"
sx={{
radius="md"
size="xs"
style={{
cursor: 'default',
position: 'absolute',
right: 2,
@ -197,56 +117,60 @@ export const LeftControls = () => {
}),
openDelay: 500,
}}
variant="default"
>
<RiArrowUpSLine
color="white"
size={20}
/>
</Button>
/>
)}
</Image>
</ImageWrapper>
</motion.div>
</div>
)}
</AnimatePresence>
<MetadataStack layout="position">
<LineItem onClick={stopPropagation}>
<motion.div
className={styles.metadataStack}
layout="position"
>
<div
className={styles.lineItem}
onClick={stopPropagation}
>
<Group
align="flex-start"
noWrap
spacing="xs"
align="center"
gap="xs"
wrap="nowrap"
>
<Text
$link
component={Link}
fw={500}
isLink
overflow="hidden"
size="md"
to={AppRoute.NOW_PLAYING}
weight={500}
>
{title || '—'}
</Text>
{isSongDefined && (
<Button
compact
<ActionIcon
icon="ellipsisVertical"
onClick={(e) => handleGeneralContextMenu(e, [currentSong!])}
size="xs"
styles={{
root: {
'--ai-size-xs': '1.15rem',
},
}}
variant="subtle"
>
<RiMore2Fill size="1.2rem" />
</Button>
/>
)}
</Group>
</LineItem>
<LineItem
$secondary
</div>
<div
className={clsx(styles.lineItem, styles.secondary)}
onClick={stopPropagation}
>
{artists?.map((artist, index) => (
<React.Fragment key={`bar-${artist.id}`}>
{index > 0 && <Separator />}
<Text
$link={artist.id !== ''}
component={artist.id ? Link : undefined}
fw={500}
isLink={artist.id !== ''}
overflow="hidden"
size="md"
to={
@ -256,20 +180,20 @@ export const LeftControls = () => {
})
: undefined
}
weight={500}
>
{artist.name || '—'}
</Text>
</React.Fragment>
))}
</LineItem>
<LineItem
$secondary
</div>
<div
className={clsx(styles.lineItem, styles.secondary)}
onClick={stopPropagation}
>
<Text
$link
component={Link}
fw={500}
isLink
overflow="hidden"
size="md"
to={
@ -279,13 +203,12 @@ export const LeftControls = () => {
})
: ''
}
weight={500}
>
{currentSong?.album || '—'}
</Text>
</LineItem>
</MetadataStack>
</div>
</motion.div>
</LayoutGroup>
</LeftControlsContainer>
</div>
);
};

View file

@ -0,0 +1,67 @@
.motion-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
.motion-wrapper.main {
display: flex;
margin: 0 0.5rem;
}
.player-button {
all: unset;
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem;
overflow: visible;
cursor: default;
button {
display: flex;
}
&:focus-visible {
outline: 1px var(--theme-colors-primary-filled) solid;
}
&:disabled {
opacity: 0.5;
}
svg {
display: flex;
}
}
.player-button.active {
svg {
fill: var(--theme-colors-primary-filled);
}
}
.main {
background: var(--theme-colors-foreground);
border-radius: 50%;
svg {
display: flex;
color: var(--theme-colors-background);
fill: var(--theme-colors-background);
}
}
.secondary {
color: var(--theme-colors-foreground);
svg {
color: var(--theme-colors-foreground);
}
}
.tertiary {
svg {
display: flex;
}
}

View file

@ -1,167 +1,72 @@
import type { TooltipProps, UnstyledButtonProps } from '@mantine/core';
import clsx from 'clsx';
import { motion } from 'motion/react';
import { forwardRef, ReactNode } from 'react';
import { UnstyledButton } from '@mantine/core';
import { motion } from 'framer-motion';
/* stylelint-disable no-descending-specificity */
import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react';
import styled, { css } from 'styled-components';
import styles from './player-button.module.css';
import { Tooltip } from '/@/renderer/components';
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
import { Tooltip, TooltipProps } from '/@/shared/components/tooltip/tooltip';
type MantineButtonProps = ComponentPropsWithoutRef<'button'> & UnstyledButtonProps;
interface PlayerButtonProps extends MantineButtonProps {
$isActive?: boolean;
interface PlayerButtonProps extends Omit<ActionIconProps, 'icon' | 'variant'> {
icon: ReactNode;
isActive?: boolean;
tooltip?: Omit<TooltipProps, 'children'>;
variant: 'main' | 'secondary' | 'tertiary';
}
const WrapperMainVariant = css`
margin: 0 0.5rem;
`;
type MotionWrapperProps = { variant: PlayerButtonProps['variant'] };
const MotionWrapper = styled(motion.div)<MotionWrapperProps>`
display: flex;
align-items: center;
justify-content: center;
${({ variant }) => variant === 'main' && WrapperMainVariant};
`;
const ButtonMainVariant = css`
padding: 0.5rem;
background: var(--playerbar-btn-main-bg);
border-radius: 50%;
svg {
display: flex;
fill: var(--playerbar-btn-main-fg);
}
&:focus-visible {
background: var(--playerbar-btn-main-bg-hover);
}
&:hover {
background: var(--playerbar-btn-main-bg-hover);
svg {
fill: var(--playerbar-btn-main-fg-hover);
}
}
`;
const ButtonSecondaryVariant = css`
padding: 0.5rem;
`;
const ButtonTertiaryVariant = css`
padding: 0.5rem;
svg {
display: flex;
}
&:focus-visible {
svg {
fill: var(--playerbar-btn-fg-hover);
stroke: var(--playerbar-btn-fg-hover);
}
}
`;
type StyledPlayerButtonProps = Omit<PlayerButtonProps, 'icon'>;
const StyledPlayerButton = styled(UnstyledButton)<StyledPlayerButtonProps>`
all: unset;
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem;
overflow: visible;
cursor: default;
background: var(--playerbar-btn-bg-hover);
button {
display: flex;
}
&:focus-visible {
background: var(--playerbar-btn-bg-hover);
outline: 1px var(--primary-color) solid;
}
&:disabled {
opacity: 0.5;
}
svg {
display: flex;
fill: ${({ $isActive }) =>
$isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)'};
stroke: var(--playerbar-btn-fg);
}
&:hover {
color: var(--playerbar-btn-fg-hover);
background: var(--playerbar-btn-bg-hover);
svg {
fill: ${({ $isActive }) =>
$isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg-hover)'};
}
}
${({ variant }) =>
variant === 'main'
? ButtonMainVariant
: variant === 'secondary'
? ButtonSecondaryVariant
: ButtonTertiaryVariant};
`;
export const PlayerButton = forwardRef<HTMLDivElement, PlayerButtonProps>(
({ icon, tooltip, variant, ...rest }: PlayerButtonProps, ref) => {
({ icon, isActive, tooltip, variant, ...rest }: PlayerButtonProps, ref) => {
if (tooltip) {
return (
<Tooltip {...tooltip}>
<MotionWrapper
<motion.div
className={clsx({
[styles.main]: variant === 'main',
[styles.motionWrapper]: true,
})}
ref={ref}
variant={variant}
>
<StyledPlayerButton
variant={variant}
<ActionIcon
className={clsx(styles.playerButton, styles[variant], {
[styles.active]: isActive,
})}
{...rest}
onClick={(e) => {
e.stopPropagation();
rest.onClick?.(e);
}}
variant="transparent"
>
{icon}
</StyledPlayerButton>
</MotionWrapper>
</ActionIcon>
</motion.div>
</Tooltip>
);
}
return (
<MotionWrapper
<motion.div
className={clsx({
[styles.main]: variant === 'main',
[styles.motionWrapper]: true,
})}
ref={ref}
variant={variant}
>
<StyledPlayerButton
variant={variant}
<ActionIcon
className={clsx(styles.playerButton, styles[variant], {
[styles.active]: isActive,
})}
{...rest}
onClick={(e) => {
e.stopPropagation();
rest.onClick?.(e);
}}
size="compact-md"
variant="transparent"
>
{icon}
</StyledPlayerButton>
</MotionWrapper>
</ActionIcon>
</motion.div>
);
},
);

View file

@ -0,0 +1,51 @@
.bar {
background-color: var(--theme-colors-foreground);
transition: background-color 0.2s ease-in-out;
}
.label {
max-width: 200px;
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
font-size: var(--theme-font-size-md);
font-weight: 550;
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
box-shadow: 4px 4px 10px 0 rgb(0 0 0 / 20%);
}
.root {
&:hover {
.bar {
background-color: var(--theme-colors-primary-filled);
}
.thumb {
opacity: 1;
}
}
&:focus {
.bar {
background-color: var(--theme-colors-primary-filled);
}
.thumb {
opacity: 1;
}
}
}
.thumb {
width: 1rem;
height: 1rem;
border-color: var(--theme-colors-primary-filled);
border-width: 1px;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.track {
&::before {
right: calc(0.1rem * -1);
}
}

View file

@ -1,44 +1,16 @@
import { rem, Slider, SliderProps } from '@mantine/core';
import styles from './playerbar-slider.module.css';
import { Slider, SliderProps } from '/@/shared/components/slider/slider';
export const PlayerbarSlider = ({ ...props }: SliderProps) => {
return (
<Slider
styles={{
bar: {
backgroundColor: 'var(--playerbar-slider-track-progress-bg)',
transition: 'background-color 0.2s ease',
},
label: {
backgroundColor: 'var(--tooltip-bg)',
color: 'var(--tooltip-fg)',
fontSize: '1.1rem',
fontWeight: 600,
padding: '0 1rem',
},
root: {
'&:hover': {
'& .mantine-Slider-bar': {
backgroundColor: 'var(--primary-color)',
},
'& .mantine-Slider-thumb': {
opacity: 1,
},
},
},
thumb: {
backgroundColor: 'var(--slider-thumb-bg)',
borderColor: 'var(--primary-color)',
borderWidth: rem(1),
height: '1rem',
opacity: 0,
width: '1rem',
},
track: {
'&::before': {
backgroundColor: 'var(--playerbar-slider-track-bg)',
right: 'calc(0.1rem * -1)',
},
},
classNames={{
bar: styles.bar,
label: styles.label,
root: styles.root,
thumb: styles.thumb,
track: styles.track,
}}
{...props}
onClick={(e) => {

View file

@ -0,0 +1,40 @@
.container {
width: 100vw;
height: 100%;
border-top: var(--theme-colors-border);
}
.controls-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
gap: 1rem;
height: 100%;
@media (width < 768px) {
grid-template-columns: minmax(0, 0.5fr) minmax(0, 1fr) minmax(0, 0.5fr);
}
}
.right-grid-item {
align-self: center;
width: 100%;
height: 100%;
overflow: hidden;
}
.left-grid-item {
width: 100%;
height: 100%;
overflow: hidden;
}
.center-grid-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
}

View file

@ -1,5 +1,6 @@
import { MouseEvent, useCallback } from 'react';
import styled from 'styled-components';
import styles from './playerbar.module.css';
import { AudioPlayer } from '/@/renderer/components';
import { CenterControls } from '/@/renderer/features/player/components/center-controls';
@ -25,47 +26,6 @@ import {
} from '/@/renderer/store/settings.store';
import { PlaybackType } from '/@/shared/types/types';
const PlayerbarContainer = styled.div`
width: 100vw;
height: 100%;
border-top: var(--playerbar-border-top);
`;
const PlayerbarControlsGrid = styled.div`
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
gap: 1rem;
height: 100%;
@media (width <= 768px) {
grid-template-columns: minmax(0, 0.5fr) minmax(0, 1fr) minmax(0, 0.5fr);
}
`;
const RightGridItem = styled.div`
align-self: center;
width: 100%;
height: 100%;
overflow: hidden;
`;
const LeftGridItem = styled.div`
width: 100%;
height: 100%;
overflow: hidden;
`;
const CenterGridItem = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
`;
export const Playerbar = () => {
const playersRef = PlayersRef;
const settings = useSettingsStore((state) => state.playback);
@ -92,20 +52,21 @@ export const Playerbar = () => {
}, [autoNext]);
return (
<PlayerbarContainer
<div
className={styles.container}
onClick={playerbarOpenDrawer ? handleToggleFullScreenPlayer : undefined}
>
<PlayerbarControlsGrid>
<LeftGridItem>
<div className={styles.controlsGrid}>
<div className={styles.leftGridItem}>
<LeftControls />
</LeftGridItem>
<CenterGridItem>
</div>
<div className={styles.centerGridItem}>
<CenterControls playersRef={playersRef} />
</CenterGridItem>
<RightGridItem>
</div>
<div className={styles.rightGridItem}>
<RightControls />
</RightGridItem>
</PlayerbarControlsGrid>
</div>
</div>
{playbackType === PlaybackType.WEB && (
<AudioPlayer
autoNext={autoNextFn}
@ -122,6 +83,6 @@ export const Playerbar = () => {
volume={(volume / 100) ** 2}
/>
)}
</PlayerbarContainer>
</div>
);
};

View file

@ -1,20 +1,8 @@
import { Flex, Group } from '@mantine/core';
import { useHotkeys, useMediaQuery } from '@mantine/hooks';
import isElectron from 'is-electron';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { HiOutlineQueueList } from 'react-icons/hi2';
import {
RiHeartFill,
RiHeartLine,
RiVolumeDownFill,
RiVolumeMuteFill,
RiVolumeUpFill,
} from 'react-icons/ri';
import { DropdownMenu, Rating } from '/@/renderer/components';
import { Slider } from '/@/renderer/components/slider';
import { PlayerButton } from '/@/renderer/features/player/components/player-button';
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
import { useRightControls } from '/@/renderer/features/player/hooks/use-right-controls';
import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared';
@ -30,6 +18,12 @@ import {
useSpeed,
useVolume,
} from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Rating } from '/@/shared/components/rating/rating';
import { Slider } from '/@/shared/components/slider/slider';
import { LibraryItem, QueueSong, ServerType, Song } from '/@/shared/types/domain-types';
const ipc = isElectron() ? window.api.ipc : null;
@ -210,15 +204,15 @@ export const RightControls = () => {
{showRating && (
<Rating
onChange={handleUpdateRating}
size="sm"
size="xs"
value={currentSong?.userRating || 0}
/>
)}
</Group>
<Group
align="center"
noWrap
spacing="xs"
gap="xs"
wrap="nowrap"
>
<DropdownMenu
arrowOffset={12}
@ -228,13 +222,17 @@ export const RightControls = () => {
withArrow
>
<DropdownMenu.Target>
<PlayerButton
icon={<>{speed} x</>}
<ActionIcon
icon="mediaSpeed"
iconProps={{
size: 'lg',
}}
size="sm"
tooltip={{
label: t('player.playbackSpeed', { postProcess: 'sentenceCase' }),
openDelay: 500,
openDelay: 0,
}}
variant="secondary"
variant="transparent"
/>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
@ -264,78 +262,61 @@ export const RightControls = () => {
/>
</DropdownMenu.Dropdown>
</DropdownMenu>
<PlayerButton
icon={
currentSong?.userFavorite ? (
<RiHeartFill
color="var(--primary-color)"
size="1.1rem"
/>
) : (
<RiHeartLine size="1.1rem" />
)
}
onClick={() => handleToggleFavorite(currentSong)}
sx={{
svg: {
fill: !currentSong?.userFavorite
? undefined
: 'var(--primary-color) !important',
},
<ActionIcon
icon="favorite"
iconProps={{
fill: currentSong?.userFavorite ? 'primary' : undefined,
size: 'lg',
}}
onClick={() => handleToggleFavorite(currentSong)}
size="sm"
tooltip={{
label: currentSong?.userFavorite
? t('player.unfavorite', { postProcess: 'titleCase' })
: t('player.favorite', { postProcess: 'titleCase' }),
openDelay: 500,
openDelay: 0,
}}
variant="secondary"
variant="transparent"
/>
<ActionIcon
icon={isQueueExpanded ? 'panelRightClose' : 'panelRightOpen'}
iconProps={{
size: 'lg',
}}
onClick={handleToggleQueue}
size="sm"
tooltip={{
label: t('player.viewQueue', { postProcess: 'titleCase' }),
openDelay: 0,
}}
variant="transparent"
/>
<ActionIcon
icon={muted ? 'volumeMute' : volume > 50 ? 'volumeMax' : 'volumeNormal'}
iconProps={{
color: muted ? 'muted' : undefined,
size: 'xl',
}}
onClick={handleMute}
onWheel={handleVolumeWheel}
size="sm"
tooltip={{
label: muted ? t('player.muted', { postProcess: 'titleCase' }) : volume,
openDelay: 0,
}}
variant="transparent"
/>
{!isMinWidth ? (
<PlayerButton
icon={<HiOutlineQueueList size="1.1rem" />}
onClick={handleToggleQueue}
tooltip={{
label: t('player.viewQueue', { postProcess: 'titleCase' }),
openDelay: 500,
}}
variant="secondary"
<PlayerbarSlider
max={100}
min={0}
onChange={handleVolumeSlider}
onWheel={handleVolumeWheel}
size={6}
value={volume}
w={volumeWidth}
/>
) : null}
<Group
noWrap
spacing="xs"
>
<PlayerButton
icon={
muted ? (
<RiVolumeMuteFill size="1.2rem" />
) : volume > 50 ? (
<RiVolumeUpFill size="1.2rem" />
) : (
<RiVolumeDownFill size="1.2rem" />
)
}
onClick={handleMute}
onWheel={handleVolumeWheel}
tooltip={{
label: muted ? t('player.muted', { postProcess: 'titleCase' }) : volume,
openDelay: 500,
}}
variant="secondary"
/>
{!isMinWidth ? (
<PlayerbarSlider
max={100}
min={0}
onChange={handleVolumeSlider}
onWheel={handleVolumeWheel}
size={6}
value={volume}
w={volumeWidth}
/>
) : null}
</Group>
</Group>
<Group h="calc(100% / 3)" />
</Flex>

View file

@ -1,18 +1,24 @@
import { Divider, Group, SelectItem, Stack } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { QueryClient } from '@tanstack/react-query';
import merge from 'lodash/merge';
import { useMemo } from 'react';
import { RiAddBoxFill, RiAddCircleFill, RiPlayFill } from 'react-icons/ri';
import { create } from 'zustand';
import { useTranslation } from 'react-i18next';
import { persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { createWithEqualityFn } from 'zustand/traditional';
import i18n from '/@/i18n/i18n';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { Button, Checkbox, NumberInput, Select } from '/@/renderer/components';
import { useAuthStore } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import {
GenreListResponse,
GenreListSort,
@ -33,7 +39,7 @@ interface ShuffleAllSlice extends RandomSongListQuery {
enableMinYear: boolean;
}
const useShuffleAllStore = create<ShuffleAllSlice>()(
const useShuffleAllStore = createWithEqualityFn<ShuffleAllSlice>()(
persist(
immer((set, get) => ({
actions: {
@ -58,7 +64,7 @@ const useShuffleAllStore = create<ShuffleAllSlice>()(
),
);
const PLAYED_DATA: SelectItem[] = [
const PLAYED_DATA: { label: string; value: Played }[] = [
{ label: 'all tracks', value: Played.All },
{ label: 'only unplayed tracks', value: Played.Never },
{ label: 'only played tracks', value: Played.Played },
@ -81,6 +87,7 @@ export const ShuffleAllModal = ({
queryClient,
server,
}: ShuffleAllModalProps) => {
const { t } = useTranslation();
const { enableMaxYear, enableMinYear, genre, limit, maxYear, minYear, musicFolderId, played } =
useShuffleAllStore();
const { setStore } = useShuffleAllStoreActions();
@ -139,7 +146,7 @@ export const ShuffleAllModal = ({
}, [musicFolders]);
return (
<Stack spacing="md">
<Stack gap="md">
<NumberInput
label="How many tracks?"
max={500}
@ -157,8 +164,8 @@ export const ShuffleAllModal = ({
rightSection={
<Checkbox
checked={enableMinYear}
mr="0.5rem"
onChange={(e) => setStore({ enableMinYear: e.currentTarget.checked })}
style={{ marginRight: '0.5rem' }}
/>
}
value={minYear}
@ -172,8 +179,8 @@ export const ShuffleAllModal = ({
rightSection={
<Checkbox
checked={enableMaxYear}
mr="0.5rem"
onChange={(e) => setStore({ enableMaxYear: e.currentTarget.checked })}
style={{ marginRight: '0.5rem' }}
/>
}
value={maxYear}
@ -210,31 +217,31 @@ export const ShuffleAllModal = ({
<Group grow>
<Button
disabled={!limit}
leftIcon={<RiAddBoxFill size="1rem" />}
leftSection={<Icon icon="mediaPlayLast" />}
onClick={() => handlePlay(Play.LAST)}
type="submit"
variant="default"
>
Add
{t('player.addLast', { postProcess: 'sentenceCase' })}
</Button>
<Button
disabled={!limit}
leftIcon={<RiAddCircleFill size="1rem" />}
leftSection={<Icon icon="mediaPlayNext" />}
onClick={() => handlePlay(Play.NEXT)}
type="submit"
variant="default"
>
Add next
{t('player.addNext', { postProcess: 'sentenceCase' })}
</Button>
</Group>
<Button
disabled={!limit}
leftIcon={<RiPlayFill size="1rem" />}
leftSection={<Icon icon="mediaPlay" />}
onClick={() => handlePlay(Play.NOW)}
type="submit"
variant="filled"
>
Play
{t('player.play', { postProcess: 'sentenceCase' })}
</Button>
</Stack>
);

View file

@ -0,0 +1,9 @@
.container {
max-width: 100%;
margin: auto;
canvas {
width: 100%;
margin: auto;
}
}

View file

@ -1,20 +1,11 @@
import AudioMotionAnalyzer from 'audiomotion-analyzer';
import { createRef, useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import styles from './visualizer.module.css';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
import { useSettingsStore } from '/@/renderer/store';
const StyledContainer = styled.div`
max-width: 100%;
margin: auto;
canvas {
width: 100%;
margin: auto;
}
`;
export const Visualizer = () => {
const { webAudio } = useWebAudio();
const canvasRef = createRef<HTMLDivElement>();
@ -67,7 +58,8 @@ export const Visualizer = () => {
}, [resize]);
return (
<StyledContainer
<div
className={styles.container}
ref={canvasRef}
style={{ height: length, width: length }}
/>

View file

@ -3,7 +3,6 @@ import debounce from 'lodash/debounce';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from '/@/renderer/components';
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import {
@ -18,6 +17,7 @@ import {
} from '/@/renderer/store';
import { usePlaybackType } from '/@/renderer/store/settings.store';
import { setAutoNext, setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
import { toast } from '/@/shared/components/toast/toast';
import { PlaybackType, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;

View file

@ -5,7 +5,6 @@ import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { queryKeys } from '/@/renderer/api/query-keys';
import { toast } from '/@/renderer/components/toast/index';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import {
@ -20,6 +19,7 @@ import {
import { useCurrentServer, usePlayerControls, usePlayerStore } from '/@/renderer/store';
import { useGeneralSettings, usePlaybackType } from '/@/renderer/store/settings.store';
import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
import { toast } from '/@/shared/components/toast/toast';
import {
instanceOfCancellationError,
LibraryItem,