mirror of
https://github.com/jeffvli/sonixd.git
synced 2026-04-29 18:52:38 -04:00
Add initial playerbar and sidebar
This commit is contained in:
159
src/renderer/components/audio-player/AudioPlayer.tsx
Normal file
159
src/renderer/components/audio-player/AudioPlayer.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
|
||||
import ReactPlayer, { ReactPlayerProps } from 'react-player';
|
||||
|
||||
import { Crossfade, PlayerStatus, Song } from '../../../types';
|
||||
import { crossfadeHandler, gaplessHandler } from './listenHandlers';
|
||||
|
||||
interface AudioPlayerProps extends ReactPlayerProps {
|
||||
status: PlayerStatus;
|
||||
currentPlayer: 1 | 2;
|
||||
player1: Song;
|
||||
player2: Song;
|
||||
volume: number;
|
||||
type: 'gapless' | 'crossfade';
|
||||
crossfadeType: Crossfade;
|
||||
crossfadeDuration: number;
|
||||
}
|
||||
|
||||
export type AudioPlayerProgress = {
|
||||
loaded: number;
|
||||
loadedSeconds: number;
|
||||
played: number;
|
||||
playedSeconds: number;
|
||||
};
|
||||
|
||||
const getDuration = (ref: any) => {
|
||||
return ref.current?.player?.player?.player?.duration;
|
||||
};
|
||||
|
||||
const AudioPlayer = (
|
||||
{
|
||||
status,
|
||||
type,
|
||||
crossfadeType,
|
||||
crossfadeDuration,
|
||||
currentPlayer,
|
||||
autoIncrement,
|
||||
player1,
|
||||
player2,
|
||||
muted,
|
||||
volume,
|
||||
}: AudioPlayerProps,
|
||||
ref: any
|
||||
) => {
|
||||
const player1Ref = useRef<any>(null);
|
||||
const player2Ref = useRef<any>(null);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
get player1() {
|
||||
return player1Ref?.current;
|
||||
},
|
||||
get player2() {
|
||||
return player2Ref?.current;
|
||||
},
|
||||
}));
|
||||
|
||||
const handleOnEnded = () => {
|
||||
autoIncrement();
|
||||
setIsTransitioning(false);
|
||||
};
|
||||
|
||||
const handleCrossfade1 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return crossfadeHandler({
|
||||
currentTime: e.playedSeconds,
|
||||
currentPlayerRef: player1Ref,
|
||||
nextPlayerRef: player2Ref,
|
||||
player: 1,
|
||||
currentPlayer,
|
||||
duration: getDuration(player1Ref),
|
||||
fadeDuration: crossfadeDuration,
|
||||
fadeType: crossfadeType,
|
||||
volume,
|
||||
isTransitioning,
|
||||
setIsTransitioning,
|
||||
});
|
||||
},
|
||||
[crossfadeDuration, crossfadeType, currentPlayer, isTransitioning, volume]
|
||||
);
|
||||
|
||||
const handleCrossfade2 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return crossfadeHandler({
|
||||
currentTime: e.playedSeconds,
|
||||
currentPlayerRef: player2Ref,
|
||||
nextPlayerRef: player1Ref,
|
||||
player: 2,
|
||||
currentPlayer,
|
||||
duration: getDuration(player2Ref),
|
||||
fadeDuration: crossfadeDuration,
|
||||
fadeType: crossfadeType,
|
||||
volume,
|
||||
isTransitioning,
|
||||
setIsTransitioning,
|
||||
});
|
||||
},
|
||||
[crossfadeDuration, crossfadeType, currentPlayer, isTransitioning, volume]
|
||||
);
|
||||
|
||||
const handleGapless1 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return gaplessHandler({
|
||||
currentTime: e.playedSeconds,
|
||||
nextPlayerRef: player2Ref,
|
||||
duration: getDuration(player1Ref),
|
||||
isTransitioning,
|
||||
setIsTransitioning,
|
||||
});
|
||||
},
|
||||
[isTransitioning]
|
||||
);
|
||||
|
||||
const handleGapless2 = useCallback(
|
||||
(e: AudioPlayerProgress) => {
|
||||
return gaplessHandler({
|
||||
currentTime: e.playedSeconds,
|
||||
nextPlayerRef: player1Ref,
|
||||
duration: getDuration(player2Ref),
|
||||
isTransitioning,
|
||||
setIsTransitioning,
|
||||
});
|
||||
},
|
||||
[isTransitioning]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactPlayer
|
||||
ref={player1Ref}
|
||||
volume={volume}
|
||||
playing={currentPlayer === 1 && status === PlayerStatus.Playing}
|
||||
url={player1?.streamUrl}
|
||||
muted={muted}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
onProgress={type === 'gapless' ? handleGapless1 : handleCrossfade1}
|
||||
onEnded={handleOnEnded}
|
||||
/>
|
||||
<ReactPlayer
|
||||
ref={player2Ref}
|
||||
volume={volume}
|
||||
playing={currentPlayer === 2 && status === PlayerStatus.Playing}
|
||||
url={player2?.streamUrl}
|
||||
muted={muted}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
onProgress={type === 'gapless' ? handleGapless2 : handleCrossfade2}
|
||||
onEnded={handleOnEnded}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(AudioPlayer);
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Dispatch, useCallback } from 'react';
|
||||
|
||||
import { useAppDispatch } from 'renderer/hooks/redux';
|
||||
import { next, pause, play, prev } from 'renderer/store/playerSlice';
|
||||
|
||||
import { PlayerStatus, Song } from '../../../../types';
|
||||
|
||||
const useMainAudioControls = (args: {
|
||||
playersRef: any;
|
||||
playerStatus: PlayerStatus;
|
||||
queue: Song[];
|
||||
currentPlayer: 1 | 2;
|
||||
setCurrentTime: Dispatch<number>;
|
||||
}) => {
|
||||
const { playersRef, playerStatus, queue, currentPlayer, setCurrentTime } =
|
||||
args;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
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 = useCallback(() => {
|
||||
player1Ref.getInternalPlayer().pause();
|
||||
player2Ref.getInternalPlayer().pause();
|
||||
resetPlayers();
|
||||
}, [player1Ref, player2Ref, resetPlayers]);
|
||||
|
||||
const handlePlay = useCallback(() => {
|
||||
dispatch(play());
|
||||
}, [dispatch]);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
dispatch(pause());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
dispatch(pause());
|
||||
stopPlayback();
|
||||
setCurrentTime(0);
|
||||
}, [dispatch, setCurrentTime, stopPlayback]);
|
||||
|
||||
const handleNextTrack = useCallback(() => {
|
||||
resetPlayers();
|
||||
dispatch(next());
|
||||
}, [dispatch, resetPlayers]);
|
||||
|
||||
const handlePrevTrack = useCallback(() => {
|
||||
resetPlayers();
|
||||
dispatch(prev());
|
||||
}, [dispatch, resetPlayers]);
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (queue) {
|
||||
if (playerStatus === PlayerStatus.Paused) {
|
||||
return handlePlay();
|
||||
}
|
||||
|
||||
return handlePause();
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [handlePause, handlePlay, playerStatus, queue]);
|
||||
|
||||
const handleSkipBackward = useCallback(() => {
|
||||
const skipBackwardSec = 5;
|
||||
const newTime = currentPlayerRef.getCurrentTime() - skipBackwardSec;
|
||||
|
||||
resetNextPlayer();
|
||||
setCurrentTime(newTime);
|
||||
currentPlayerRef.seekTo(newTime);
|
||||
}, [currentPlayerRef, resetNextPlayer, setCurrentTime]);
|
||||
|
||||
const handleSkipForward = useCallback(() => {
|
||||
const skipForwardSec = 5;
|
||||
const checkNewTime = currentPlayerRef.getCurrentTime() + skipForwardSec;
|
||||
|
||||
const songDuration = currentPlayerRef.player.player.duration;
|
||||
|
||||
const newTime =
|
||||
checkNewTime >= songDuration ? songDuration - 1 : checkNewTime;
|
||||
|
||||
resetNextPlayer();
|
||||
setCurrentTime(newTime);
|
||||
currentPlayerRef.seekTo(newTime);
|
||||
}, [currentPlayerRef, resetNextPlayer, setCurrentTime]);
|
||||
|
||||
const handleSeekSlider = useCallback(
|
||||
(e: number | any) => {
|
||||
setCurrentTime(e);
|
||||
currentPlayerRef.seekTo(e);
|
||||
},
|
||||
[currentPlayerRef, setCurrentTime]
|
||||
);
|
||||
|
||||
// useEffect(() => {
|
||||
// ipcRenderer.on('player-next-track', () => {
|
||||
// handleNextTrack();
|
||||
// });
|
||||
|
||||
// ipcRenderer.on('player-prev-track', () => {
|
||||
// handlePrevTrack();
|
||||
// });
|
||||
|
||||
// ipcRenderer.on('player-play-pause', () => {
|
||||
// handlePlayPause();
|
||||
// });
|
||||
|
||||
// ipcRenderer.on('player-play', () => {
|
||||
// handlePlay();
|
||||
// });
|
||||
|
||||
// ipcRenderer.on('player-pause', () => {
|
||||
// handlePause();
|
||||
// });
|
||||
|
||||
// ipcRenderer.on('player-stop', () => {
|
||||
// handleStop();
|
||||
// });
|
||||
|
||||
// ipcRenderer.on('player-shuffle', () => {
|
||||
// handleShuffle();
|
||||
// });
|
||||
|
||||
// ipcRenderer.on('player-repeat', () => {
|
||||
// handleRepeat();
|
||||
// });
|
||||
|
||||
// ipcRenderer.on('save-queue-state', (_event, path: string) => {
|
||||
// handleSaveQueue(path);
|
||||
// });
|
||||
|
||||
// ipcRenderer.on('restore-queue-state', (_event, path: string) => {
|
||||
// handleRestoreQueue(path);
|
||||
// });
|
||||
|
||||
// return () => {
|
||||
// ipcRenderer.removeAllListeners('player-next-track');
|
||||
// ipcRenderer.removeAllListeners('player-prev-track');
|
||||
// ipcRenderer.removeAllListeners('player-play-pause');
|
||||
// ipcRenderer.removeAllListeners('player-play');
|
||||
// ipcRenderer.removeAllListeners('player-pause');
|
||||
// ipcRenderer.removeAllListeners('player-stop');
|
||||
// ipcRenderer.removeAllListeners('player-shuffle');
|
||||
// ipcRenderer.removeAllListeners('player-repeat');
|
||||
// ipcRenderer.removeAllListeners('save-queue-state');
|
||||
// ipcRenderer.removeAllListeners('restore-queue-state');
|
||||
// };
|
||||
// }, [
|
||||
// handleNextTrack,
|
||||
// handlePause,
|
||||
// handlePlay,
|
||||
// handlePlayPause,
|
||||
// handlePrevTrack,
|
||||
// handleRepeat,
|
||||
// handleShuffle,
|
||||
// handleStop,
|
||||
// handleSaveQueue,
|
||||
// handleRestoreQueue,
|
||||
// ]);
|
||||
|
||||
// useEffect(() => {
|
||||
// ipcRenderer.on('current-position-request', (_event, arg) => {
|
||||
// if (arg.currentPlayer === 1) {
|
||||
// ipcRenderer.send(
|
||||
// 'seeked',
|
||||
// Math.floor(
|
||||
// playersRef.current.player1.audioEl.current.currentTime * 1000000
|
||||
// )
|
||||
// );
|
||||
// } else {
|
||||
// ipcRenderer.send(
|
||||
// 'seeked',
|
||||
// Math.floor(
|
||||
// playersRef.current.player2.audioEl.current.currentTime * 1000000
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
// });
|
||||
|
||||
// ipcRenderer.on('position-request', (_event, arg) => {
|
||||
// const newPosition = Math.floor(arg.position / 1000000);
|
||||
|
||||
// if (arg.currentPlayer === 1) {
|
||||
// playersRef.current.player1.audioEl.current.currentTime = newPosition;
|
||||
// } else {
|
||||
// playersRef.current.player2.audioEl.current.currentTime = newPosition;
|
||||
// }
|
||||
|
||||
// ipcRenderer.send('seeked', arg.position);
|
||||
// });
|
||||
|
||||
// ipcRenderer.on('seek-request', (_event, arg) => {
|
||||
// let newPosition;
|
||||
// if (arg.currentPlayer === 1) {
|
||||
// newPosition =
|
||||
// playersRef.current.player1.audioEl.current.currentTime +
|
||||
// arg.offset / 1000000;
|
||||
// setCurrentTime(newPosition);
|
||||
// ipcRenderer.send('seeked', newPosition * 1000000);
|
||||
// } else {
|
||||
// newPosition =
|
||||
// playersRef.current.player2.audioEl.current.currentTime +
|
||||
// arg.offset / 1000000;
|
||||
// setCurrentTime(newPosition);
|
||||
// ipcRenderer.send('seeked', newPosition * 1000000);
|
||||
// }
|
||||
// });
|
||||
|
||||
// return () => {
|
||||
// ipcRenderer.removeAllListeners('current-position-request');
|
||||
// ipcRenderer.removeAllListeners('position-request');
|
||||
// ipcRenderer.removeAllListeners('seek-request');
|
||||
// };
|
||||
// }, [playersRef, setCurrentTime]);
|
||||
|
||||
return {
|
||||
handleNextTrack,
|
||||
handlePrevTrack,
|
||||
handlePlayPause,
|
||||
handleSkipForward,
|
||||
handleSkipBackward,
|
||||
handleSeekSlider,
|
||||
handleStop,
|
||||
};
|
||||
};
|
||||
|
||||
export default useMainAudioControls;
|
||||
146
src/renderer/components/audio-player/listenHandlers.ts
Normal file
146
src/renderer/components/audio-player/listenHandlers.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { Dispatch } from 'react';
|
||||
|
||||
import { Crossfade } from '../../../types';
|
||||
|
||||
export const gaplessHandler = (args: {
|
||||
nextPlayerRef: any;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isTransitioning: boolean;
|
||||
setIsTransitioning: Dispatch<boolean>;
|
||||
}) => {
|
||||
const {
|
||||
nextPlayerRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isTransitioning,
|
||||
setIsTransitioning,
|
||||
} = args;
|
||||
|
||||
if (!isTransitioning) {
|
||||
if (currentTime > duration - 2) {
|
||||
return setIsTransitioning(true);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const durationPadding = 0.13;
|
||||
if (currentTime + durationPadding >= duration) {
|
||||
return nextPlayerRef.current.getInternalPlayer().play();
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const crossfadeHandler = (args: {
|
||||
currentTime: number;
|
||||
currentPlayerRef: any;
|
||||
nextPlayerRef: any;
|
||||
volume: number;
|
||||
player: 1 | 2;
|
||||
currentPlayer: 1 | 2;
|
||||
fadeDuration: number;
|
||||
fadeType: Crossfade;
|
||||
duration: number;
|
||||
isTransitioning: boolean;
|
||||
setIsTransitioning: Dispatch<boolean>;
|
||||
}) => {
|
||||
const {
|
||||
currentTime,
|
||||
player,
|
||||
currentPlayer,
|
||||
currentPlayerRef,
|
||||
nextPlayerRef,
|
||||
fadeDuration,
|
||||
fadeType,
|
||||
duration,
|
||||
volume,
|
||||
isTransitioning,
|
||||
setIsTransitioning,
|
||||
} = args;
|
||||
|
||||
if (!isTransitioning || currentPlayer !== player) {
|
||||
const shouldBeginTransition = currentTime >= duration - fadeDuration;
|
||||
|
||||
if (shouldBeginTransition) {
|
||||
setIsTransitioning(true);
|
||||
return nextPlayerRef.current.getInternalPlayer().play();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeLeft = duration - currentTime;
|
||||
let currentPlayerVolumeCalculation;
|
||||
let nextPlayerVolumeCalculation;
|
||||
let percentageOfFadeLeft;
|
||||
let n;
|
||||
switch (fadeType) {
|
||||
case 'equalPower':
|
||||
// https://dsp.stackexchange.com/a/14755
|
||||
percentageOfFadeLeft = (timeLeft / fadeDuration) * 2;
|
||||
currentPlayerVolumeCalculation =
|
||||
Math.sqrt(0.5 * percentageOfFadeLeft) * volume;
|
||||
nextPlayerVolumeCalculation =
|
||||
Math.sqrt(0.5 * (2 - percentageOfFadeLeft)) * volume;
|
||||
break;
|
||||
case 'linear':
|
||||
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
|
||||
nextPlayerVolumeCalculation =
|
||||
((fadeDuration - timeLeft) / fadeDuration) * volume;
|
||||
break;
|
||||
case 'dipped':
|
||||
// https://math.stackexchange.com/a/4622
|
||||
percentageOfFadeLeft = timeLeft / fadeDuration;
|
||||
currentPlayerVolumeCalculation = percentageOfFadeLeft ** 2 * volume;
|
||||
nextPlayerVolumeCalculation = (percentageOfFadeLeft - 1) ** 2 * volume;
|
||||
break;
|
||||
case fadeType.match(/constantPower.*/)?.input:
|
||||
// https://math.stackexchange.com/a/26159
|
||||
n =
|
||||
fadeType === 'constantPower'
|
||||
? 0
|
||||
: fadeType === 'constantPowerSlowFade'
|
||||
? 1
|
||||
: fadeType === 'constantPowerSlowCut'
|
||||
? 3
|
||||
: 10;
|
||||
|
||||
percentageOfFadeLeft = timeLeft / fadeDuration;
|
||||
currentPlayerVolumeCalculation =
|
||||
Math.cos(
|
||||
(Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) - 1)
|
||||
) * volume;
|
||||
nextPlayerVolumeCalculation =
|
||||
Math.cos(
|
||||
(Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) + 1)
|
||||
) * volume;
|
||||
break;
|
||||
|
||||
default:
|
||||
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
|
||||
nextPlayerVolumeCalculation =
|
||||
((fadeDuration - timeLeft) / fadeDuration) * volume;
|
||||
break;
|
||||
}
|
||||
|
||||
const currentPlayerVolume =
|
||||
currentPlayerVolumeCalculation >= 0 ? currentPlayerVolumeCalculation : 0;
|
||||
|
||||
const nextPlayerVolume =
|
||||
nextPlayerVolumeCalculation <= volume
|
||||
? nextPlayerVolumeCalculation
|
||||
: volume;
|
||||
|
||||
if (currentPlayer === 1) {
|
||||
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
|
||||
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
|
||||
} else {
|
||||
currentPlayerRef.current.getInternalPlayer().volume = currentPlayerVolume;
|
||||
nextPlayerRef.current.getInternalPlayer().volume = nextPlayerVolume;
|
||||
}
|
||||
// }
|
||||
|
||||
return null;
|
||||
};
|
||||
35
src/renderer/components/icon-button/IconButton.tsx
Normal file
35
src/renderer/components/icon-button/IconButton.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { ActionIcon, ActionIconProps, Tooltip } from '@mantine/core';
|
||||
|
||||
interface IconButtonProps extends ActionIconProps<'button'> {
|
||||
tooltip?: { label: string };
|
||||
icon: ReactNode;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const IconButton = ({ active, tooltip, icon, ...rest }: IconButtonProps) => {
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip {...tooltip}>
|
||||
<ActionIcon
|
||||
sx={(theme) => ({
|
||||
color: active ? theme.colors.primary[5] : theme.colors.white,
|
||||
})}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return <ActionIcon {...rest}>{icon}</ActionIcon>;
|
||||
};
|
||||
|
||||
IconButton.defaultProps = {
|
||||
tooltip: undefined,
|
||||
active: false,
|
||||
};
|
||||
|
||||
export default IconButton;
|
||||
132
src/renderer/components/slider/Slider.tsx
Normal file
132
src/renderer/components/slider/Slider.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
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 {
|
||||
toolTipType?: 'text' | 'time';
|
||||
hasToolTip?: boolean;
|
||||
}
|
||||
|
||||
const StyledSlider = styled<any>(ReactSlider)`
|
||||
width: 100%;
|
||||
height: 25px;
|
||||
outline: none;
|
||||
|
||||
.thumb {
|
||||
opacity: 1;
|
||||
top: 37%;
|
||||
|
||||
&:after {
|
||||
content: attr(data-tooltip);
|
||||
top: -25px;
|
||||
left: -18px;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
position: absolute;
|
||||
display: ${(props) =>
|
||||
props.isDragging && props.hasToolTip ? 'block' : 'none'};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
height: 13px;
|
||||
width: 13px;
|
||||
border: 1px #000 solid;
|
||||
border-radius: 100%;
|
||||
text-align: center;
|
||||
background-color: #ffffff;
|
||||
transform: translate(-12px, -4px);
|
||||
}
|
||||
}
|
||||
|
||||
.track-0 {
|
||||
background: ${(props) => props.isDragging && '#3a81ed'};
|
||||
}
|
||||
|
||||
.track {
|
||||
top: 37%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.track-0 {
|
||||
background: ${(props) => (props.index === 1 ? '#2f3136' : '#3a81ed')};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTrack = styled.div<any>`
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: 5px;
|
||||
background: ${(props) => (props.index === 1 ? '#1a1b1e' : '#36393f')};
|
||||
`;
|
||||
|
||||
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} />;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
const Track = (props: any, state: any) => {
|
||||
const { index } = state;
|
||||
|
||||
return <StyledTrack {...props} index={index} />;
|
||||
};
|
||||
const Thumb = (props: any, state: any, toolTipType: any) => (
|
||||
<MemoizedThumb
|
||||
key="slider"
|
||||
tabIndex={0}
|
||||
props={props}
|
||||
state={state}
|
||||
toolTipType={toolTipType}
|
||||
/>
|
||||
);
|
||||
|
||||
const Slider = ({ toolTipType, hasToolTip, ...rest }: SliderProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
return (
|
||||
<StyledSlider
|
||||
{...rest}
|
||||
defaultValue={0}
|
||||
renderTrack={Track}
|
||||
renderThumb={(props: any, state: any) => {
|
||||
return Thumb(props, state, toolTipType);
|
||||
}}
|
||||
isDragging={isDragging}
|
||||
hasToolTip={hasToolTip}
|
||||
onBeforeChange={(e: number, index: number) => {
|
||||
if (rest.onBeforeChange) {
|
||||
rest.onBeforeChange(e, index);
|
||||
}
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onAfterChange={(e: number, index: number) => {
|
||||
if (rest.onAfterChange) {
|
||||
rest.onAfterChange(e, index);
|
||||
}
|
||||
setIsDragging(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Slider.defaultProps = {
|
||||
toolTipType: 'text',
|
||||
hasToolTip: true,
|
||||
};
|
||||
|
||||
export default Slider;
|
||||
88
src/renderer/features/player-bar/PlayerBar.tsx
Normal file
88
src/renderer/features/player-bar/PlayerBar.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { Container, createStyles, Grid } from '@mantine/core';
|
||||
import * as Space from 'react-spaces';
|
||||
|
||||
import AudioPlayer from 'renderer/components/audio-player/AudioPlayer';
|
||||
import { useAppDispatch, useAppSelector } from 'renderer/hooks/redux';
|
||||
import {
|
||||
selectCurrentPlayer,
|
||||
selectPlayerStatus,
|
||||
autoIncrement,
|
||||
selectPlayer2Song,
|
||||
selectPlayer1Song,
|
||||
selectPlayerConfig,
|
||||
} from 'renderer/store/playerSlice';
|
||||
|
||||
import CenterControls from './components/CenterControls';
|
||||
import LeftControls from './components/LeftControls';
|
||||
import RightControls from './components/RightControls';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
wrapper: {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.layout[1]
|
||||
: theme.colors.layout[1],
|
||||
borderTop: '1px #323232 solid',
|
||||
height: '100%',
|
||||
color: '#d8d8d8',
|
||||
},
|
||||
grid: {
|
||||
margin: '0',
|
||||
height: '100%',
|
||||
},
|
||||
}));
|
||||
|
||||
const PlayerBar = () => {
|
||||
const { classes } = useStyles();
|
||||
const dispatch = useAppDispatch();
|
||||
const playersRef = useRef<any>();
|
||||
const playerStatus = useAppSelector(selectPlayerStatus);
|
||||
const player1Song = useAppSelector(selectPlayer1Song);
|
||||
const player2Song = useAppSelector(selectPlayer2Song);
|
||||
const currentPlayer = useAppSelector(selectCurrentPlayer);
|
||||
const { muted, volume, type, crossfadeType, crossfadeDuration } =
|
||||
useAppSelector(selectPlayerConfig);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AudioPlayer
|
||||
ref={playersRef}
|
||||
status={playerStatus}
|
||||
currentPlayer={currentPlayer}
|
||||
autoIncrement={() => dispatch(autoIncrement())}
|
||||
player1={player1Song}
|
||||
player2={player2Song}
|
||||
volume={volume}
|
||||
muted={muted}
|
||||
type={type}
|
||||
crossfadeType={crossfadeType}
|
||||
crossfadeDuration={crossfadeDuration}
|
||||
/>
|
||||
<Space.Bottom size={100}>
|
||||
<Container className={classes.wrapper} px="xs" fluid>
|
||||
<Grid className={classes.grid} gutter="xs">
|
||||
<Grid.Col span={3}>
|
||||
<LeftControls
|
||||
song={currentPlayer === 1 ? player1Song : player2Song}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<CenterControls
|
||||
playersRef={playersRef}
|
||||
currentPlayer={currentPlayer}
|
||||
status={playerStatus}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={3}>
|
||||
<RightControls />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Space.Bottom>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlayerBar;
|
||||
191
src/renderer/features/player-bar/components/CenterControls.tsx
Normal file
191
src/renderer/features/player-bar/components/CenterControls.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Box, createStyles, Grid, Text } from '@mantine/core';
|
||||
import format from 'format-duration';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
PlayerPause,
|
||||
PlayerPlay,
|
||||
PlayerSkipBack,
|
||||
PlayerSkipForward,
|
||||
PlayerStop,
|
||||
PlayerTrackNext,
|
||||
PlayerTrackPrev,
|
||||
} from 'tabler-icons-react';
|
||||
|
||||
import useMainAudioControls from 'renderer/components/audio-player/hooks/useMainAudioControls';
|
||||
import IconButton from 'renderer/components/icon-button/IconButton';
|
||||
import Slider from 'renderer/components/slider/Slider';
|
||||
import { useAppSelector } from 'renderer/hooks/redux';
|
||||
import { selectCurrentQueue } from 'renderer/store/playerSlice';
|
||||
import { PlayerStatus } from 'types';
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
slider: {
|
||||
width: '100%',
|
||||
height: '40%',
|
||||
alignContent: 'flex-start',
|
||||
},
|
||||
controls: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '60%',
|
||||
},
|
||||
time: {
|
||||
userSelect: 'none',
|
||||
textAlign: 'center',
|
||||
padding: '.5em',
|
||||
},
|
||||
left: {
|
||||
textAlign: 'right',
|
||||
},
|
||||
right: {
|
||||
textAlign: 'left',
|
||||
},
|
||||
}));
|
||||
|
||||
interface CenterControlsProps {
|
||||
status: PlayerStatus;
|
||||
playersRef: any;
|
||||
currentPlayer: 1 | 2;
|
||||
}
|
||||
|
||||
const CenterControls = ({
|
||||
status,
|
||||
playersRef,
|
||||
currentPlayer,
|
||||
}: CenterControlsProps) => {
|
||||
const { classes, cx } = useStyles();
|
||||
const { t } = useTranslation();
|
||||
const queue = useAppSelector(selectCurrentQueue);
|
||||
const player1 = playersRef?.current?.player1?.player;
|
||||
const player2 = playersRef?.current?.player2?.player;
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
|
||||
const {
|
||||
handlePlayPause,
|
||||
handleSkipBackward,
|
||||
handleSkipForward,
|
||||
handleSeekSlider,
|
||||
handleNextTrack,
|
||||
handlePrevTrack,
|
||||
handleStop,
|
||||
} = useMainAudioControls({
|
||||
playersRef,
|
||||
playerStatus: status,
|
||||
queue,
|
||||
currentPlayer,
|
||||
setCurrentTime,
|
||||
});
|
||||
|
||||
const currentPlayerRef = currentPlayer === 1 ? player1 : player2;
|
||||
|
||||
const duration = useMemo(
|
||||
() => format(currentPlayerRef?.player?.player.duration * 1000 || 0),
|
||||
[currentPlayerRef?.player?.player.duration]
|
||||
);
|
||||
|
||||
const formattedTime = useMemo(
|
||||
() => format(currentTime * 1000 || 0),
|
||||
[currentTime]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: any;
|
||||
|
||||
if (status === PlayerStatus.Playing && !isSeeking) {
|
||||
interval = setInterval(() => {
|
||||
setCurrentTime(currentPlayerRef.getCurrentTime());
|
||||
}, 500);
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box className={classes.controls}>
|
||||
<IconButton
|
||||
size={40}
|
||||
variant="transparent"
|
||||
tooltip={{ label: `${t('player.stop')}` }}
|
||||
icon={<PlayerStop size={15} strokeWidth={1.5} />}
|
||||
onClick={handleStop}
|
||||
/>
|
||||
<IconButton
|
||||
size={40}
|
||||
variant="transparent"
|
||||
tooltip={{ label: `${t('player.prev')}` }}
|
||||
icon={<PlayerSkipBack size={15} strokeWidth={1.5} />}
|
||||
onClick={handlePrevTrack}
|
||||
/>
|
||||
<IconButton
|
||||
size={40}
|
||||
variant="transparent"
|
||||
tooltip={{ label: `${t('player.skipBack')}` }}
|
||||
icon={<PlayerTrackPrev size={15} strokeWidth={1.5} />}
|
||||
onClick={handleSkipBackward}
|
||||
/>
|
||||
<IconButton
|
||||
size={40}
|
||||
variant="transparent"
|
||||
radius="xl"
|
||||
tooltip={{
|
||||
label:
|
||||
status === PlayerStatus.Paused
|
||||
? `${t('player.play')}`
|
||||
: `${t('player.pause')}`,
|
||||
}}
|
||||
icon={
|
||||
status === PlayerStatus.Paused ? (
|
||||
<PlayerPlay size={20} strokeWidth={1.5} />
|
||||
) : (
|
||||
<PlayerPause size={20} strokeWidth={1.5} />
|
||||
)
|
||||
}
|
||||
onClick={handlePlayPause}
|
||||
/>
|
||||
<IconButton
|
||||
size={40}
|
||||
variant="transparent"
|
||||
tooltip={{ label: `${t('player.skipForward')}` }}
|
||||
icon={<PlayerTrackNext size={15} strokeWidth={1.5} />}
|
||||
onClick={handleSkipForward}
|
||||
/>
|
||||
<IconButton
|
||||
size={40}
|
||||
variant="transparent"
|
||||
tooltip={{ label: `${t('player.next')}` }}
|
||||
icon={<PlayerSkipForward size={15} strokeWidth={1.5} />}
|
||||
onClick={handleNextTrack}
|
||||
/>
|
||||
</Box>
|
||||
<Grid className={classes.slider} align="center" gutter="xs">
|
||||
<Grid.Col className={cx(classes.time, classes.left)} span={1}>
|
||||
<Text size="xs">{formattedTime}</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={10}>
|
||||
<Slider
|
||||
value={currentTime}
|
||||
min={0}
|
||||
max={currentPlayerRef?.player.player.duration}
|
||||
onAfterChange={(e) => {
|
||||
handleSeekSlider(e);
|
||||
setIsSeeking(false);
|
||||
}}
|
||||
toolTipType="time"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col className={cx(classes.time, classes.right)} span={1}>
|
||||
<Text size="xs">{duration}</Text>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CenterControls;
|
||||
72
src/renderer/features/player-bar/components/LeftControls.tsx
Normal file
72
src/renderer/features/player-bar/components/LeftControls.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Box, createStyles, Stack, Text } from '@mantine/core';
|
||||
import { LazyLoadImage as Image } from 'react-lazy-load-image-component';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Song } from 'types';
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
wrapper: {
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
},
|
||||
image: {
|
||||
minWidth: '100px',
|
||||
width: '100px',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
stack: {
|
||||
margin: '10px 0',
|
||||
width: '100%',
|
||||
},
|
||||
info: {
|
||||
height: '100%',
|
||||
width: 'calc(100% - 100px)',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
grid: {
|
||||
margin: '0',
|
||||
height: '100%',
|
||||
},
|
||||
}));
|
||||
|
||||
interface LeftControlsProps {
|
||||
song: Song;
|
||||
}
|
||||
|
||||
const LeftControls = ({ song }: LeftControlsProps) => {
|
||||
const { classes } = useStyles();
|
||||
|
||||
return (
|
||||
<Box className={classes.wrapper}>
|
||||
<Box className={classes.image}>
|
||||
<Image src={song?.image} height={80} width={80} />
|
||||
</Box>
|
||||
<Stack className={classes.stack} align="stretch" spacing="xs">
|
||||
<Box className={classes.info}>
|
||||
<Text<typeof Link> component={Link} to="/nowplaying">
|
||||
{song?.title || 'N/a'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box className={classes.info}>
|
||||
<Text<typeof Link> component={Link} to="/nowplaying">
|
||||
{song?.artist?.map((artist) => artist?.title) || 'N/a'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box className={classes.info}>
|
||||
<Text<typeof Link> component={Link} to="/nowplaying">
|
||||
{song?.album || 'N/a'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeftControls;
|
||||
180
src/renderer/features/player-bar/components/RightControls.tsx
Normal file
180
src/renderer/features/player-bar/components/RightControls.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
createStyles,
|
||||
Group,
|
||||
NumberInput,
|
||||
Popover,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Select,
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useThrottleFn } from 'react-use';
|
||||
import {
|
||||
Adjustments,
|
||||
ArrowsShuffle,
|
||||
Playlist,
|
||||
Repeat,
|
||||
RepeatOnce,
|
||||
Volume2,
|
||||
Volume3,
|
||||
} from 'tabler-icons-react';
|
||||
|
||||
import IconButton from 'renderer/components/icon-button/IconButton';
|
||||
import Slider from 'renderer/components/slider/Slider';
|
||||
import { useAppDispatch, useAppSelector } from 'renderer/hooks/redux';
|
||||
import {
|
||||
selectPlayerConfig,
|
||||
setCrossfadeDuration,
|
||||
setType,
|
||||
setVolume as setGlobalVolume,
|
||||
toggleMute,
|
||||
toggleRepeat,
|
||||
toggleShuffle,
|
||||
} from 'renderer/store/playerSlice';
|
||||
import { PlayerRepeat } from 'types';
|
||||
|
||||
const CROSSFADE_TYPES = [
|
||||
{ label: 'Equal Power', value: 'equalPower' },
|
||||
{ label: 'Linear', value: 'linear' },
|
||||
{ label: 'Dipped', value: 'dipped' },
|
||||
{ label: 'Constant Power', value: 'constantPower' },
|
||||
{ label: 'Constant Power (Slow fade)', value: 'constantPowerSlowFade' },
|
||||
{ label: 'Constant Power (Slow cut)', value: 'constantPowerSlowCut' },
|
||||
];
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
wrapper: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
},
|
||||
box: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
height: 'calc(100% / 3)',
|
||||
},
|
||||
volumeSlider: {
|
||||
maxWidth: '7em',
|
||||
},
|
||||
}));
|
||||
|
||||
const RightControls = () => {
|
||||
const { t } = useTranslation();
|
||||
const { classes } = useStyles();
|
||||
const dispatch = useAppDispatch();
|
||||
const {
|
||||
muted,
|
||||
volume,
|
||||
shuffle,
|
||||
repeat,
|
||||
crossfadeType,
|
||||
crossfadeDuration,
|
||||
type,
|
||||
} = useAppSelector(selectPlayerConfig);
|
||||
const [localVolume, setLocalVolume] = useState(volume * 100);
|
||||
useThrottleFn((v) => dispatch(setGlobalVolume(v)), 200, [localVolume]);
|
||||
const [openConfig, setOpenConfig] = useState(false);
|
||||
|
||||
return (
|
||||
<Container className={classes.wrapper}>
|
||||
<Box className={classes.box} />
|
||||
<Box className={classes.box}>
|
||||
<Group position="right" spacing="sm">
|
||||
<Popover
|
||||
opened={openConfig}
|
||||
onClose={() => setOpenConfig(false)}
|
||||
position="top"
|
||||
withArrow
|
||||
target={
|
||||
<IconButton
|
||||
variant="transparent"
|
||||
tooltip={{ label: `${t('player.config')}` }}
|
||||
icon={<Adjustments size={20} />}
|
||||
onClick={() => setOpenConfig(!openConfig)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack>
|
||||
<RadioGroup
|
||||
label={`${t('player.config.playbackType')}`}
|
||||
orientation="vertical"
|
||||
value={type}
|
||||
spacing="md"
|
||||
onChange={(e: 'gapless' | 'crossfade') => dispatch(setType(e))}
|
||||
>
|
||||
<Radio value="gapless" label={`${t('player.gapless')}`} />
|
||||
<Radio value="crossfade" label={`${t('player.crossfade')}`} />
|
||||
</RadioGroup>
|
||||
<Select
|
||||
defaultValue={crossfadeType}
|
||||
label={`${t('player.config.crossfadeType')}`}
|
||||
data={CROSSFADE_TYPES}
|
||||
disabled={type !== 'crossfade'}
|
||||
/>
|
||||
<NumberInput
|
||||
defaultValue={crossfadeDuration}
|
||||
label={`${t('player.config.crossfadeDuration')}`}
|
||||
min={1}
|
||||
max={12}
|
||||
onBlur={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
dispatch(setCrossfadeDuration(Number(e.currentTarget.value)))
|
||||
}
|
||||
disabled={type !== 'crossfade'}
|
||||
/>
|
||||
</Stack>
|
||||
</Popover>
|
||||
<IconButton
|
||||
active={repeat !== PlayerRepeat.None}
|
||||
variant="transparent"
|
||||
tooltip={{ label: `${t('player.repeat')}` }}
|
||||
icon={
|
||||
repeat === PlayerRepeat.One ? (
|
||||
<RepeatOnce size={20} />
|
||||
) : (
|
||||
<Repeat size={20} />
|
||||
)
|
||||
}
|
||||
onClick={() => dispatch(toggleRepeat())}
|
||||
/>
|
||||
<IconButton
|
||||
active={shuffle}
|
||||
variant="transparent"
|
||||
tooltip={{ label: `${t('player.shuffle')}` }}
|
||||
icon={<ArrowsShuffle size={20} />}
|
||||
onClick={() => dispatch(toggleShuffle())}
|
||||
/>
|
||||
<IconButton
|
||||
variant="transparent"
|
||||
tooltip={{ label: `${t('player.queue')}` }}
|
||||
icon={<Playlist size={20} />}
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
<Box className={classes.box}>
|
||||
<IconButton
|
||||
variant="transparent"
|
||||
tooltip={{
|
||||
label: muted ? `${t('player.muted')}` : String(localVolume),
|
||||
}}
|
||||
icon={muted ? <Volume3 size={15} /> : <Volume2 size={15} />}
|
||||
onClick={() => dispatch(toggleMute())}
|
||||
/>
|
||||
<Slider
|
||||
className={classes.volumeSlider}
|
||||
value={localVolume}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e: number) => setLocalVolume(e)}
|
||||
toolTipType="text"
|
||||
/>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default RightControls;
|
||||
27
src/renderer/layouts/default/sidebar/Sidebar.tsx
Normal file
27
src/renderer/layouts/default/sidebar/Sidebar.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Container, createStyles } from '@mantine/core';
|
||||
import * as Space from 'react-spaces';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
wrapper: {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.layout[1]
|
||||
: theme.colors.layout[1],
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
}));
|
||||
|
||||
const Sidebar = () => {
|
||||
const { classes } = useStyles();
|
||||
|
||||
return (
|
||||
<Space.Left resizable minimumSize={65} maximumSize={350} size={150}>
|
||||
<Container color="dark" className={classes.wrapper}>
|
||||
Sidebar
|
||||
</Container>
|
||||
</Space.Left>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
Reference in New Issue
Block a user