Add fullscreen player view (#27)
* Add store controls for fullscreen player * Normalize styles for playback config * Add fullscreen player component * Add option component * Update player controls to use option/popover components * Add esc hotkey to close player * Add usePlayerData hook
This commit is contained in:
parent
6cfdb8ff84
commit
e47fcfc62e
18 changed files with 780 additions and 62 deletions
|
@ -73,7 +73,7 @@ const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
|
|||
border-radius: var(--dropdown-menu-border-radius);
|
||||
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 50%));
|
||||
|
||||
*:first-child {
|
||||
/* *:first-child {
|
||||
border-top-left-radius: var(--dropdown-menu-border-radius);
|
||||
border-top-right-radius: var(--dropdown-menu-border-radius);
|
||||
}
|
||||
|
@ -81,7 +81,7 @@ const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
|
|||
*:last-child {
|
||||
border-bottom-right-radius: var(--dropdown-menu-border-radius);
|
||||
border-bottom-left-radius: var(--dropdown-menu-border-radius);
|
||||
}
|
||||
} */
|
||||
`;
|
||||
|
||||
const StyledMenuDivider = styled(MantineMenu.Divider)`
|
||||
|
|
|
@ -34,3 +34,4 @@ export * from './context-menu';
|
|||
export * from './query-builder';
|
||||
export * from './rating';
|
||||
export * from './hover-card';
|
||||
export * from './option';
|
||||
|
|
32
src/renderer/components/option/index.tsx
Normal file
32
src/renderer/components/option/index.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { Flex, Group } from '@mantine/core';
|
||||
|
||||
export const Option = ({ children }: any) => {
|
||||
return (
|
||||
<Group
|
||||
grow
|
||||
p="0.5rem"
|
||||
>
|
||||
{children}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface LabelProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Label = ({ children }: LabelProps) => {
|
||||
return <Flex align="flex-start">{children}</Flex>;
|
||||
};
|
||||
|
||||
interface ControlProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Control = ({ children }: ControlProps) => {
|
||||
return <Flex justify="flex-end">{children}</Flex>;
|
||||
};
|
||||
|
||||
Option.Label = Label;
|
||||
Option.Control = Control;
|
|
@ -1,11 +1,10 @@
|
|||
import type { ChangeEvent } from 'react';
|
||||
import { Divider, Stack } from '@mantine/core';
|
||||
import { MultiSelect } from '/@/renderer/components/select';
|
||||
import { Slider } from '/@/renderer/components/slider';
|
||||
import { Switch } from '/@/renderer/components/switch';
|
||||
import { Text } from '/@/renderer/components/text';
|
||||
import { useSettingsStoreActions, useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { TableColumn, TableType } from '/@/renderer/types';
|
||||
import { Option } from '/@/renderer/components/option';
|
||||
|
||||
export const SONG_TABLE_COLUMNS = [
|
||||
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
|
||||
|
@ -168,42 +167,49 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
p="1rem"
|
||||
spacing="md"
|
||||
>
|
||||
<Stack spacing="xs">
|
||||
<Text>Table Columns</Text>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={SONG_TABLE_COLUMNS}
|
||||
defaultValue={tableConfig[type]?.columns.map((column) => column.column)}
|
||||
dropdownPosition="top"
|
||||
width={300}
|
||||
onChange={handleAddOrRemoveColumns}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack spacing="xs">
|
||||
<Text>Row Height</Text>
|
||||
<Slider
|
||||
defaultValue={tableConfig[type]?.rowHeight}
|
||||
max={100}
|
||||
min={25}
|
||||
sx={{ width: 150 }}
|
||||
onChangeEnd={handleUpdateRowHeight}
|
||||
/>
|
||||
</Stack>
|
||||
<Divider my="0.5rem" />
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.autoFit}
|
||||
label="Auto-fit columns"
|
||||
onChange={handleUpdateAutoFit}
|
||||
/>
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.followCurrentSong}
|
||||
label="Follow current song"
|
||||
onChange={handleUpdateFollow}
|
||||
/>
|
||||
</Stack>
|
||||
<>
|
||||
<Option>
|
||||
<Option.Label>Auto-fit Columns</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.autoFit}
|
||||
onChange={handleUpdateAutoFit}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Follow current song</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.followCurrentSong}
|
||||
onChange={handleUpdateFollow}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Control>
|
||||
<Slider
|
||||
defaultValue={tableConfig[type]?.rowHeight}
|
||||
label={(value) => `Item size: ${value}`}
|
||||
max={100}
|
||||
min={25}
|
||||
w="100%"
|
||||
onChangeEnd={handleUpdateRowHeight}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Control>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={SONG_TABLE_COLUMNS}
|
||||
defaultValue={tableConfig[type]?.columns.map((column) => column.column)}
|
||||
dropdownPosition="bottom"
|
||||
width={300}
|
||||
onChange={handleAddOrRemoveColumns}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
useAppStoreActions,
|
||||
useCurrentSong,
|
||||
useDefaultQueue,
|
||||
usePlayerControls,
|
||||
usePreviousSong,
|
||||
useQueueControls,
|
||||
} from '/@/renderer/store';
|
||||
|
@ -53,6 +54,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
|||
const tableConfig = useTableSettings(type);
|
||||
const [gridApi, setGridApi] = useState<AgGridReactType | undefined>();
|
||||
const playerType = usePlayerType();
|
||||
const { play } = usePlayerControls();
|
||||
|
||||
useEffect(() => {
|
||||
if (tableRef.current) {
|
||||
|
@ -79,6 +81,8 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
|||
if (playerType === PlaybackType.LOCAL) {
|
||||
mpvPlayer.setQueue(playerData);
|
||||
}
|
||||
|
||||
play();
|
||||
};
|
||||
|
||||
const handleDragStart = () => {
|
||||
|
@ -160,7 +164,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
|||
}
|
||||
};
|
||||
|
||||
const rowClassRules = useMemo<RowClassRules>(() => {
|
||||
const rowClassRules = useMemo<RowClassRules | undefined>(() => {
|
||||
return {
|
||||
'current-song': (params) => {
|
||||
return params.data.uniqueId === currentSong?.uniqueId;
|
||||
|
@ -205,11 +209,13 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
|||
rowDragMultiRow
|
||||
autoFitColumns={tableConfig.autoFit}
|
||||
columnDefs={columnDefs}
|
||||
deselectOnClickOutside={type === 'fullScreen'}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
rowBuffer={50}
|
||||
rowClassRules={rowClassRules}
|
||||
rowData={queue}
|
||||
rowHeight={tableConfig.rowHeight || 40}
|
||||
suppressCellFocus={type === 'fullScreen'}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onCellDoubleClicked={handleDoubleClick}
|
||||
onColumnMoved={handleColumnChange}
|
||||
|
|
|
@ -0,0 +1,237 @@
|
|||
import { Flex, Stack, Group, Center } from '@mantine/core';
|
||||
import { useSetState } from '@mantine/hooks';
|
||||
import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'framer-motion';
|
||||
import { useEffect } 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 { QueueSong } from '/@/renderer/api/types';
|
||||
import { Badge, Text, TextTitle } from '/@/renderer/components';
|
||||
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { PlayerData, usePlayerData, usePlayerStore } from '/@/renderer/store';
|
||||
|
||||
const Image = styled(motion.img)`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 5px;
|
||||
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
|
||||
`;
|
||||
|
||||
const ImageContainer = styled(motion.div)`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 65%;
|
||||
aspect-ratio: 1/1;
|
||||
`;
|
||||
|
||||
const imageVariants: Variants = {
|
||||
closed: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: 'linear',
|
||||
},
|
||||
},
|
||||
initial: {
|
||||
opacity: 0,
|
||||
},
|
||||
open: (custom) => {
|
||||
const { isOpen } = custom;
|
||||
return {
|
||||
opacity: isOpen ? 1 : 0,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: 'linear',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const scaleImageUrl = (url?: string | null) => {
|
||||
return url
|
||||
?.replace(/&size=\d+/, '&size=800')
|
||||
.replace(/\?width=\d+/, '?width=800')
|
||||
.replace(/&height=\d+/, '&height=800');
|
||||
};
|
||||
|
||||
const ImageWithPlaceholder = ({ ...props }: HTMLMotionProps<'img'>) => {
|
||||
if (!props.src) {
|
||||
return (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
size="25%"
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return <Image {...props} />;
|
||||
};
|
||||
|
||||
export const FullScreenPlayerImage = () => {
|
||||
const { queue } = usePlayerData();
|
||||
const currentSong = queue.current;
|
||||
const background = useFastAverageColor(queue.current?.imageUrl, true, 'dominant');
|
||||
const imageKey = `image-${background}`;
|
||||
|
||||
const [imageState, setImageState] = useSetState({
|
||||
bottomImage: scaleImageUrl(queue.next?.imageUrl),
|
||||
current: 0,
|
||||
topImage: scaleImageUrl(queue.current?.imageUrl),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubSongChange = usePlayerStore.subscribe(
|
||||
(state) => [state.current.song, state.actions.getPlayerData().queue],
|
||||
(state) => {
|
||||
const isTop = imageState.current === 0;
|
||||
const queue = state[1] as PlayerData['queue'];
|
||||
|
||||
const currentImageUrl = scaleImageUrl(queue.current?.imageUrl);
|
||||
const nextImageUrl = scaleImageUrl(queue.next?.imageUrl);
|
||||
|
||||
setImageState({
|
||||
bottomImage: isTop ? currentImageUrl : nextImageUrl,
|
||||
current: isTop ? 1 : 0,
|
||||
topImage: isTop ? nextImageUrl : currentImageUrl,
|
||||
});
|
||||
},
|
||||
{ equalityFn: (a, b) => (a[0] as QueueSong)?.id === (b[0] as QueueSong)?.id },
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubSongChange();
|
||||
};
|
||||
}, [imageState, queue, setImageState]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
className="full-screen-player-image-container"
|
||||
direction="column"
|
||||
justify="flex-start"
|
||||
p="1rem"
|
||||
sx={{ flex: 0.5, gap: '1rem' }}
|
||||
>
|
||||
<ImageContainer>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="popLayout"
|
||||
>
|
||||
{imageState.current === 0 && (
|
||||
<ImageWithPlaceholder
|
||||
key={imageKey}
|
||||
animate="open"
|
||||
className="full-screen-player-image"
|
||||
custom={{ isOpen: imageState.current === 0 }}
|
||||
draggable={false}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
placeholder="var(--placeholder-bg)"
|
||||
src={imageState.topImage || ''}
|
||||
variants={imageVariants}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="popLayout"
|
||||
>
|
||||
{imageState.current === 1 && (
|
||||
<ImageWithPlaceholder
|
||||
key={imageKey}
|
||||
animate="open"
|
||||
className="full-screen-player-image"
|
||||
custom={{ isOpen: imageState.current === 1 }}
|
||||
draggable={false}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
placeholder="var(--placeholder-bg)"
|
||||
src={imageState.bottomImage || ''}
|
||||
variants={imageVariants}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</ImageContainer>
|
||||
<Stack
|
||||
className="full-screen-player-image-metadata"
|
||||
spacing="sm"
|
||||
>
|
||||
<TextTitle
|
||||
align="center"
|
||||
order={1}
|
||||
overflow="hidden"
|
||||
w="100%"
|
||||
weight={900}
|
||||
>
|
||||
{currentSong?.name}
|
||||
</TextTitle>
|
||||
<TextTitle
|
||||
$link
|
||||
align="center"
|
||||
component={Link}
|
||||
order={2}
|
||||
overflow="hidden"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: currentSong?.albumId || '',
|
||||
})}
|
||||
w="100%"
|
||||
weight={600}
|
||||
>
|
||||
{currentSong?.album}{' '}
|
||||
</TextTitle>
|
||||
{currentSong?.artists?.map((artist, index) => (
|
||||
<TextTitle
|
||||
key={`fs-artist-${artist.id}`}
|
||||
align="center"
|
||||
order={4}
|
||||
>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 0.5rem',
|
||||
}}
|
||||
>
|
||||
•
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
weight={600}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
</TextTitle>
|
||||
))}
|
||||
<Group position="center">
|
||||
{currentSong?.container && (
|
||||
<Badge size="lg">
|
||||
{currentSong?.container} {currentSong?.bitRate}
|
||||
</Badge>
|
||||
)}
|
||||
{currentSong?.releaseYear && <Badge size="lg">{currentSong?.releaseYear}</Badge>}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Flex>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,129 @@
|
|||
import { Stack, Group, Center, Box } from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import { HiOutlineQueueList } from 'react-icons/hi2';
|
||||
import { RiFileMusicLine, RiFileTextLine, RiInformationFill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { Button, TextTitle } from '/@/renderer/components';
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing';
|
||||
import {
|
||||
useFullScreenPlayerStore,
|
||||
useFullScreenPlayerStoreActions,
|
||||
} from '/@/renderer/store/full-screen-player.store';
|
||||
|
||||
const QueueContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
--ag-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
--ag-odd-row-background-color: rgba(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);
|
||||
`;
|
||||
|
||||
export const FullScreenPlayerQueue = () => {
|
||||
const { activeTab } = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
|
||||
const headerItems = [
|
||||
{
|
||||
active: activeTab === 'queue',
|
||||
icon: <RiFileMusicLine size="1.5rem" />,
|
||||
label: 'Up Next',
|
||||
onClick: () => setStore({ activeTab: 'queue' }),
|
||||
},
|
||||
{
|
||||
active: activeTab === 'related',
|
||||
icon: <HiOutlineQueueList size="1.5rem" />,
|
||||
label: 'Related',
|
||||
onClick: () => setStore({ activeTab: 'related' }),
|
||||
},
|
||||
{
|
||||
active: activeTab === 'lyrics',
|
||||
icon: <RiFileTextLine size="1.5rem" />,
|
||||
label: 'Lyrics',
|
||||
onClick: () => setStore({ activeTab: 'lyrics' }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="full-screen-player-queue-container"
|
||||
maw="100%"
|
||||
sx={{ flex: 0.5 }}
|
||||
>
|
||||
<Group
|
||||
grow
|
||||
align="center"
|
||||
position="center"
|
||||
>
|
||||
{headerItems.map((item) => (
|
||||
<Box pos="relative">
|
||||
<Button
|
||||
fullWidth
|
||||
uppercase
|
||||
fw="600"
|
||||
pos="relative"
|
||||
size="lg"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
color: item.active
|
||||
? 'var(--main-fg) !important'
|
||||
: 'var(--main-fg-secondary) !important',
|
||||
letterSpacing: '1px',
|
||||
}}
|
||||
variant="subtle"
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
{item.active ? <ActiveTabIndicator layoutId="underline" /> : null}
|
||||
</Box>
|
||||
))}
|
||||
</Group>
|
||||
{activeTab === 'queue' ? (
|
||||
<QueueContainer>
|
||||
<PlayQueue type="fullScreen" />
|
||||
</QueueContainer>
|
||||
) : activeTab === 'related' ? (
|
||||
<Center>
|
||||
<Group>
|
||||
<RiInformationFill size="2rem" />
|
||||
<TextTitle
|
||||
order={3}
|
||||
weight={700}
|
||||
>
|
||||
COMING SOON
|
||||
</TextTitle>
|
||||
</Group>
|
||||
</Center>
|
||||
) : activeTab === 'lyrics' ? (
|
||||
<Center>
|
||||
<Group>
|
||||
<RiInformationFill size="2rem" />
|
||||
<TextTitle
|
||||
order={3}
|
||||
weight={700}
|
||||
>
|
||||
COMING SOON
|
||||
</TextTitle>
|
||||
</Group>
|
||||
</Center>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
};
|
194
src/renderer/features/player/components/full-screen-player.tsx
Normal file
194
src/renderer/features/player/components/full-screen-player.tsx
Normal file
|
@ -0,0 +1,194 @@
|
|||
import { useLayoutEffect, useRef } from 'react';
|
||||
import { Group } from '@mantine/core';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { Variants, motion } from 'framer-motion';
|
||||
import { RiArrowDownSLine, RiSettings3Line } from 'react-icons/ri';
|
||||
import { useLocation } from 'react-router';
|
||||
import styled from 'styled-components';
|
||||
import { Button, Option, Popover, Switch, TableConfigDropdown } from '/@/renderer/components';
|
||||
import {
|
||||
useCurrentSong,
|
||||
useFullScreenPlayerStore,
|
||||
useFullScreenPlayerStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { useFastAverageColor } from '../../../hooks/use-fast-average-color';
|
||||
import { FullScreenPlayerImage } from '/@/renderer/features/player/components/full-screen-player-image';
|
||||
import { FullScreenPlayerQueue } from '/@/renderer/features/player/components/full-screen-player-queue';
|
||||
|
||||
const Container = styled(motion.div)`
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
`;
|
||||
|
||||
const ResponsiveContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
width: 100%;
|
||||
max-width: 2560px;
|
||||
margin-top: 70px;
|
||||
|
||||
.full-screen-player-image {
|
||||
max-height: calc(35vh - 90px);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1080px) {
|
||||
flex-direction: row;
|
||||
|
||||
.full-screen-player-image {
|
||||
max-height: calc(70vh - 90px);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 800px) and (min-width: 1080px) {
|
||||
.full-screen-player-image {
|
||||
max-height: calc(50vh - 90px);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const BackgroundImageOverlay = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(20, 21, 23, 40%), var(--main-bg));
|
||||
`;
|
||||
|
||||
const Controls = () => {
|
||||
const { dynamicBackground, expanded } = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
|
||||
const handleToggleFullScreenPlayer = () => {
|
||||
setStore({ expanded: !expanded });
|
||||
};
|
||||
|
||||
useHotkeys([['Escape', handleToggleFullScreenPlayer]]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group
|
||||
p="1rem"
|
||||
pos="absolute"
|
||||
spacing="sm"
|
||||
sx={{
|
||||
left: 0,
|
||||
top: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Minimize' }}
|
||||
variant="subtle"
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
>
|
||||
<RiArrowDownSLine size="2rem" />
|
||||
</Button>
|
||||
<Popover position="bottom-start">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Configure' }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Line size="1.5rem" />
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Option>
|
||||
<Option.Label>Dynamic Background</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={dynamicBackground}
|
||||
onChange={(e) =>
|
||||
setStore({
|
||||
dynamicBackground: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<TableConfigDropdown type="fullScreen" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const containerVariants: Variants = {
|
||||
closed: {
|
||||
height: 'calc(100vh - 90px)',
|
||||
position: 'absolute',
|
||||
top: '100vh',
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
width: '100vw',
|
||||
y: -100,
|
||||
},
|
||||
open: (custom) => {
|
||||
const { dynamicBackground, background } = custom;
|
||||
return {
|
||||
background: dynamicBackground ? background : 'var(--main-bg)',
|
||||
height: 'calc(100vh - 90px)',
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transition: {
|
||||
background: {
|
||||
duration: 1,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
delay: 0.1,
|
||||
duration: 0.5,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
width: '100vw',
|
||||
y: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const FullScreenPlayer = () => {
|
||||
const { dynamicBackground } = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
|
||||
const location = useLocation();
|
||||
const isOpenedRef = useRef<boolean | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isOpenedRef.current !== null) {
|
||||
setStore({ expanded: false });
|
||||
}
|
||||
|
||||
isOpenedRef.current = true;
|
||||
}, [location, setStore]);
|
||||
|
||||
const currentSong = useCurrentSong();
|
||||
const background = useFastAverageColor(currentSong?.imageUrl, true, 'dominant');
|
||||
|
||||
return (
|
||||
<Container
|
||||
animate="open"
|
||||
custom={{ background, dynamicBackground }}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<Controls />
|
||||
{dynamicBackground && <BackgroundImageOverlay />}
|
||||
<ResponsiveContainer>
|
||||
<FullScreenPlayerImage />
|
||||
<FullScreenPlayerQueue />
|
||||
</ResponsiveContainer>
|
||||
</Container>
|
||||
);
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { MouseEvent } from 'react';
|
||||
import { Center, Group } from '@mantine/core';
|
||||
import { motion, AnimatePresence, LayoutGroup } from 'framer-motion';
|
||||
import { RiArrowUpSLine, RiDiscLine, RiMore2Fill } from 'react-icons/ri';
|
||||
|
@ -6,7 +6,13 @@ import { generatePath, Link } from 'react-router-dom';
|
|||
import styled from 'styled-components';
|
||||
import { Button, Text } from '/@/renderer/components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useAppStoreActions, useAppStore, useCurrentSong } from '/@/renderer/store';
|
||||
import {
|
||||
useAppStoreActions,
|
||||
useAppStore,
|
||||
useCurrentSong,
|
||||
useSetFullScreenPlayerStore,
|
||||
useFullScreenPlayerStore,
|
||||
} from '/@/renderer/store';
|
||||
import { fadeIn } from '/@/renderer/styles';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
|
@ -35,7 +41,7 @@ const MetadataStack = styled(motion.div)`
|
|||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Image = styled(motion(Link))`
|
||||
const Image = styled(motion.div)`
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 100%));
|
||||
|
@ -75,6 +81,8 @@ const LineItem = styled.div<{ $secondary?: boolean }>`
|
|||
|
||||
export const LeftControls = () => {
|
||||
const { setSidebar } = useAppStoreActions();
|
||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||
const hideImage = useAppStore((state) => state.sidebar.image);
|
||||
const currentSong = useCurrentSong();
|
||||
const title = currentSong?.name;
|
||||
|
@ -87,6 +95,16 @@ export const LeftControls = () => {
|
|||
SONG_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
const handleToggleFullScreenPlayer = (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
|
||||
};
|
||||
|
||||
const handleToggleSidebarImage = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setSidebar({ image: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<LeftControlsContainer>
|
||||
<LayoutGroup>
|
||||
|
@ -101,8 +119,9 @@ export const LeftControls = () => {
|
|||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
to={AppRoute.NOW_PLAYING}
|
||||
role="button"
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
>
|
||||
{currentSong?.imageUrl ? (
|
||||
<PlayerbarImage
|
||||
|
@ -133,10 +152,7 @@ export const LeftControls = () => {
|
|||
sx={{ position: 'absolute', right: 2, top: 2 }}
|
||||
tooltip={{ label: 'Expand', openDelay: 500 }}
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSidebar({ image: true });
|
||||
}}
|
||||
onClick={handleToggleSidebarImage}
|
||||
>
|
||||
<RiArrowUpSLine
|
||||
color="white"
|
||||
|
|
|
@ -34,6 +34,8 @@ import {
|
|||
useAppStoreActions,
|
||||
useCurrentSong,
|
||||
useCurrentServer,
|
||||
useSetFullScreenPlayerStore,
|
||||
useFullScreenPlayerStore,
|
||||
} from '/@/renderer/store';
|
||||
import { fadeIn } from '/@/renderer/styles';
|
||||
import { CreatePlaylistForm, usePlaylistList } from '/@/renderer/features/playlists';
|
||||
|
@ -48,7 +50,7 @@ const SidebarContainer = styled.div`
|
|||
user-select: none;
|
||||
`;
|
||||
|
||||
const ImageContainer = styled(motion(Link))<{ height: string }>`
|
||||
const ImageContainer = styled(motion.div)<{ height: string }>`
|
||||
position: relative;
|
||||
height: ${(props) => props.height};
|
||||
|
||||
|
@ -112,6 +114,12 @@ export const Sidebar = () => {
|
|||
startIndex: 0,
|
||||
});
|
||||
|
||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
||||
const expandFullScreenPlayer = () => {
|
||||
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
|
||||
};
|
||||
|
||||
const cq = useContainerQuery({ sm: 300 });
|
||||
|
||||
return (
|
||||
|
@ -327,8 +335,9 @@ export const Sidebar = () => {
|
|||
exit={{ opacity: 0, y: 200 }}
|
||||
height={sidebar.leftWidth}
|
||||
initial={{ opacity: 0, y: 200 }}
|
||||
to={AppRoute.NOW_PLAYING}
|
||||
role="button"
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
onClick={expandFullScreenPlayer}
|
||||
>
|
||||
{upsizedImageUrl ? (
|
||||
<SidebarImage
|
||||
|
@ -352,7 +361,7 @@ export const Sidebar = () => {
|
|||
tooltip={{ label: 'Collapse', openDelay: 500 }}
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSidebar({ image: false });
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -11,10 +11,11 @@ import { DrawerPlayQueue, SidebarPlayQueue } from '/@/renderer/features/now-play
|
|||
import { Playerbar } from '/@/renderer/features/player';
|
||||
import { Sidebar } from '/@/renderer/features/sidebar/components/sidebar';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useAppStore, useAppStoreActions } from '/@/renderer/store';
|
||||
import { useAppStore, useAppStoreActions, useFullScreenPlayerStore } from '/@/renderer/store';
|
||||
import { useSettingsStore, useGeneralSettings } from '/@/renderer/store/settings.store';
|
||||
import { PlaybackType } from '/@/renderer/types';
|
||||
import { constrainSidebarWidth, constrainRightSidebarWidth } from '/@/renderer/utils';
|
||||
import { FullScreenPlayer } from '/@/renderer/features/player/components/full-screen-player';
|
||||
|
||||
if (!isElectron()) {
|
||||
useSettingsStore.getState().actions.setSettings({
|
||||
|
@ -84,7 +85,7 @@ const ResizeHandle = styled.div<{
|
|||
right: ${(props) => props.placement === 'right' && 0};
|
||||
bottom: ${(props) => props.placement === 'bottom' && 0};
|
||||
left: ${(props) => props.placement === 'left' && 0};
|
||||
z-index: 100;
|
||||
z-index: 90;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background-color: var(--sidebar-handle-bg);
|
||||
|
@ -147,6 +148,7 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
|
|||
location.pathname !== AppRoute.NOW_PLAYING;
|
||||
|
||||
const showSideQueue = sidebar.rightExpanded && location.pathname !== AppRoute.NOW_PLAYING;
|
||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
||||
|
||||
const queueDrawerButtonVariants: Variants = {
|
||||
hidden: {
|
||||
|
@ -259,6 +261,12 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
|
|||
>
|
||||
{!shell && (
|
||||
<>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{isFullScreenPlayerExpanded && <FullScreenPlayer />}
|
||||
</AnimatePresence>
|
||||
<SidebarContainer id="sidebar">
|
||||
<ResizeHandle
|
||||
ref={sidebarRef}
|
||||
|
|
46
src/renderer/store/full-screen-player.store.ts
Normal file
46
src/renderer/store/full-screen-player.store.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import merge from 'lodash/merge';
|
||||
import create from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
|
||||
interface FullScreenPlayerState {
|
||||
activeTab: string | 'queue' | 'related' | 'lyrics';
|
||||
dynamicBackground?: boolean;
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export interface FullScreenPlayerSlice extends FullScreenPlayerState {
|
||||
actions: {
|
||||
setStore: (data: Partial<FullScreenPlayerSlice>) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const useFullScreenPlayerStore = create<FullScreenPlayerSlice>()(
|
||||
persist(
|
||||
devtools(
|
||||
immer((set, get) => ({
|
||||
actions: {
|
||||
setStore: (data) => {
|
||||
set({ ...get(), ...data });
|
||||
},
|
||||
},
|
||||
activeTab: 'queue',
|
||||
expanded: false,
|
||||
})),
|
||||
{ name: 'store_full_screen_player' },
|
||||
),
|
||||
{
|
||||
merge: (persistedState, currentState) => {
|
||||
return merge(currentState, persistedState);
|
||||
},
|
||||
name: 'store_full_screen_player',
|
||||
version: 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const useFullScreenPlayerStoreActions = () =>
|
||||
useFullScreenPlayerStore((state) => state.actions);
|
||||
|
||||
export const useSetFullScreenPlayerStore = () =>
|
||||
useFullScreenPlayerStore((state) => state.actions.setStore);
|
|
@ -5,3 +5,4 @@ export * from './list.store';
|
|||
export * from './playlist.store';
|
||||
export * from './album-list-data.store';
|
||||
export * from './album-artist-list-data.store';
|
||||
export * from './full-screen-player.store';
|
||||
|
|
|
@ -892,6 +892,12 @@ export const useDefaultQueue = () => usePlayerStore((state) => state.queue.defau
|
|||
|
||||
export const useCurrentSong = () => usePlayerStore((state) => state.current.song);
|
||||
|
||||
export const usePlayerData = () =>
|
||||
usePlayerStore(
|
||||
(state) => state.actions.getPlayerData(),
|
||||
(a, b) => a.current.nextIndex === b.current.nextIndex,
|
||||
);
|
||||
|
||||
export const useCurrentPlayer = () => usePlayerStore((state) => state.current.player);
|
||||
|
||||
export const useCurrentStatus = () => usePlayerStore((state) => state.current.status);
|
||||
|
|
|
@ -61,6 +61,7 @@ export interface SettingsState {
|
|||
};
|
||||
tab: 'general' | 'playback' | 'view' | string;
|
||||
tables: {
|
||||
fullScreen: DataTableProps;
|
||||
nowPlaying: DataTableProps;
|
||||
sideDrawerQueue: DataTableProps;
|
||||
sideQueue: DataTableProps;
|
||||
|
@ -116,6 +117,25 @@ export const useSettingsStore = create<SettingsSlice>()(
|
|||
|
||||
tab: 'general',
|
||||
tables: {
|
||||
fullScreen: {
|
||||
autoFit: true,
|
||||
columns: [
|
||||
{
|
||||
column: TableColumn.TITLE_COMBINED,
|
||||
width: 500,
|
||||
},
|
||||
{
|
||||
column: TableColumn.DURATION,
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
column: TableColumn.USER_FAVORITE,
|
||||
width: 100,
|
||||
},
|
||||
],
|
||||
followCurrentSong: true,
|
||||
rowHeight: 60,
|
||||
},
|
||||
nowPlaying: {
|
||||
autoFit: true,
|
||||
columns: [
|
||||
|
|
|
@ -48,21 +48,21 @@
|
|||
--btn-primary-fg: #ffffff;
|
||||
--btn-primary-fg-hover: #ffffff;
|
||||
--btn-primary-border: none;
|
||||
--btn-primary-radius: 0;
|
||||
--btn-primary-radius: 4px;
|
||||
|
||||
--btn-default-bg: rgb(31, 31, 32);
|
||||
--btn-default-bg-hover: rgb(63, 63, 63);
|
||||
--btn-default-fg: rgb(193, 193, 193);
|
||||
--btn-default-fg-hover: rgb(193, 193, 193);
|
||||
--btn-default-border: none;
|
||||
--btn-default-radius: 0;
|
||||
--btn-default-radius: 2px;
|
||||
|
||||
--btn-subtle-bg: transparent;
|
||||
--btn-subtle-bg-hover: transparent;
|
||||
--btn-subtle-fg: rgb(220, 220, 220);
|
||||
--btn-subtle-fg-hover: rgb(255, 255, 255);
|
||||
--btn-subtle-border: none;
|
||||
--btn-subtle-radius: 0;
|
||||
--btn-subtle-radius: 4px;
|
||||
|
||||
--btn-outline-bg: transparent;
|
||||
--btn-outline-bg-hover: transparent;
|
||||
|
@ -77,13 +77,13 @@
|
|||
--input-active-fg: rgb(193, 193, 193);
|
||||
--input-active-bg: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--dropdown-menu-bg: rgb(45, 45, 45);
|
||||
--dropdown-menu-bg: rgb(32, 32, 32);
|
||||
--dropdown-menu-fg: rgb(235, 235, 235);
|
||||
--dropdown-menu-item-padding: 0.8rem;
|
||||
--dropdown-menu-item-font-size: 1rem;
|
||||
--dropdown-menu-bg-hover: rgb(62, 62, 62);
|
||||
--dropdown-menu-border: 1px var(--generic-border-color) solid;
|
||||
--dropdown-menu-border-radius: 0;
|
||||
--dropdown-menu-border-radius: 4px;
|
||||
|
||||
--switch-track-bg: rgb(50, 50, 50);
|
||||
--switch-track-enabled-bg: var(--primary-color);
|
||||
|
@ -106,7 +106,7 @@
|
|||
|
||||
--paper-bg: rgb(20, 20, 20);
|
||||
|
||||
--placeholder-bg: rgba(53, 53, 53, 0.5);
|
||||
--placeholder-bg: rgba(53, 53, 53, 1);
|
||||
--placeholder-fg: rgba(126, 126, 126);
|
||||
|
||||
--card-default-bg: rgb(32, 32, 32);
|
||||
|
|
|
@ -52,6 +52,13 @@ body[data-theme='defaultLight'] {
|
|||
--btn-subtle-fg: rgb(80, 80, 80);
|
||||
--btn-subtle-fg-hover: rgb(0, 0, 0);
|
||||
|
||||
--btn-outline-bg: transparent;
|
||||
--btn-outline-bg-hover: transparent;
|
||||
--btn-outline-fg: rgb(60, 60, 60);
|
||||
--btn-outline-fg-hover: rgb(0, 0, 0);
|
||||
--btn-outline-border: 1px rgba(140, 140, 140, 0.5) solid;
|
||||
--btn-outline-radius: 1px;
|
||||
|
||||
--input-bg: rgb(240, 241, 242);
|
||||
--input-fg: rgb(0, 0, 0);
|
||||
--input-placeholder-fg: rgb(119, 126, 139);
|
||||
|
|
|
@ -18,7 +18,7 @@ export type CardRoute = {
|
|||
slugs?: RouteSlug[];
|
||||
};
|
||||
|
||||
export type TableType = 'nowPlaying' | 'sideQueue' | 'sideDrawerQueue' | 'songs';
|
||||
export type TableType = 'nowPlaying' | 'sideQueue' | 'sideDrawerQueue' | 'songs' | 'fullScreen';
|
||||
|
||||
export type CardRow<T> = {
|
||||
arrayProperty?: string;
|
||||
|
|
Reference in a new issue