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:
Jeff 2023-03-28 14:19:23 -07:00 committed by GitHub
parent 6cfdb8ff84
commit e47fcfc62e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 780 additions and 62 deletions

View file

@ -73,7 +73,7 @@ const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
border-radius: var(--dropdown-menu-border-radius); border-radius: var(--dropdown-menu-border-radius);
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 50%)); 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-left-radius: var(--dropdown-menu-border-radius);
border-top-right-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 { *:last-child {
border-bottom-right-radius: var(--dropdown-menu-border-radius); border-bottom-right-radius: var(--dropdown-menu-border-radius);
border-bottom-left-radius: var(--dropdown-menu-border-radius); border-bottom-left-radius: var(--dropdown-menu-border-radius);
} } */
`; `;
const StyledMenuDivider = styled(MantineMenu.Divider)` const StyledMenuDivider = styled(MantineMenu.Divider)`

View file

@ -34,3 +34,4 @@ export * from './context-menu';
export * from './query-builder'; export * from './query-builder';
export * from './rating'; export * from './rating';
export * from './hover-card'; export * from './hover-card';
export * from './option';

View 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;

View file

@ -1,11 +1,10 @@
import type { ChangeEvent } from 'react'; import type { ChangeEvent } from 'react';
import { Divider, Stack } from '@mantine/core';
import { MultiSelect } from '/@/renderer/components/select'; import { MultiSelect } from '/@/renderer/components/select';
import { Slider } from '/@/renderer/components/slider'; import { Slider } from '/@/renderer/components/slider';
import { Switch } from '/@/renderer/components/switch'; import { Switch } from '/@/renderer/components/switch';
import { Text } from '/@/renderer/components/text';
import { useSettingsStoreActions, useSettingsStore } from '/@/renderer/store/settings.store'; import { useSettingsStoreActions, useSettingsStore } from '/@/renderer/store/settings.store';
import { TableColumn, TableType } from '/@/renderer/types'; import { TableColumn, TableType } from '/@/renderer/types';
import { Option } from '/@/renderer/components/option';
export const SONG_TABLE_COLUMNS = [ export const SONG_TABLE_COLUMNS = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX }, { label: 'Row Index', value: TableColumn.ROW_INDEX },
@ -168,42 +167,49 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
}; };
return ( return (
<Stack <>
p="1rem" <Option>
spacing="md" <Option.Label>Auto-fit Columns</Option.Label>
> <Option.Control>
<Stack spacing="xs"> <Switch
<Text>Table Columns</Text> defaultChecked={tableConfig[type]?.autoFit}
<MultiSelect onChange={handleUpdateAutoFit}
clearable />
data={SONG_TABLE_COLUMNS} </Option.Control>
defaultValue={tableConfig[type]?.columns.map((column) => column.column)} </Option>
dropdownPosition="top" <Option>
width={300} <Option.Label>Follow current song</Option.Label>
onChange={handleAddOrRemoveColumns} <Option.Control>
/> <Switch
</Stack> defaultChecked={tableConfig[type]?.followCurrentSong}
<Stack spacing="xs"> onChange={handleUpdateFollow}
<Text>Row Height</Text> />
<Slider </Option.Control>
defaultValue={tableConfig[type]?.rowHeight} </Option>
max={100} <Option>
min={25} <Option.Control>
sx={{ width: 150 }} <Slider
onChangeEnd={handleUpdateRowHeight} defaultValue={tableConfig[type]?.rowHeight}
/> label={(value) => `Item size: ${value}`}
</Stack> max={100}
<Divider my="0.5rem" /> min={25}
<Switch w="100%"
defaultChecked={tableConfig[type]?.autoFit} onChangeEnd={handleUpdateRowHeight}
label="Auto-fit columns" />
onChange={handleUpdateAutoFit} </Option.Control>
/> </Option>
<Switch <Option>
defaultChecked={tableConfig[type]?.followCurrentSong} <Option.Control>
label="Follow current song" <MultiSelect
onChange={handleUpdateFollow} clearable
/> data={SONG_TABLE_COLUMNS}
</Stack> defaultValue={tableConfig[type]?.columns.map((column) => column.column)}
dropdownPosition="bottom"
width={300}
onChange={handleAddOrRemoveColumns}
/>
</Option.Control>
</Option>
</>
); );
}; };

View file

@ -13,6 +13,7 @@ import {
useAppStoreActions, useAppStoreActions,
useCurrentSong, useCurrentSong,
useDefaultQueue, useDefaultQueue,
usePlayerControls,
usePreviousSong, usePreviousSong,
useQueueControls, useQueueControls,
} from '/@/renderer/store'; } from '/@/renderer/store';
@ -53,6 +54,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
const tableConfig = useTableSettings(type); const tableConfig = useTableSettings(type);
const [gridApi, setGridApi] = useState<AgGridReactType | undefined>(); const [gridApi, setGridApi] = useState<AgGridReactType | undefined>();
const playerType = usePlayerType(); const playerType = usePlayerType();
const { play } = usePlayerControls();
useEffect(() => { useEffect(() => {
if (tableRef.current) { if (tableRef.current) {
@ -79,6 +81,8 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
if (playerType === PlaybackType.LOCAL) { if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueue(playerData); mpvPlayer.setQueue(playerData);
} }
play();
}; };
const handleDragStart = () => { 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 { return {
'current-song': (params) => { 'current-song': (params) => {
return params.data.uniqueId === currentSong?.uniqueId; return params.data.uniqueId === currentSong?.uniqueId;
@ -205,11 +209,13 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
rowDragMultiRow rowDragMultiRow
autoFitColumns={tableConfig.autoFit} autoFitColumns={tableConfig.autoFit}
columnDefs={columnDefs} columnDefs={columnDefs}
deselectOnClickOutside={type === 'fullScreen'}
getRowId={(data) => data.data.uniqueId} getRowId={(data) => data.data.uniqueId}
rowBuffer={50} rowBuffer={50}
rowClassRules={rowClassRules} rowClassRules={rowClassRules}
rowData={queue} rowData={queue}
rowHeight={tableConfig.rowHeight || 40} rowHeight={tableConfig.rowHeight || 40}
suppressCellFocus={type === 'fullScreen'}
onCellContextMenu={handleContextMenu} onCellContextMenu={handleContextMenu}
onCellDoubleClicked={handleDoubleClick} onCellDoubleClicked={handleDoubleClick}
onColumnMoved={handleColumnChange} onColumnMoved={handleColumnChange}

View file

@ -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>
);
};

View file

@ -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>
);
};

View 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>
);
};

View file

@ -1,4 +1,4 @@
import React from 'react'; import React, { MouseEvent } from 'react';
import { Center, Group } from '@mantine/core'; import { Center, Group } from '@mantine/core';
import { motion, AnimatePresence, LayoutGroup } from 'framer-motion'; import { motion, AnimatePresence, LayoutGroup } from 'framer-motion';
import { RiArrowUpSLine, RiDiscLine, RiMore2Fill } from 'react-icons/ri'; 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 styled from 'styled-components';
import { Button, Text } from '/@/renderer/components'; import { Button, Text } from '/@/renderer/components';
import { AppRoute } from '/@/renderer/router/routes'; 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 { fadeIn } from '/@/renderer/styles';
import { LibraryItem } from '/@/renderer/api/types'; import { LibraryItem } from '/@/renderer/api/types';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
@ -35,7 +41,7 @@ const MetadataStack = styled(motion.div)`
overflow: hidden; overflow: hidden;
`; `;
const Image = styled(motion(Link))` const Image = styled(motion.div)`
width: 60px; width: 60px;
height: 60px; height: 60px;
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 100%)); filter: drop-shadow(0 0 5px rgb(0, 0, 0, 100%));
@ -75,6 +81,8 @@ const LineItem = styled.div<{ $secondary?: boolean }>`
export const LeftControls = () => { export const LeftControls = () => {
const { setSidebar } = useAppStoreActions(); const { setSidebar } = useAppStoreActions();
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
const hideImage = useAppStore((state) => state.sidebar.image); const hideImage = useAppStore((state) => state.sidebar.image);
const currentSong = useCurrentSong(); const currentSong = useCurrentSong();
const title = currentSong?.name; const title = currentSong?.name;
@ -87,6 +95,16 @@ export const LeftControls = () => {
SONG_CONTEXT_MENU_ITEMS, 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 ( return (
<LeftControlsContainer> <LeftControlsContainer>
<LayoutGroup> <LayoutGroup>
@ -101,8 +119,9 @@ export const LeftControls = () => {
animate={{ opacity: 1, scale: 1, x: 0 }} animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }} exit={{ opacity: 0, x: -50 }}
initial={{ opacity: 0, x: -50 }} initial={{ opacity: 0, x: -50 }}
to={AppRoute.NOW_PLAYING} role="button"
transition={{ duration: 0.3, ease: 'easeInOut' }} transition={{ duration: 0.3, ease: 'easeInOut' }}
onClick={handleToggleFullScreenPlayer}
> >
{currentSong?.imageUrl ? ( {currentSong?.imageUrl ? (
<PlayerbarImage <PlayerbarImage
@ -133,10 +152,7 @@ export const LeftControls = () => {
sx={{ position: 'absolute', right: 2, top: 2 }} sx={{ position: 'absolute', right: 2, top: 2 }}
tooltip={{ label: 'Expand', openDelay: 500 }} tooltip={{ label: 'Expand', openDelay: 500 }}
variant="default" variant="default"
onClick={(e) => { onClick={handleToggleSidebarImage}
e.preventDefault();
setSidebar({ image: true });
}}
> >
<RiArrowUpSLine <RiArrowUpSLine
color="white" color="white"

View file

@ -34,6 +34,8 @@ import {
useAppStoreActions, useAppStoreActions,
useCurrentSong, useCurrentSong,
useCurrentServer, useCurrentServer,
useSetFullScreenPlayerStore,
useFullScreenPlayerStore,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { fadeIn } from '/@/renderer/styles'; import { fadeIn } from '/@/renderer/styles';
import { CreatePlaylistForm, usePlaylistList } from '/@/renderer/features/playlists'; import { CreatePlaylistForm, usePlaylistList } from '/@/renderer/features/playlists';
@ -48,7 +50,7 @@ const SidebarContainer = styled.div`
user-select: none; user-select: none;
`; `;
const ImageContainer = styled(motion(Link))<{ height: string }>` const ImageContainer = styled(motion.div)<{ height: string }>`
position: relative; position: relative;
height: ${(props) => props.height}; height: ${(props) => props.height};
@ -112,6 +114,12 @@ export const Sidebar = () => {
startIndex: 0, startIndex: 0,
}); });
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
const expandFullScreenPlayer = () => {
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
};
const cq = useContainerQuery({ sm: 300 }); const cq = useContainerQuery({ sm: 300 });
return ( return (
@ -327,8 +335,9 @@ export const Sidebar = () => {
exit={{ opacity: 0, y: 200 }} exit={{ opacity: 0, y: 200 }}
height={sidebar.leftWidth} height={sidebar.leftWidth}
initial={{ opacity: 0, y: 200 }} initial={{ opacity: 0, y: 200 }}
to={AppRoute.NOW_PLAYING} role="button"
transition={{ duration: 0.3, ease: 'easeInOut' }} transition={{ duration: 0.3, ease: 'easeInOut' }}
onClick={expandFullScreenPlayer}
> >
{upsizedImageUrl ? ( {upsizedImageUrl ? (
<SidebarImage <SidebarImage
@ -352,7 +361,7 @@ export const Sidebar = () => {
tooltip={{ label: 'Collapse', openDelay: 500 }} tooltip={{ label: 'Collapse', openDelay: 500 }}
variant="default" variant="default"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.stopPropagation();
setSidebar({ image: false }); setSidebar({ image: false });
}} }}
> >

View file

@ -11,10 +11,11 @@ import { DrawerPlayQueue, SidebarPlayQueue } from '/@/renderer/features/now-play
import { Playerbar } from '/@/renderer/features/player'; import { Playerbar } from '/@/renderer/features/player';
import { Sidebar } from '/@/renderer/features/sidebar/components/sidebar'; import { Sidebar } from '/@/renderer/features/sidebar/components/sidebar';
import { AppRoute } from '/@/renderer/router/routes'; 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 { useSettingsStore, useGeneralSettings } from '/@/renderer/store/settings.store';
import { PlaybackType } from '/@/renderer/types'; import { PlaybackType } from '/@/renderer/types';
import { constrainSidebarWidth, constrainRightSidebarWidth } from '/@/renderer/utils'; import { constrainSidebarWidth, constrainRightSidebarWidth } from '/@/renderer/utils';
import { FullScreenPlayer } from '/@/renderer/features/player/components/full-screen-player';
if (!isElectron()) { if (!isElectron()) {
useSettingsStore.getState().actions.setSettings({ useSettingsStore.getState().actions.setSettings({
@ -84,7 +85,7 @@ const ResizeHandle = styled.div<{
right: ${(props) => props.placement === 'right' && 0}; right: ${(props) => props.placement === 'right' && 0};
bottom: ${(props) => props.placement === 'bottom' && 0}; bottom: ${(props) => props.placement === 'bottom' && 0};
left: ${(props) => props.placement === 'left' && 0}; left: ${(props) => props.placement === 'left' && 0};
z-index: 100; z-index: 90;
width: 2px; width: 2px;
height: 100%; height: 100%;
background-color: var(--sidebar-handle-bg); background-color: var(--sidebar-handle-bg);
@ -147,6 +148,7 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
location.pathname !== AppRoute.NOW_PLAYING; location.pathname !== AppRoute.NOW_PLAYING;
const showSideQueue = sidebar.rightExpanded && location.pathname !== AppRoute.NOW_PLAYING; const showSideQueue = sidebar.rightExpanded && location.pathname !== AppRoute.NOW_PLAYING;
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
const queueDrawerButtonVariants: Variants = { const queueDrawerButtonVariants: Variants = {
hidden: { hidden: {
@ -259,6 +261,12 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
> >
{!shell && ( {!shell && (
<> <>
<AnimatePresence
initial={false}
mode="wait"
>
{isFullScreenPlayerExpanded && <FullScreenPlayer />}
</AnimatePresence>
<SidebarContainer id="sidebar"> <SidebarContainer id="sidebar">
<ResizeHandle <ResizeHandle
ref={sidebarRef} ref={sidebarRef}

View 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);

View file

@ -5,3 +5,4 @@ export * from './list.store';
export * from './playlist.store'; export * from './playlist.store';
export * from './album-list-data.store'; export * from './album-list-data.store';
export * from './album-artist-list-data.store'; export * from './album-artist-list-data.store';
export * from './full-screen-player.store';

View file

@ -892,6 +892,12 @@ export const useDefaultQueue = () => usePlayerStore((state) => state.queue.defau
export const useCurrentSong = () => usePlayerStore((state) => state.current.song); 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 useCurrentPlayer = () => usePlayerStore((state) => state.current.player);
export const useCurrentStatus = () => usePlayerStore((state) => state.current.status); export const useCurrentStatus = () => usePlayerStore((state) => state.current.status);

View file

@ -61,6 +61,7 @@ export interface SettingsState {
}; };
tab: 'general' | 'playback' | 'view' | string; tab: 'general' | 'playback' | 'view' | string;
tables: { tables: {
fullScreen: DataTableProps;
nowPlaying: DataTableProps; nowPlaying: DataTableProps;
sideDrawerQueue: DataTableProps; sideDrawerQueue: DataTableProps;
sideQueue: DataTableProps; sideQueue: DataTableProps;
@ -116,6 +117,25 @@ export const useSettingsStore = create<SettingsSlice>()(
tab: 'general', tab: 'general',
tables: { 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: { nowPlaying: {
autoFit: true, autoFit: true,
columns: [ columns: [

View file

@ -48,21 +48,21 @@
--btn-primary-fg: #ffffff; --btn-primary-fg: #ffffff;
--btn-primary-fg-hover: #ffffff; --btn-primary-fg-hover: #ffffff;
--btn-primary-border: none; --btn-primary-border: none;
--btn-primary-radius: 0; --btn-primary-radius: 4px;
--btn-default-bg: rgb(31, 31, 32); --btn-default-bg: rgb(31, 31, 32);
--btn-default-bg-hover: rgb(63, 63, 63); --btn-default-bg-hover: rgb(63, 63, 63);
--btn-default-fg: rgb(193, 193, 193); --btn-default-fg: rgb(193, 193, 193);
--btn-default-fg-hover: rgb(193, 193, 193); --btn-default-fg-hover: rgb(193, 193, 193);
--btn-default-border: none; --btn-default-border: none;
--btn-default-radius: 0; --btn-default-radius: 2px;
--btn-subtle-bg: transparent; --btn-subtle-bg: transparent;
--btn-subtle-bg-hover: transparent; --btn-subtle-bg-hover: transparent;
--btn-subtle-fg: rgb(220, 220, 220); --btn-subtle-fg: rgb(220, 220, 220);
--btn-subtle-fg-hover: rgb(255, 255, 255); --btn-subtle-fg-hover: rgb(255, 255, 255);
--btn-subtle-border: none; --btn-subtle-border: none;
--btn-subtle-radius: 0; --btn-subtle-radius: 4px;
--btn-outline-bg: transparent; --btn-outline-bg: transparent;
--btn-outline-bg-hover: transparent; --btn-outline-bg-hover: transparent;
@ -77,13 +77,13 @@
--input-active-fg: rgb(193, 193, 193); --input-active-fg: rgb(193, 193, 193);
--input-active-bg: rgba(255, 255, 255, 0.1); --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-fg: rgb(235, 235, 235);
--dropdown-menu-item-padding: 0.8rem; --dropdown-menu-item-padding: 0.8rem;
--dropdown-menu-item-font-size: 1rem; --dropdown-menu-item-font-size: 1rem;
--dropdown-menu-bg-hover: rgb(62, 62, 62); --dropdown-menu-bg-hover: rgb(62, 62, 62);
--dropdown-menu-border: 1px var(--generic-border-color) solid; --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-bg: rgb(50, 50, 50);
--switch-track-enabled-bg: var(--primary-color); --switch-track-enabled-bg: var(--primary-color);
@ -106,7 +106,7 @@
--paper-bg: rgb(20, 20, 20); --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); --placeholder-fg: rgba(126, 126, 126);
--card-default-bg: rgb(32, 32, 32); --card-default-bg: rgb(32, 32, 32);

View file

@ -52,6 +52,13 @@ body[data-theme='defaultLight'] {
--btn-subtle-fg: rgb(80, 80, 80); --btn-subtle-fg: rgb(80, 80, 80);
--btn-subtle-fg-hover: rgb(0, 0, 0); --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-bg: rgb(240, 241, 242);
--input-fg: rgb(0, 0, 0); --input-fg: rgb(0, 0, 0);
--input-placeholder-fg: rgb(119, 126, 139); --input-placeholder-fg: rgb(119, 126, 139);

View file

@ -18,7 +18,7 @@ export type CardRoute = {
slugs?: RouteSlug[]; slugs?: RouteSlug[];
}; };
export type TableType = 'nowPlaying' | 'sideQueue' | 'sideDrawerQueue' | 'songs'; export type TableType = 'nowPlaying' | 'sideQueue' | 'sideDrawerQueue' | 'songs' | 'fullScreen';
export type CardRow<T> = { export type CardRow<T> = {
arrayProperty?: string; arrayProperty?: string;