mirror of
https://github.com/jeffvli/feishin.git
synced 2026-08-10 14:23:18 +02:00
add initial files
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export * from './routes/LoginRoute';
|
||||
@@ -0,0 +1,38 @@
|
||||
import md5 from 'md5';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useMutation } from 'react-query';
|
||||
import { authApi } from 'renderer/api/authApi';
|
||||
import { useAuthStore } from 'renderer/store';
|
||||
|
||||
export const useLogin = (
|
||||
serverUrl: string,
|
||||
body: {
|
||||
password: string;
|
||||
username: string;
|
||||
}
|
||||
) => {
|
||||
const login = useAuthStore((state) => state.login);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => authApi.login(serverUrl, body),
|
||||
onSuccess: (res) => {
|
||||
const key = md5(serverUrl);
|
||||
login({ key, serverUrl });
|
||||
|
||||
if (!localStorage.getItem('device_id')) {
|
||||
localStorage.setItem('device_id', nanoid());
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
'authentication',
|
||||
JSON.stringify({
|
||||
accessToken: res.data.accessToken,
|
||||
isAuthenticated: true,
|
||||
key,
|
||||
refreshToken: res.data.refreshToken,
|
||||
serverUrl,
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from 'react-query';
|
||||
import { authApi } from 'renderer/api/authApi';
|
||||
import { queryKeys } from 'renderer/api/queryKeys';
|
||||
|
||||
export const usePingServer = (server: string) => {
|
||||
return useQuery({
|
||||
enabled: !!server,
|
||||
queryFn: () => authApi.ping(server),
|
||||
queryKey: queryKeys.ping(server),
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
.container {
|
||||
min-width: 400px;
|
||||
max-width: 50%;
|
||||
margin: auto;
|
||||
padding: 3rem;
|
||||
background: rgba(50, 50, 50, 0.4);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: var(--primary-color);
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: var(--primary-color);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
TextInput,
|
||||
PasswordInput,
|
||||
Button,
|
||||
Stack,
|
||||
Title,
|
||||
Loader,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { AlertCircle, CircleCheck } from 'tabler-icons-react';
|
||||
import { normalizeServerUrl } from 'renderer/utils';
|
||||
import { useLogin } from '../queries/useLogin';
|
||||
import { usePingServer } from '../queries/usePingServer';
|
||||
import styles from './LoginRoute.module.scss';
|
||||
|
||||
export const LoginRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [username, setUsername] = useState(searchParams.get('username') || '');
|
||||
const [password, setPassword] = useState(searchParams.get('password') || '');
|
||||
const [server, setServer] = useState(
|
||||
searchParams.get('server') || 'http://localhost:9321'
|
||||
);
|
||||
const [debouncedServer] = useDebouncedValue(server, 500);
|
||||
|
||||
const {
|
||||
mutate: handleLogin,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useLogin(normalizeServerUrl(server), {
|
||||
password,
|
||||
username,
|
||||
});
|
||||
|
||||
const {
|
||||
isLoading: isCheckingServer,
|
||||
isSuccess: isValidServer,
|
||||
isFetched,
|
||||
} = usePingServer(normalizeServerUrl(debouncedServer));
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Title>{t('auth.login')}</Title>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleLogin(undefined, {
|
||||
onError: () => {},
|
||||
onSuccess: () => {},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Stack spacing="md">
|
||||
<TextInput
|
||||
required
|
||||
disabled={isLoading}
|
||||
error={!isValidServer && isFetched}
|
||||
label={`${t('auth.server.label')}`}
|
||||
placeholder={`${t('auth.server.placeholder')}`}
|
||||
rightSection={
|
||||
isCheckingServer ? (
|
||||
<Loader size="xs" />
|
||||
) : isValidServer ? (
|
||||
<CircleCheck />
|
||||
) : null
|
||||
}
|
||||
value={server}
|
||||
variant="default"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setServer(e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
disabled={isLoading}
|
||||
label={`${t('auth.username.label')}`}
|
||||
placeholder={`${t('auth.username.placeholder')}`}
|
||||
value={username}
|
||||
variant="default"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setUsername(e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
<PasswordInput
|
||||
required
|
||||
disabled={isLoading}
|
||||
label={`${t('auth.password.label')}`}
|
||||
placeholder={`${t('auth.password.placeholder')}`}
|
||||
value={password}
|
||||
variant="default"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setPassword(e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
className={styles.button}
|
||||
disabled={!isValidServer}
|
||||
type="submit"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
{isError && (
|
||||
<Alert color="red" icon={<AlertCircle />} variant="outline">
|
||||
{t('Invalid username or password.')}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './routes/DashboardRoute';
|
||||
@@ -0,0 +1,3 @@
|
||||
export const DashboardRoute = () => {
|
||||
return <></>;
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link, LinkProps } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { fontGotham } from 'renderer/styles';
|
||||
|
||||
interface LibraryTabProps extends LinkProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const StyledLibraryTab = styled(motion.div)`
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(150, 150, 150, 30%);
|
||||
}
|
||||
`;
|
||||
|
||||
const TabLink = styled(Link)`
|
||||
${fontGotham(60)}
|
||||
color: #fff;
|
||||
font-size: 1.2em;
|
||||
`;
|
||||
|
||||
const LibraryTab = ({ children, ...rest }: LibraryTabProps) => {
|
||||
return (
|
||||
<TabLink {...rest}>
|
||||
<StyledLibraryTab>{children}</StyledLibraryTab>
|
||||
</TabLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryTab;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useInfiniteQuery, useQuery } from 'react-query';
|
||||
import { queryKeys } from 'renderer/api/queryKeys';
|
||||
import { albumsApi, AlbumsRequest } from '../../../api/albumsApi';
|
||||
|
||||
export const useAlbums = (params: AlbumsRequest) => {
|
||||
return useQuery({
|
||||
queryFn: () => albumsApi.getAlbums(params),
|
||||
queryKey: queryKeys.albums(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useAlbumsInfinite = (params: AlbumsRequest) => {
|
||||
return useInfiniteQuery({
|
||||
getNextPageParam: (lastPage) => {
|
||||
return !!lastPage.pagination.nextPage;
|
||||
},
|
||||
getPreviousPageParam: (firstPage) => {
|
||||
return !!firstPage.pagination.prevPage;
|
||||
},
|
||||
queryFn: ({ pageParam }) =>
|
||||
albumsApi.getAlbums({ ...(pageParam || params) }),
|
||||
queryKey: queryKeys.albums(params),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/* eslint-disable no-plusplus */
|
||||
import { useRef } from 'react';
|
||||
import InfiniteLoader from 'react-window-infinite-loader';
|
||||
import { albumsApi } from 'renderer/api/albumsApi';
|
||||
import { VirtualInfiniteGrid } from 'renderer/components/virtual-grid/VirtualInfiniteGrid';
|
||||
import { AnimatedPage } from 'renderer/features/shared/components/AnimatedPage';
|
||||
import { AppRoute } from 'renderer/router/utils/routes';
|
||||
import { Item } from 'types';
|
||||
import { useAlbums } from '../queries/getAlbums';
|
||||
|
||||
export const LibraryAlbumsRoute = () => {
|
||||
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
||||
|
||||
const params = {
|
||||
orderBy: 'asc',
|
||||
sortBy: 'title',
|
||||
};
|
||||
|
||||
const { data: albums } = useAlbums({
|
||||
limit: 0,
|
||||
page: 0,
|
||||
...params,
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
{albums && (
|
||||
<VirtualInfiniteGrid
|
||||
ref={infiniteLoaderRef}
|
||||
cardControls={{
|
||||
endpoint: albumsApi.getAlbum,
|
||||
idProperty: 'id',
|
||||
type: Item.Album,
|
||||
}}
|
||||
cardRows={[
|
||||
{
|
||||
align: 'center',
|
||||
prop: 'name',
|
||||
route: {
|
||||
prop: 'id',
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
},
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
prop: 'year',
|
||||
},
|
||||
]}
|
||||
itemCount={albums.pagination.totalEntries}
|
||||
itemGap={20}
|
||||
itemSize={180}
|
||||
query={albumsApi.getAlbums}
|
||||
queryParams={params}
|
||||
/>
|
||||
)}
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/* eslint-disable no-plusplus */
|
||||
import { useRef } from 'react';
|
||||
import InfiniteLoader from 'react-window-infinite-loader';
|
||||
import { AnimatedPage } from 'renderer/features/shared/components/AnimatedPage';
|
||||
import { useAlbums } from '../queries/getAlbums';
|
||||
|
||||
export const LibraryArtistsRoute = () => {
|
||||
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
||||
|
||||
const params = {
|
||||
orderBy: 'asc',
|
||||
sortBy: 'title',
|
||||
};
|
||||
|
||||
const { data: albums } = useAlbums({
|
||||
limit: 0,
|
||||
page: 0,
|
||||
...params,
|
||||
});
|
||||
|
||||
return <AnimatedPage />;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import styled from 'styled-components';
|
||||
import { AnimatedPage } from 'renderer/features/shared/components/AnimatedPage';
|
||||
import { AppRoute } from 'renderer/router/utils/routes';
|
||||
import LibraryTab from '../components/LibraryTab';
|
||||
|
||||
const TabContainer = styled.div`
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
export const LibraryRoute = () => {
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<TabContainer>
|
||||
<LibraryTab to={AppRoute.LIBRARY_ALBUMS}>Albums</LibraryTab>
|
||||
<LibraryTab to={AppRoute.LIBRARY_ALBUMARTISTS}>
|
||||
Album Artists
|
||||
</LibraryTab>
|
||||
<LibraryTab to={AppRoute.LIBRARY_ARTISTS}>Artists</LibraryTab>
|
||||
</TabContainer>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import format from 'format-duration';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
PlayerPause,
|
||||
PlayerPlay,
|
||||
PlayerSkipBack,
|
||||
PlayerSkipForward,
|
||||
PlayerTrackNext,
|
||||
PlayerTrackPrev,
|
||||
} from 'tabler-icons-react';
|
||||
import { Text } from 'renderer/components';
|
||||
import { usePlayerStore } from 'renderer/store';
|
||||
import { Font } from 'renderer/styles';
|
||||
import { PlaybackType, PlayerStatus } from 'types';
|
||||
import { useCenterControls } from '../hooks/useCenterControls';
|
||||
import { PlayerButton } from './PlayerButton';
|
||||
import { Slider } from './Slider';
|
||||
|
||||
interface CenterControlsProps {
|
||||
playersRef: any;
|
||||
}
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 35px;
|
||||
`;
|
||||
|
||||
const ButtonsContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const SliderContainer = styled.div`
|
||||
display: flex;
|
||||
height: 20px;
|
||||
`;
|
||||
|
||||
const SliderValueWrapper = styled.div<{ position: 'left' | 'right' }>`
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
max-width: 50px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const SliderWrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 6;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const playerData = usePlayerStore((state) => state.getPlayerData());
|
||||
const player1 = playersRef?.current?.player1?.player;
|
||||
const player2 = playersRef?.current?.player2?.player;
|
||||
const { status, player } = usePlayerStore((state) => state.current);
|
||||
const settings = usePlayerStore((state) => state.settings);
|
||||
const setCurrentTime = usePlayerStore((state) => state.setCurrentTime);
|
||||
|
||||
const {
|
||||
handleNextTrack,
|
||||
handlePlayPause,
|
||||
handlePrevTrack,
|
||||
handleSeekSlider,
|
||||
handleSkipBackward,
|
||||
handleSkipForward,
|
||||
} = useCenterControls({ playersRef });
|
||||
|
||||
const currentTime = usePlayerStore((state) => state.current.time);
|
||||
|
||||
const currentPlayerRef = player === 1 ? player1 : player2;
|
||||
|
||||
const duration = useMemo(
|
||||
() => format((playerData.queue.current?.duration || 0) * 1000),
|
||||
[playerData.queue]
|
||||
);
|
||||
|
||||
const formattedTime = useMemo(
|
||||
() => format(currentTime * 1000 || 0),
|
||||
[currentTime]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: any;
|
||||
|
||||
if (status === PlayerStatus.Playing && !isSeeking) {
|
||||
if (settings.type === PlaybackType.Web) {
|
||||
interval = setInterval(() => {
|
||||
setCurrentTime(currentPlayerRef.getCurrentTime());
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentPlayerRef, isSeeking, setCurrentTime, settings.type, status]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ControlsContainer onScroll={(e) => console.log(e)}>
|
||||
<ButtonsContainer>
|
||||
<PlayerButton
|
||||
icon={<PlayerSkipBack size={15} />}
|
||||
tooltip={{ label: `${t('player.prev')}` }}
|
||||
variant="secondary"
|
||||
onClick={handlePrevTrack}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<PlayerTrackPrev size={15} />}
|
||||
tooltip={{ label: `${t('player.skipBack')}` }}
|
||||
variant="secondary"
|
||||
onClick={handleSkipBackward}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={
|
||||
status === PlayerStatus.Paused ? (
|
||||
<PlayerPlay size={20} />
|
||||
) : (
|
||||
<PlayerPause size={20} />
|
||||
)
|
||||
}
|
||||
tooltip={{
|
||||
label:
|
||||
status === PlayerStatus.Paused
|
||||
? `${t('player.play')}`
|
||||
: `${t('player.pause')}`,
|
||||
}}
|
||||
variant="main"
|
||||
onClick={handlePlayPause}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<PlayerTrackNext size={15} />}
|
||||
tooltip={{ label: `${t('player.skipForward')}` }}
|
||||
variant="secondary"
|
||||
onClick={handleSkipForward}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<PlayerSkipForward size={15} />}
|
||||
tooltip={{ label: `${t('player.next')}` }}
|
||||
variant="secondary"
|
||||
onClick={handleNextTrack}
|
||||
/>
|
||||
</ButtonsContainer>
|
||||
</ControlsContainer>
|
||||
<SliderContainer>
|
||||
<SliderValueWrapper position="left">
|
||||
<Text noSelect secondary font={Font.POPPINS} size="xs" weight={600}>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
</SliderValueWrapper>
|
||||
<SliderWrapper>
|
||||
<Slider
|
||||
height="100%"
|
||||
max={playerData.queue.current?.duration}
|
||||
min={0}
|
||||
tooltipType="time"
|
||||
value={currentTime}
|
||||
onAfterChange={(e) => {
|
||||
handleSeekSlider(e);
|
||||
setIsSeeking(false);
|
||||
}}
|
||||
/>
|
||||
</SliderWrapper>
|
||||
<SliderValueWrapper position="right">
|
||||
<Text noSelect secondary font={Font.POPPINS} size="xs" weight={600}>
|
||||
{duration}
|
||||
</Text>
|
||||
</SliderValueWrapper>
|
||||
</SliderContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { LazyLoadImage as Image } from 'react-lazy-load-image-component';
|
||||
import styled from 'styled-components';
|
||||
import { Text } from 'renderer/components';
|
||||
import { usePlayerStore } from 'renderer/store';
|
||||
import { Font } from 'renderer/styles';
|
||||
|
||||
const LeftControlsContainer = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ImageWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
`;
|
||||
|
||||
const MetadataStack = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const LeftControls = () => {
|
||||
const song = usePlayerStore((state) => state.current.song);
|
||||
const title = song?.title;
|
||||
const artists = song?.artist?.map((artist) => artist?.title).join(', ');
|
||||
const album = song?.album;
|
||||
|
||||
return (
|
||||
<LeftControlsContainer>
|
||||
<ImageWrapper>
|
||||
<Image height={60} src={song?.image} width={60} />
|
||||
</ImageWrapper>
|
||||
<MetadataStack>
|
||||
<Text
|
||||
font={Font.POPPINS}
|
||||
link={!!title}
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
to="/nowplaying"
|
||||
weight={500}
|
||||
>
|
||||
{title || '—'}
|
||||
</Text>
|
||||
<Text
|
||||
secondary
|
||||
font={Font.POPPINS}
|
||||
link={!!artists}
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
to="/nowplaying"
|
||||
weight={500}
|
||||
>
|
||||
{artists || '—'}
|
||||
</Text>
|
||||
<Text
|
||||
secondary
|
||||
font={Font.POPPINS}
|
||||
link={!!album}
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
to="/nowplaying"
|
||||
weight={500}
|
||||
>
|
||||
{album || '—'}
|
||||
</Text>
|
||||
</MetadataStack>
|
||||
</LeftControlsContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
TooltipProps,
|
||||
UnstyledButton,
|
||||
UnstyledButtonProps,
|
||||
} from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { Tooltip } from 'renderer/components';
|
||||
|
||||
interface PlayerButtonProps extends UnstyledButtonProps<'button'> {
|
||||
icon: ReactNode;
|
||||
tooltip?: Omit<TooltipProps, 'children'>;
|
||||
variant: 'main' | 'secondary';
|
||||
}
|
||||
|
||||
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-color);
|
||||
border-radius: 50%;
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
fill: black;
|
||||
stroke: black;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--playerbar-btn-color-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background: var(--playerbar-btn-color-hover);
|
||||
}
|
||||
`;
|
||||
|
||||
const ButtonSecondaryVariant = css`
|
||||
padding: 0.5rem;
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
fill: var(--playerbar-btn-color);
|
||||
stroke: var(--playerbar-btn-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg {
|
||||
fill: var(--playerbar-btn-color-hover);
|
||||
stroke: var(--playerbar-btn-color-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
svg {
|
||||
fill: var(--playerbar-btn-color-hover);
|
||||
stroke: var(--playerbar-btn-color-hover);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type StyledPlayerButtonProps = Omit<PlayerButtonProps, 'icon'>;
|
||||
|
||||
const StyledPlayerButton = styled(UnstyledButton)<StyledPlayerButtonProps>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
overflow: visible;
|
||||
all: unset;
|
||||
cursor: default;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px var(--primary-color) solid;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
${({ variant }) =>
|
||||
variant === 'main' ? ButtonMainVariant : ButtonSecondaryVariant}
|
||||
`;
|
||||
|
||||
export const PlayerButton = ({
|
||||
tooltip,
|
||||
variant,
|
||||
icon,
|
||||
...rest
|
||||
}: PlayerButtonProps) => {
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip {...tooltip}>
|
||||
<MotionWrapper variant={variant}>
|
||||
<StyledPlayerButton variant={variant} {...rest}>
|
||||
{icon}
|
||||
</StyledPlayerButton>
|
||||
</MotionWrapper>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MotionWrapper variant={variant}>
|
||||
<StyledPlayerButton variant={variant} {...rest}>
|
||||
{icon}
|
||||
</StyledPlayerButton>
|
||||
</MotionWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
PlayerButton.defaultProps = {
|
||||
tooltip: undefined,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { AudioPlayer } from 'renderer/components';
|
||||
import { usePlayerStore } from 'renderer/store';
|
||||
import { PlaybackType } from 'types';
|
||||
import { CenterControls } from './CenterControls';
|
||||
import { LeftControls } from './LeftControls';
|
||||
import { RightControls } from './RightControls';
|
||||
|
||||
const PlayerbarContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-top: var(--playerbar-border-top);
|
||||
`;
|
||||
|
||||
const PlayerbarControlsGrid = styled.div`
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const RightGridItem = styled.div`
|
||||
align-self: center;
|
||||
width: calc(100% / 3);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const LeftGridItem = styled.div`
|
||||
width: calc(100% / 3);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const CenterGridItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
width: calc(100% / 3);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const Playerbar = () => {
|
||||
const playersRef = useRef<any>();
|
||||
const settings = usePlayerStore((state) => state.settings);
|
||||
const volume = usePlayerStore((state) => state.settings.volume);
|
||||
const player1 = usePlayerStore((state) => state.player1());
|
||||
const player2 = usePlayerStore((state) => state.player2());
|
||||
const status = usePlayerStore((state) => state.current.status);
|
||||
const player = usePlayerStore((state) => state.current.player);
|
||||
const autoNext = usePlayerStore((state) => state.autoNext);
|
||||
|
||||
return (
|
||||
<PlayerbarContainer>
|
||||
<PlayerbarControlsGrid>
|
||||
<LeftGridItem>
|
||||
<LeftControls />
|
||||
</LeftGridItem>
|
||||
<CenterGridItem>
|
||||
<CenterControls playersRef={playersRef} />
|
||||
</CenterGridItem>
|
||||
<RightGridItem>
|
||||
<RightControls />
|
||||
</RightGridItem>
|
||||
</PlayerbarControlsGrid>
|
||||
{settings.type === PlaybackType.Web && (
|
||||
<AudioPlayer
|
||||
ref={playersRef}
|
||||
autoNext={autoNext}
|
||||
crossfadeDuration={settings.crossfadeDuration}
|
||||
crossfadeStyle={settings.crossfadeStyle}
|
||||
currentPlayer={player}
|
||||
muted={settings.muted}
|
||||
player1={player1}
|
||||
player2={player2}
|
||||
status={status}
|
||||
style={settings.style}
|
||||
volume={volume}
|
||||
/>
|
||||
)}
|
||||
</PlayerbarContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import styled from 'styled-components';
|
||||
import { Volume2, Volume3 } from 'tabler-icons-react';
|
||||
import { IconButton } from 'renderer/components';
|
||||
import { usePlayerStore } from 'renderer/store';
|
||||
import { useRightControls } from '../hooks/useRightControls';
|
||||
import { Slider } from './Slider';
|
||||
|
||||
const RightControlsContainer = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
`;
|
||||
|
||||
const VolumeSliderWrapper = styled.div`
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
width: 90px;
|
||||
`;
|
||||
|
||||
const MetadataStack = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
`;
|
||||
|
||||
export const RightControls = () => {
|
||||
const volume = usePlayerStore((state) => state.settings.volume);
|
||||
const muted = usePlayerStore((state) => state.settings.muted);
|
||||
const { handleVolumeSlider, handleVolumeSliderState, handleMute } =
|
||||
useRightControls();
|
||||
|
||||
return (
|
||||
<RightControlsContainer>
|
||||
<MetadataStack>
|
||||
<VolumeSliderWrapper>
|
||||
<IconButton
|
||||
icon={
|
||||
muted ? (
|
||||
<Volume3 size={20} strokeWidth={1.5} />
|
||||
) : (
|
||||
<Volume2 size={20} strokeWidth={1.5} />
|
||||
)
|
||||
}
|
||||
size={20}
|
||||
tooltip={{ label: muted ? 'Muted' : volume }}
|
||||
variant="transparent"
|
||||
onClick={handleMute}
|
||||
/>
|
||||
|
||||
<Slider
|
||||
hasTooltip
|
||||
height="100%"
|
||||
max={100}
|
||||
min={0}
|
||||
value={volume}
|
||||
onAfterChange={handleVolumeSliderState}
|
||||
onChange={handleVolumeSlider}
|
||||
/>
|
||||
</VolumeSliderWrapper>
|
||||
</MetadataStack>
|
||||
</RightControlsContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import format from 'format-duration';
|
||||
import ReactSlider, { ReactSliderProps } from 'react-slider';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface SliderProps extends ReactSliderProps {
|
||||
hasTooltip?: boolean;
|
||||
height: string;
|
||||
tooltipType?: 'text' | 'time';
|
||||
}
|
||||
|
||||
const StyledSlider = styled(ReactSlider)<SliderProps | any>`
|
||||
width: 100%;
|
||||
height: ${(props) => props.height};
|
||||
outline: none;
|
||||
|
||||
.thumb {
|
||||
top: 37%;
|
||||
opacity: 1;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: -25px;
|
||||
left: -18px;
|
||||
display: ${(props) =>
|
||||
props.$isDragging && props.$hasToolTip ? 'block' : 'none'};
|
||||
padding: 2px 6px;
|
||||
color: var(--tooltip-text-color);
|
||||
white-space: nowrap;
|
||||
background: var(--tooltip-bg);
|
||||
border-radius: 4px;
|
||||
content: attr(data-tooltip);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
border: 1px var(--primary-color) solid;
|
||||
border-radius: 100%;
|
||||
outline: none;
|
||||
transform: translate(-12px, -4px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.track-0 {
|
||||
background: ${(props) => props.$isDragging && 'var(--primary-color)'};
|
||||
transition: background 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.track {
|
||||
top: 37%;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.track-0 {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const MemoizedThumb = ({ props, state, toolTipType }: any) => {
|
||||
const { value } = state;
|
||||
const formattedValue = useMemo(() => {
|
||||
if (toolTipType === 'text') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return format(value * 1000);
|
||||
}, [toolTipType, value]);
|
||||
|
||||
return <div {...props} data-tooltip={formattedValue} />;
|
||||
};
|
||||
|
||||
const StyledTrack = styled.div<any>`
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: 5px;
|
||||
background: ${(props) =>
|
||||
props.index === 1
|
||||
? 'var(--playerbar-slider-track-bg)'
|
||||
: 'var(--playerbar-slider-track-progress-bg)'};
|
||||
`;
|
||||
|
||||
const Track = (props: any, state: any) => (
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
<StyledTrack {...props} index={state.index} />
|
||||
);
|
||||
const Thumb = (props: any, state: any, toolTipType: any) => (
|
||||
<MemoizedThumb
|
||||
key="slider"
|
||||
props={props}
|
||||
state={state}
|
||||
tabIndex={0}
|
||||
toolTipType={toolTipType}
|
||||
/>
|
||||
);
|
||||
|
||||
export const Slider = ({
|
||||
height,
|
||||
tooltipType: toolTipType,
|
||||
hasTooltip: hasToolTip,
|
||||
...rest
|
||||
}: SliderProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
return (
|
||||
<StyledSlider
|
||||
{...rest}
|
||||
$hasToolTip={hasToolTip}
|
||||
$isDragging={isDragging}
|
||||
className="player-slider"
|
||||
defaultValue={0}
|
||||
height={height}
|
||||
renderThumb={(props: any, state: any) => {
|
||||
return Thumb(props, state, toolTipType);
|
||||
}}
|
||||
renderTrack={Track}
|
||||
onAfterChange={(e: number, index: number) => {
|
||||
if (rest.onAfterChange) {
|
||||
rest.onAfterChange(e, index);
|
||||
}
|
||||
setIsDragging(false);
|
||||
}}
|
||||
onBeforeChange={(e: number, index: number) => {
|
||||
if (rest.onBeforeChange) {
|
||||
rest.onBeforeChange(e, index);
|
||||
}
|
||||
setIsDragging(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Slider.defaultProps = {
|
||||
hasTooltip: true,
|
||||
tooltipType: 'text',
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useCallback } from 'react';
|
||||
import { usePlayerStore } from 'renderer/store';
|
||||
import { PlaybackType, PlayerStatus } from '../../../../types';
|
||||
import { mpvPlayer } from '../utils/mpvPlayer';
|
||||
|
||||
export const useCenterControls = (args: { playersRef: any }) => {
|
||||
const { playersRef } = args;
|
||||
|
||||
const settings = usePlayerStore((state) => state.settings);
|
||||
const play = usePlayerStore((state) => state.play);
|
||||
const pause = usePlayerStore((state) => state.pause);
|
||||
const prev = usePlayerStore((state) => state.prev);
|
||||
const next = usePlayerStore((state) => state.next);
|
||||
const queue = usePlayerStore((state) => state.queue.default);
|
||||
const playerStatus = usePlayerStore((state) => state.current.status);
|
||||
const currentPlayer = usePlayerStore((state) => state.current.player);
|
||||
const currentTime = usePlayerStore((state) => state.current.time);
|
||||
const setCurrentTime = usePlayerStore((state) => state.setCurrentTime);
|
||||
|
||||
const player1Ref = playersRef?.current?.player1;
|
||||
const player2Ref = playersRef?.current?.player2;
|
||||
const currentPlayerRef = currentPlayer === 1 ? player1Ref : player2Ref;
|
||||
const nextPlayerRef = currentPlayer === 1 ? player2Ref : player1Ref;
|
||||
|
||||
const resetPlayers = useCallback(() => {
|
||||
player1Ref.getInternalPlayer().currentTime = 0;
|
||||
player2Ref.getInternalPlayer().currentTime = 0;
|
||||
player1Ref.getInternalPlayer().pause();
|
||||
player2Ref.getInternalPlayer().pause();
|
||||
}, [player1Ref, player2Ref]);
|
||||
|
||||
const resetNextPlayer = useCallback(() => {
|
||||
currentPlayerRef.getInternalPlayer().volume = 0.1;
|
||||
nextPlayerRef.getInternalPlayer().currentTime = 0;
|
||||
nextPlayerRef.getInternalPlayer().pause();
|
||||
}, [currentPlayerRef, nextPlayerRef]);
|
||||
|
||||
const stopPlayback = () => {
|
||||
player1Ref.getInternalPlayer().pause();
|
||||
player2Ref.getInternalPlayer().pause();
|
||||
resetPlayers();
|
||||
};
|
||||
|
||||
const handlePlay = useCallback(() => {
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
mpvPlayer.play();
|
||||
} else {
|
||||
currentPlayerRef.getInternalPlayer().play();
|
||||
}
|
||||
|
||||
play();
|
||||
}, [currentPlayerRef, play, settings]);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
mpvPlayer.pause();
|
||||
}
|
||||
|
||||
pause();
|
||||
}, [pause, settings]);
|
||||
|
||||
const handleStop = () => {
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
mpvPlayer.stop();
|
||||
} else {
|
||||
stopPlayback();
|
||||
}
|
||||
|
||||
setCurrentTime(0);
|
||||
pause();
|
||||
};
|
||||
|
||||
const handleNextTrack = useCallback(() => {
|
||||
const playerData = next();
|
||||
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.next();
|
||||
} else {
|
||||
resetPlayers();
|
||||
}
|
||||
|
||||
setCurrentTime(0);
|
||||
}, [next, resetPlayers, setCurrentTime, settings]);
|
||||
|
||||
const handlePrevTrack = useCallback(() => {
|
||||
const playerData = prev();
|
||||
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.previous();
|
||||
} else {
|
||||
resetPlayers();
|
||||
}
|
||||
|
||||
setCurrentTime(0);
|
||||
}, [prev, resetPlayers, setCurrentTime, settings]);
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (queue) {
|
||||
if (playerStatus === PlayerStatus.Paused) {
|
||||
return handlePlay();
|
||||
}
|
||||
|
||||
return handlePause();
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [handlePause, handlePlay, playerStatus, queue]);
|
||||
|
||||
const handleSkipBackward = () => {
|
||||
const skipBackwardSec = 5;
|
||||
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
const newTime = currentTime - skipBackwardSec;
|
||||
mpvPlayer.seek(-skipBackwardSec);
|
||||
setCurrentTime(newTime < 0 ? 0 : newTime);
|
||||
} else {
|
||||
const newTime = currentPlayerRef.getCurrentTime() - skipBackwardSec;
|
||||
resetNextPlayer();
|
||||
setCurrentTime(newTime);
|
||||
currentPlayerRef.seekTo(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipForward = () => {
|
||||
const skipForwardSec = 5;
|
||||
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
const newTime = currentTime + skipForwardSec;
|
||||
mpvPlayer.seek(skipForwardSec);
|
||||
setCurrentTime(newTime);
|
||||
} else {
|
||||
const checkNewTime = currentPlayerRef.getCurrentTime() + skipForwardSec;
|
||||
const songDuration = currentPlayerRef.player.player.duration;
|
||||
|
||||
const newTime =
|
||||
checkNewTime >= songDuration ? songDuration - 1 : checkNewTime;
|
||||
|
||||
resetNextPlayer();
|
||||
setCurrentTime(newTime);
|
||||
currentPlayerRef.seekTo(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeekSlider = useCallback(
|
||||
(e: number | any) => {
|
||||
setCurrentTime(e);
|
||||
|
||||
if (settings.type === PlaybackType.Local) {
|
||||
mpvPlayer.seekTo(e);
|
||||
} else {
|
||||
currentPlayerRef.seekTo(e);
|
||||
}
|
||||
},
|
||||
[currentPlayerRef, setCurrentTime, settings]
|
||||
);
|
||||
|
||||
// const handleVolumeSlider = useCallback(
|
||||
// (e: number | any) => {
|
||||
// // dispatch(setVolume(e));
|
||||
// if (settings.type === PlaybackType.Local) {
|
||||
// // playerApi.volume(currentTime, e);
|
||||
// }
|
||||
|
||||
// setSettings({ volume: (e / 100) ** 2 });
|
||||
// },
|
||||
// [currentTime, setSettings, settings]
|
||||
// );
|
||||
|
||||
return {
|
||||
handleNextTrack,
|
||||
handlePlayPause,
|
||||
handlePrevTrack,
|
||||
handleSeekSlider,
|
||||
handleSkipBackward,
|
||||
handleSkipForward,
|
||||
handleStop,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { usePlayerStore } from 'renderer/store';
|
||||
import {
|
||||
getJellyfinStreamUrl,
|
||||
getServerFolderAuth,
|
||||
getSubsonicStreamUrl,
|
||||
} from 'renderer/utils';
|
||||
import { Item, Play } from 'types';
|
||||
import { mpvPlayer } from '../utils/mpvPlayer';
|
||||
|
||||
const getEndpoint = (item: Item) => {
|
||||
switch (item) {
|
||||
case Item.Album:
|
||||
return 'getAlbum';
|
||||
case Item.Artist:
|
||||
return 'getArtistSongs';
|
||||
case Item.Playlist:
|
||||
return 'getPlaylist';
|
||||
default:
|
||||
return 'getAlbum';
|
||||
}
|
||||
};
|
||||
|
||||
export const usePlayQueueHandler = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const addQueue = usePlayerStore((state) => state.add);
|
||||
|
||||
// const dispatchSongsToQueue = useCallback(
|
||||
// (entries: Song[], play: Play) => {
|
||||
// const filteredSongs = filterPlayQueue(config.playback.filters, entries);
|
||||
|
||||
// if (play === Play.Play) {
|
||||
// if (filteredSongs.entries.length > 0) {
|
||||
// } else {
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (play === Play.Next || play === Play.Later) {
|
||||
// if (filteredSongs.entries.length > 0) {
|
||||
// }
|
||||
// }
|
||||
|
||||
// notifyToast(
|
||||
// 'info',
|
||||
// getPlayedSongsNotification({
|
||||
// ...filteredSongs.count,
|
||||
// type: play === Play.Play ? 'play' : 'add',
|
||||
// })
|
||||
// );
|
||||
// },
|
||||
// [config.playback.filters, dispatch]
|
||||
// );
|
||||
|
||||
const handlePlayQueueAdd = async (options: {
|
||||
byData?: any[];
|
||||
byItemType?: {
|
||||
endpoint: (params: Record<string, any>) => any;
|
||||
id: string;
|
||||
type: Item;
|
||||
};
|
||||
play: Play;
|
||||
}) => {
|
||||
if (options.byData) {
|
||||
// dispatchSongsToQueue(options.byData, options.play);
|
||||
}
|
||||
|
||||
if (options.byItemType) {
|
||||
const deviceId = localStorage.getItem('device_id');
|
||||
const { serverUrl } = JSON.parse(
|
||||
localStorage.getItem('authentication') || '{}'
|
||||
);
|
||||
|
||||
if (deviceId) {
|
||||
const data = await options.byItemType.endpoint({
|
||||
id: options.byItemType.id,
|
||||
});
|
||||
|
||||
const songs = data.data.songs.map((song) => {
|
||||
const auth = getServerFolderAuth(serverUrl, song.serverFolderId);
|
||||
|
||||
if (auth) {
|
||||
const streamUrl =
|
||||
auth.type === 'jellyfin'
|
||||
? getJellyfinStreamUrl(auth, song, deviceId)
|
||||
: getSubsonicStreamUrl(auth, song, deviceId);
|
||||
|
||||
return {
|
||||
albumId: song.albumId,
|
||||
artistName: song.artistName,
|
||||
duration: song.duration,
|
||||
id: song.id,
|
||||
streamUrl,
|
||||
title: song.name,
|
||||
year: song.year,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
albumId: song.albumId,
|
||||
artistName: song.artistName,
|
||||
duration: song.duration,
|
||||
id: song.id,
|
||||
streamUrl: song.streamUrl,
|
||||
title: song.name,
|
||||
year: song.year,
|
||||
};
|
||||
});
|
||||
|
||||
const pData = addQueue(songs);
|
||||
mpvPlayer.setQueue(pData);
|
||||
}
|
||||
|
||||
// const data = await apiController({
|
||||
// args: { id: options.byItemType.id, musicFolder: options.musicFolder },
|
||||
// endpoint:
|
||||
// options.byItemType.endpoint || getEndpoint(options.byItemType.item),
|
||||
// serverType: config.serverType,
|
||||
// });
|
||||
// if (options.byItemType.item === Item.Album) {
|
||||
// queryClient.setQueryData(['album', options.byItemType.id], data);
|
||||
// } else if (options.byItemType.item === Item.Artist) {
|
||||
// queryClient.setQueryData(['artistSongs', options.byItemType.id], data);
|
||||
// } else if (options.byItemType.item === Item.Playlist) {
|
||||
// queryClient.setQueryData(['playlist', options.byItemType.id], data);
|
||||
// }
|
||||
// if (data?.song) {
|
||||
// dispatchSongsToQueue(data.song, options.play);
|
||||
// } else {
|
||||
// dispatchSongsToQueue(data, options.play);
|
||||
// }
|
||||
}
|
||||
};
|
||||
|
||||
return { handlePlayQueueAdd };
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect } from 'react';
|
||||
import { usePlayerStore } from 'renderer/store';
|
||||
import { mpvPlayer } from '../utils/mpvPlayer';
|
||||
|
||||
export const useRightControls = () => {
|
||||
const setSettings = usePlayerStore((state) => state.setSettings);
|
||||
const volume = usePlayerStore((state) => state.settings.volume);
|
||||
const muted = usePlayerStore((state) => state.settings.muted);
|
||||
|
||||
// Ensure that the mpv player volume is set on startup
|
||||
useEffect(() => {
|
||||
mpvPlayer.volume(volume);
|
||||
}, [volume]);
|
||||
|
||||
// Ensure that the mpv player mute status is set on startup
|
||||
useEffect(() => {
|
||||
if (muted) {
|
||||
mpvPlayer.mute();
|
||||
}
|
||||
}, [muted]);
|
||||
|
||||
const handleVolumeSlider = (e: number) => {
|
||||
mpvPlayer.volume(e);
|
||||
};
|
||||
|
||||
const handleVolumeSliderState = (e: number) => {
|
||||
setSettings({ volume: e });
|
||||
};
|
||||
|
||||
const handleMute = () => {
|
||||
setSettings({ muted: !muted });
|
||||
mpvPlayer.mute();
|
||||
};
|
||||
|
||||
return {
|
||||
handleMute,
|
||||
handleVolumeSlider,
|
||||
handleVolumeSliderState,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './components/CenterControls';
|
||||
export * from './components/LeftControls';
|
||||
export * from './components/Playerbar';
|
||||
export * from './components/Slider';
|
||||
@@ -0,0 +1,65 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { PlayerData, usePlayerStore } from 'renderer/store';
|
||||
|
||||
const ipc = isElectron() ? window.electron.ipcRenderer : null;
|
||||
|
||||
const play = () => ipc?.PLAYER_PLAY();
|
||||
|
||||
const pause = () => ipc?.PLAYER_PAUSE();
|
||||
|
||||
const stop = () => ipc?.PLAYER_STOP();
|
||||
|
||||
const currentTime = () => ipc?.PLAYER_CURRENT_TIME();
|
||||
|
||||
const next = () => ipc?.PLAYER_NEXT();
|
||||
|
||||
const previous = () => ipc?.PLAYER_PREVIOUS();
|
||||
|
||||
const setQueue = (data: PlayerData) => ipc?.PLAYER_SET_QUEUE(data);
|
||||
|
||||
const setQueueNext = (data: PlayerData) => ipc?.PLAYER_SET_QUEUE_NEXT(data);
|
||||
|
||||
const seek = (seconds: number) => ipc?.PLAYER_SEEK(seconds);
|
||||
|
||||
const seekTo = (seconds: number) => ipc?.PLAYER_SEEK_TO(seconds);
|
||||
|
||||
const volume = (value: number) => ipc?.PLAYER_VOLUME(value);
|
||||
|
||||
const mute = () => ipc?.PLAYER_MUTE();
|
||||
|
||||
const {
|
||||
setCurrentTime,
|
||||
play: setPlay,
|
||||
pause: setPause,
|
||||
autoNext,
|
||||
} = usePlayerStore.getState();
|
||||
|
||||
ipc?.RENDERER_PLAYER_PLAY(() => setPlay());
|
||||
|
||||
ipc?.RENDERER_PLAYER_PAUSE(() => setPause());
|
||||
|
||||
ipc?.RENDERER_PLAYER_STOP(() => setPause());
|
||||
|
||||
ipc?.RENDERER_PLAYER_CURRENT_TIME((_event, time) => setCurrentTime(time));
|
||||
|
||||
ipc?.RENDERER_PLAYER_SET_QUEUE_NEXT(() => {
|
||||
const playerData = autoNext();
|
||||
if (playerData.queue.next) {
|
||||
setQueueNext(playerData);
|
||||
}
|
||||
});
|
||||
|
||||
export const mpvPlayer = {
|
||||
currentTime,
|
||||
mute,
|
||||
next,
|
||||
pause,
|
||||
play,
|
||||
previous,
|
||||
seek,
|
||||
seekTo,
|
||||
setQueue,
|
||||
setQueueNext,
|
||||
stop,
|
||||
volume,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
ModalProps,
|
||||
PasswordInput,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCreateServer, validateServer } from '../queries/useCreateServer';
|
||||
|
||||
export const AddServerModal = ({ ...rest }: ModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
legacyAuth: false,
|
||||
name: '',
|
||||
password: '',
|
||||
serverType: 'jellyfin',
|
||||
url: 'http://',
|
||||
username: '',
|
||||
},
|
||||
});
|
||||
|
||||
const createServerMutation = useCreateServer();
|
||||
|
||||
return (
|
||||
<Modal centered title={t('server.add.title')} {...rest}>
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
const res = await validateServer(values);
|
||||
|
||||
if (res?.token) {
|
||||
createServerMutation.mutateAsync({
|
||||
...values,
|
||||
remoteUserId: res.userId,
|
||||
token: res.token,
|
||||
});
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ label: 'Jellyfin', value: 'jellyfin' },
|
||||
{ label: 'Subsonic', value: 'subsonic' },
|
||||
]}
|
||||
{...form.getInputProps('serverType')}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label={t('server.name')}
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label={t('server.url')}
|
||||
{...form.getInputProps('url')}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label={t('server.username')}
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
required
|
||||
label={t('server.password')}
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
{form.getInputProps('serverType').value === 'subsonic' && (
|
||||
<Checkbox
|
||||
label={t('server.legacyauth')}
|
||||
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
|
||||
/>
|
||||
)}
|
||||
<Button type="submit">{t('server.submit')}</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
ModalProps,
|
||||
PasswordInput,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ServerResponse } from 'renderer/api/types';
|
||||
|
||||
interface EditServerModalProps extends ModalProps {
|
||||
server: ServerResponse | undefined;
|
||||
}
|
||||
|
||||
export const EditServerModal = ({ server }: EditServerModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
legacyAuth: false,
|
||||
name: server?.name,
|
||||
password: '',
|
||||
serverType: server?.serverType,
|
||||
url: server?.url,
|
||||
username: server?.username,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(async () => {})}>
|
||||
<Stack>
|
||||
<SegmentedControl
|
||||
disabled
|
||||
data={[
|
||||
{ label: 'Jellyfin', value: 'jellyfin' },
|
||||
{ label: 'Subsonic', value: 'subsonic' },
|
||||
]}
|
||||
{...form.getInputProps('serverType')}
|
||||
/>
|
||||
<TextInput label={t('server.name')} {...form.getInputProps('name')} />
|
||||
<TextInput label={t('server.url')} {...form.getInputProps('url')} />
|
||||
<TextInput
|
||||
label={t('server.username')}
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t('server.password')}
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
{form.getInputProps('serverType').value === 'subsonic' && (
|
||||
<Checkbox
|
||||
label={t('server.legacyauth')}
|
||||
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
|
||||
/>
|
||||
)}
|
||||
<Button type="submit">{t('server.submit')}</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
max-width: 50vw;
|
||||
margin: 1rem;
|
||||
padding: 1rem;
|
||||
outline: 1px #fff solid;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Text } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { EditCircle } from 'tabler-icons-react';
|
||||
import { ServerResponse } from 'renderer/api/types';
|
||||
import { IconButton } from 'renderer/components';
|
||||
import { useServers } from '../queries/useServers';
|
||||
import { EditServerModal } from './EditServerModal';
|
||||
import styles from './ServerList.module.scss';
|
||||
|
||||
export const ServerList = () => {
|
||||
const { data: servers } = useServers();
|
||||
const [editServerModal, editServerHandlers] = useDisclosure(false);
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const [selectedServer, setSelectedServer] = useState<
|
||||
ServerResponse | undefined
|
||||
>();
|
||||
|
||||
const handleClickServer = (server: ServerResponse) => {
|
||||
setSelectedServer(server);
|
||||
editServerHandlers.open();
|
||||
};
|
||||
|
||||
const handleKeyDownServer = (
|
||||
e: React.KeyboardEvent<HTMLDivElement>,
|
||||
server: ServerResponse
|
||||
) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
setSelectedServer(server);
|
||||
editServerHandlers.open();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setOpened(false);
|
||||
editServerHandlers.close();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editServerModal === true) {
|
||||
setTimeout(() => setOpened(true));
|
||||
} else {
|
||||
setTimeout(() => setSelectedServer(undefined), 100);
|
||||
}
|
||||
}, [editServerModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{servers &&
|
||||
servers.data.map((server) => (
|
||||
<>
|
||||
<div
|
||||
className={styles.item}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleClickServer(server)}
|
||||
onKeyDown={(e) => handleKeyDownServer(e, server)}
|
||||
>
|
||||
<div>
|
||||
{server.name}
|
||||
<Text>Hello</Text>
|
||||
</div>
|
||||
<IconButton
|
||||
icon={<EditCircle />}
|
||||
onClick={() => editServerHandlers.toggle()}
|
||||
>
|
||||
Edit
|
||||
</IconButton>
|
||||
</div>
|
||||
{selectedServer && (
|
||||
<EditServerModal
|
||||
opened={opened}
|
||||
server={selectedServer}
|
||||
onClose={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './routes/ServersRoute';
|
||||
export * from './queries/useCreateServer';
|
||||
export * from './queries/useServers';
|
||||
export * from './components/AddServerModal';
|
||||
export * from './components/ServerList';
|
||||
@@ -0,0 +1,70 @@
|
||||
import axios from 'axios';
|
||||
import md5 from 'md5';
|
||||
import { useMutation } from 'react-query';
|
||||
import { serversApi } from 'renderer/api/serversApi';
|
||||
import { randomString } from 'renderer/utils';
|
||||
|
||||
export const validateServer = async (options: {
|
||||
legacyAuth: boolean;
|
||||
password: string;
|
||||
serverType: string;
|
||||
url: string;
|
||||
username: string;
|
||||
}) => {
|
||||
const { serverType, url, username, password, legacyAuth } = options;
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
try {
|
||||
if (serverType === 'subsonic') {
|
||||
let testConnection;
|
||||
let token;
|
||||
if (legacyAuth) {
|
||||
token = `u=${username}&p=${password}`;
|
||||
testConnection = await axios.get(
|
||||
`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=sonixd&f=json&${token}`
|
||||
);
|
||||
} else {
|
||||
const salt = randomString();
|
||||
const hash = md5(password + salt);
|
||||
token = `u=${username}&s=${salt}&t=${hash}`;
|
||||
|
||||
testConnection = await axios.get(
|
||||
`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=sonixd&f=json&${token}`
|
||||
);
|
||||
}
|
||||
|
||||
if (testConnection.data['subsonic-response'].status === 'failed') {
|
||||
return {
|
||||
error: `${testConnection.data['subsonic-response'].error.message}`,
|
||||
};
|
||||
}
|
||||
return { token, userId: '' };
|
||||
}
|
||||
|
||||
const { data } = await axios.post(
|
||||
`${cleanServerUrl}/users/authenticatebyname`,
|
||||
{ pw: password, username },
|
||||
{
|
||||
headers: {
|
||||
'X-Emby-Authorization': `MediaBrowser Client="Sonixd", Device="PC", DeviceId="Sonixd", Version="1.0.0-alpha1"`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return { token: data.AccessToken, userId: data.User.Id };
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useCreateServer = () => {
|
||||
return useMutation({
|
||||
mutationFn: serversApi.createServer,
|
||||
onError: (e) => console.log(e),
|
||||
onSuccess: (e) => console.log(e),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import md5 from 'md5';
|
||||
import { useQuery } from 'react-query';
|
||||
import { queryKeys } from 'renderer/api/queryKeys';
|
||||
import { serversApi } from 'renderer/api/serversApi';
|
||||
import { ServerFolderResponse } from 'renderer/api/types';
|
||||
import { ServerFolderAuth } from 'types';
|
||||
|
||||
export const useServers = () => {
|
||||
return useQuery({
|
||||
onSuccess: (servers) => {
|
||||
const { serverUrl } = JSON.parse(
|
||||
localStorage.getItem('authentication') || '{}'
|
||||
);
|
||||
const storedServersKey = `servers_${md5(serverUrl)}`;
|
||||
const serversFromLocalStorage = localStorage.getItem(storedServersKey);
|
||||
|
||||
// If a custom account/token is set for a server, use that instead of the default one
|
||||
if (serversFromLocalStorage) {
|
||||
const existingServers = JSON.parse(serversFromLocalStorage);
|
||||
|
||||
// The 'locked' property determines whether or not to skip updating the server auth
|
||||
const skipped = existingServers.filter(
|
||||
(server: ServerFolderAuth) => server.locked
|
||||
);
|
||||
|
||||
const store = servers?.data?.flatMap((server) =>
|
||||
server.serverFolder?.map((serverFolder: ServerFolderResponse) => {
|
||||
if (skipped.includes(serverFolder.id)) {
|
||||
return existingServers.find(
|
||||
(s: ServerFolderAuth) => s.id === serverFolder.id
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: serverFolder.id,
|
||||
locked: false,
|
||||
serverId: server.id,
|
||||
token: server.token,
|
||||
type: server.serverType,
|
||||
url: server.url,
|
||||
userId: server.remoteUserId,
|
||||
username: server.username,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return localStorage.setItem(storedServersKey, JSON.stringify(store));
|
||||
}
|
||||
|
||||
const store = servers?.data?.flatMap((server) =>
|
||||
server.serverFolder?.map((serverFolder: ServerFolderResponse) => ({
|
||||
id: serverFolder.id,
|
||||
locked: false,
|
||||
serverId: server.id,
|
||||
token: server.token,
|
||||
type: server.serverType,
|
||||
url: server.url,
|
||||
userId: server.remoteUserId,
|
||||
username: server.username,
|
||||
}))
|
||||
);
|
||||
|
||||
return localStorage.setItem(storedServersKey, JSON.stringify(store));
|
||||
},
|
||||
queryFn: () => serversApi.getServers(),
|
||||
queryKey: queryKeys.servers,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Title } from '@mantine/core';
|
||||
import { ServerList } from '../components/ServerList';
|
||||
|
||||
export const ServersRoute = () => {
|
||||
return (
|
||||
<div>
|
||||
<Title>Servers</Title>
|
||||
<ServerList />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { PlayerState } from 'renderer/store';
|
||||
import {
|
||||
CrossfadeStyle,
|
||||
PlaybackStyle,
|
||||
PlaybackType,
|
||||
PlayerRepeat,
|
||||
} from 'types';
|
||||
|
||||
export interface WebSettings {
|
||||
player: PlayerSettings;
|
||||
}
|
||||
|
||||
export type PlayerSettings = PlayerState['settings'];
|
||||
|
||||
const DEFAULT_SETTINGS: WebSettings = {
|
||||
player: {
|
||||
crossfadeDuration: 5,
|
||||
crossfadeStyle: CrossfadeStyle.EqualPower,
|
||||
muted: false,
|
||||
repeat: PlayerRepeat.All,
|
||||
shuffle: false,
|
||||
style: PlaybackStyle.Gapless,
|
||||
type: isElectron() ? PlaybackType.Local : PlaybackType.Web,
|
||||
volume: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
export const useDefaultSettings = () => {
|
||||
const currentSettings = localStorage.getItem('settings');
|
||||
|
||||
if (currentSettings) {
|
||||
return JSON.parse(currentSettings);
|
||||
}
|
||||
|
||||
return localStorage.setItem('settings', JSON.stringify(DEFAULT_SETTINGS));
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './hooks/useDefaultSettings';
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface AnimatedPageProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const StyledAnimatedPage = styled(motion.div)`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const variants = {
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
initial: { opacity: 0 },
|
||||
};
|
||||
|
||||
export const AnimatedPage = ({ children }: AnimatedPageProps) => {
|
||||
return (
|
||||
<StyledAnimatedPage
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
transition={{ duration: 0.2, type: 'tween' }}
|
||||
variants={variants}
|
||||
>
|
||||
{children}
|
||||
</StyledAnimatedPage>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link, LinkProps } from 'react-router-dom';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { fontInter } from 'renderer/styles';
|
||||
|
||||
interface ListItemProps {
|
||||
children: ReactNode;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
const StyledListItem = styled(motion.div)`
|
||||
${fontInter(600)}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const ItemStyle = css`
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--sidebar-btn-color);
|
||||
transition: color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
color: var(--sidebar-btn-color-hover);
|
||||
}
|
||||
`;
|
||||
|
||||
const Box = styled.div`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
const ItemLink = styled(Link)<LinkProps>`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
export const ListItem = ({ icon, children, ...rest }: ListItemProps) => {
|
||||
return <StyledListItem {...rest}>{children}</StyledListItem>;
|
||||
};
|
||||
|
||||
ListItem.Box = Box;
|
||||
|
||||
ListItem.Link = ItemLink;
|
||||
|
||||
ListItem.defaultProps = {
|
||||
icon: <></>,
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import styled from 'styled-components';
|
||||
import { Home, Notebook, Search } from 'tabler-icons-react';
|
||||
import { UserMenu } from 'renderer/features/user-menu';
|
||||
import { AppRoute } from 'renderer/router/utils/routes';
|
||||
import { ListItem } from './ListItem';
|
||||
|
||||
const SidebarContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ItemGroup = styled.ul``;
|
||||
|
||||
const Item = styled.li``;
|
||||
|
||||
const StyledSidebar = styled.div``;
|
||||
|
||||
export const Sidebar = () => {
|
||||
|
||||
return (
|
||||
<StyledSidebar>
|
||||
<ListItem icon={<Home />}>
|
||||
<ListItem.Link to={AppRoute.HOME}>
|
||||
<Home />
|
||||
Home
|
||||
</ListItem.Link>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItem.Link to={AppRoute.SEARCH}>
|
||||
<Search />
|
||||
Search
|
||||
</ListItem.Link>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItem.Link to={AppRoute.LIBRARY}>
|
||||
<Notebook />
|
||||
Your Library
|
||||
</ListItem.Link>
|
||||
</ListItem>
|
||||
<UserMenu />
|
||||
</StyledSidebar>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './components/Sidebar';
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Group } from '@mantine/core';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { ChevronLeft, ChevronRight } from 'tabler-icons-react';
|
||||
import { IconButton } from 'renderer/components';
|
||||
import { WindowControls } from 'renderer/features/window-controls';
|
||||
|
||||
interface TitlebarProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const TitlebarContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
-webkit-app-region: drag;
|
||||
`;
|
||||
|
||||
const Left = styled.div`
|
||||
height: 100%;
|
||||
|
||||
button {
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
svg {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Right = styled.div`
|
||||
height: 100%;
|
||||
-webkit-app-region: no-drag;
|
||||
`;
|
||||
|
||||
export const Titlebar = ({ children }: TitlebarProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitlebarContainer>
|
||||
<Left>
|
||||
<Group spacing="xs">
|
||||
<IconButton
|
||||
icon={<ChevronLeft size={25} strokeWidth={1.5} />}
|
||||
size={25}
|
||||
onClick={() => navigate(-1)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<ChevronRight size={25} strokeWidth={1.5} />}
|
||||
size={25}
|
||||
onClick={() => navigate(1)}
|
||||
/>
|
||||
</Group>
|
||||
</Left>
|
||||
<Right>
|
||||
{children}
|
||||
|
||||
<WindowControls />
|
||||
</Right>
|
||||
</TitlebarContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Titlebar.defaultProps = {
|
||||
children: <></>,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Button, Menu } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Logout, Server, Settings } from 'tabler-icons-react';
|
||||
import { AddServerModal } from 'renderer/features/servers';
|
||||
import { useAuthStore } from 'renderer/store';
|
||||
|
||||
export const UserMenu = () => {
|
||||
const navigate = useNavigate();
|
||||
const [addServerModal, addServerHandlers] = useDisclosure(false);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
localStorage.removeItem('authentication');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
control={
|
||||
<Button radius="lg" size="xs" variant="default">
|
||||
User
|
||||
</Button>
|
||||
}
|
||||
position="bottom"
|
||||
size="md"
|
||||
>
|
||||
<Menu.Item
|
||||
icon={<Server size={20} />}
|
||||
onClick={() => addServerHandlers.open()}
|
||||
>
|
||||
Servers
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<Settings size={20} />}>Settings</Menu.Item>
|
||||
<Menu.Item icon={<Logout size={20} />} onClick={handleLogout}>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
<AddServerModal
|
||||
opened={addServerModal}
|
||||
onClose={() => addServerHandlers.close()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './components/Titlebar';
|
||||
@@ -0,0 +1,9 @@
|
||||
.root {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 150px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Button, Menu } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Logout, Server, Settings } from 'tabler-icons-react';
|
||||
import { useAuthStore } from 'renderer/store';
|
||||
import { AddServerModal } from '../servers/components/AddServerModal';
|
||||
import styles from './UserMenu.module.scss';
|
||||
|
||||
export const UserMenu = () => {
|
||||
const navigate = useNavigate();
|
||||
const [addServerModal, addServerHandlers] = useDisclosure(false);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
localStorage.removeItem('authentication');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
classNames={{ itemIcon: styles.icon, root: styles.root }}
|
||||
control={
|
||||
<Button radius="lg" size="xs" variant="default">
|
||||
User
|
||||
</Button>
|
||||
}
|
||||
position="bottom"
|
||||
size="md"
|
||||
>
|
||||
<Menu.Item
|
||||
icon={<Server size={20} />}
|
||||
onClick={() => addServerHandlers.open()}
|
||||
>
|
||||
Servers
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<Settings size={20} />}>Settings</Menu.Item>
|
||||
<Menu.Item icon={<Logout size={20} />} onClick={handleLogout}>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
<AddServerModal
|
||||
opened={addServerModal}
|
||||
onClose={() => addServerHandlers.close()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './UserMenu';
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import isElectron from 'is-electron';
|
||||
import styled from 'styled-components';
|
||||
import windowsClose from '../assets/close-w-10.png';
|
||||
import windowsMax from '../assets/max-w-10.png';
|
||||
import windowsMin from '../assets/min-w-10.png';
|
||||
|
||||
interface WindowControlsProps {
|
||||
style?: 'macos' | 'windows' | 'linux';
|
||||
}
|
||||
|
||||
const WindowControlsContainer = styled.div`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const WindowsButtonGroup = styled.div`
|
||||
display: flex;
|
||||
width: 130px;
|
||||
height: 100%;
|
||||
-webkit-app-region: no-drag;
|
||||
`;
|
||||
|
||||
const WindowsButton = styled.div`
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
&:hover {
|
||||
background: rgba(125, 125, 125, 30%);
|
||||
}
|
||||
`;
|
||||
|
||||
const close = () => window.electron.ipcRenderer.windowClose();
|
||||
|
||||
const minimize = () => window.electron.ipcRenderer.windowMinimize();
|
||||
|
||||
const maximize = () => window.electron.ipcRenderer.windowMaximize();
|
||||
|
||||
const unmaximize = () => window.electron.ipcRenderer.windowUnmaximize();
|
||||
|
||||
export const WindowControls = ({ style }: WindowControlsProps) => {
|
||||
const [max, setMax] = useState(false);
|
||||
|
||||
const handleMinimize = () => minimize();
|
||||
|
||||
const handleMaximize = () => {
|
||||
if (max) {
|
||||
unmaximize();
|
||||
} else {
|
||||
maximize();
|
||||
}
|
||||
setMax(!max);
|
||||
};
|
||||
|
||||
const handleClose = () => close();
|
||||
|
||||
return (
|
||||
<WindowControlsContainer>
|
||||
{isElectron() && (
|
||||
<>
|
||||
{style === 'windows' && (
|
||||
<>
|
||||
<WindowsButtonGroup>
|
||||
<WindowsButton role="button" onClick={handleMinimize}>
|
||||
<img alt="minimize" src={windowsMin} />
|
||||
</WindowsButton>
|
||||
<WindowsButton role="button" onClick={handleMaximize}>
|
||||
<img alt="maximize" src={windowsMax} />
|
||||
</WindowsButton>
|
||||
<WindowsButton role="button" onClick={handleClose}>
|
||||
<img alt="exit" src={windowsClose} />
|
||||
</WindowsButton>
|
||||
</WindowsButtonGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</WindowControlsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
WindowControls.defaultProps = {
|
||||
style: 'windows',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './components/WindowControls';
|
||||
Reference in New Issue
Block a user