Add initial playerbar and sidebar

This commit is contained in:
jeffvli
2022-04-25 18:00:06 -07:00
parent 7343809771
commit 0bfc1bbabe
10 changed files with 1272 additions and 0 deletions

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

View File

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

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

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

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

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

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

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

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

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