From 0bfc1bbabe2245d320b763aaf12e77b5f9ddfacf Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 25 Apr 2022 18:00:06 -0700 Subject: [PATCH] Add initial playerbar and sidebar --- .../components/audio-player/AudioPlayer.tsx | 159 ++++++++++++ .../hooks/useMainAudioControls.ts | 242 ++++++++++++++++++ .../components/audio-player/listenHandlers.ts | 146 +++++++++++ .../components/icon-button/IconButton.tsx | 35 +++ src/renderer/components/slider/Slider.tsx | 132 ++++++++++ .../features/player-bar/PlayerBar.tsx | 88 +++++++ .../player-bar/components/CenterControls.tsx | 191 ++++++++++++++ .../player-bar/components/LeftControls.tsx | 72 ++++++ .../player-bar/components/RightControls.tsx | 180 +++++++++++++ .../layouts/default/sidebar/Sidebar.tsx | 27 ++ 10 files changed, 1272 insertions(+) create mode 100644 src/renderer/components/audio-player/AudioPlayer.tsx create mode 100644 src/renderer/components/audio-player/hooks/useMainAudioControls.ts create mode 100644 src/renderer/components/audio-player/listenHandlers.ts create mode 100644 src/renderer/components/icon-button/IconButton.tsx create mode 100644 src/renderer/components/slider/Slider.tsx create mode 100644 src/renderer/features/player-bar/PlayerBar.tsx create mode 100644 src/renderer/features/player-bar/components/CenterControls.tsx create mode 100644 src/renderer/features/player-bar/components/LeftControls.tsx create mode 100644 src/renderer/features/player-bar/components/RightControls.tsx create mode 100644 src/renderer/layouts/default/sidebar/Sidebar.tsx diff --git a/src/renderer/components/audio-player/AudioPlayer.tsx b/src/renderer/components/audio-player/AudioPlayer.tsx new file mode 100644 index 0000000..45dcdbe --- /dev/null +++ b/src/renderer/components/audio-player/AudioPlayer.tsx @@ -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(null); + const player2Ref = useRef(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 ( + <> + + + + ); +}; + +export default forwardRef(AudioPlayer); diff --git a/src/renderer/components/audio-player/hooks/useMainAudioControls.ts b/src/renderer/components/audio-player/hooks/useMainAudioControls.ts new file mode 100644 index 0000000..a378035 --- /dev/null +++ b/src/renderer/components/audio-player/hooks/useMainAudioControls.ts @@ -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; +}) => { + 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; diff --git a/src/renderer/components/audio-player/listenHandlers.ts b/src/renderer/components/audio-player/listenHandlers.ts new file mode 100644 index 0000000..4b87f66 --- /dev/null +++ b/src/renderer/components/audio-player/listenHandlers.ts @@ -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; +}) => { + 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; +}) => { + 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; +}; diff --git a/src/renderer/components/icon-button/IconButton.tsx b/src/renderer/components/icon-button/IconButton.tsx new file mode 100644 index 0000000..27569aa --- /dev/null +++ b/src/renderer/components/icon-button/IconButton.tsx @@ -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 ( + + ({ + color: active ? theme.colors.primary[5] : theme.colors.white, + })} + {...rest} + > + {icon} + + + ); + } + + return {icon}; +}; + +IconButton.defaultProps = { + tooltip: undefined, + active: false, +}; + +export default IconButton; diff --git a/src/renderer/components/slider/Slider.tsx b/src/renderer/components/slider/Slider.tsx new file mode 100644 index 0000000..ce1a522 --- /dev/null +++ b/src/renderer/components/slider/Slider.tsx @@ -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(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` + 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
; +}; + +// eslint-disable-next-line react/destructuring-assignment +const Track = (props: any, state: any) => { + const { index } = state; + + return ; +}; +const Thumb = (props: any, state: any, toolTipType: any) => ( + +); + +const Slider = ({ toolTipType, hasToolTip, ...rest }: SliderProps) => { + const [isDragging, setIsDragging] = useState(false); + + return ( + { + 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; diff --git a/src/renderer/features/player-bar/PlayerBar.tsx b/src/renderer/features/player-bar/PlayerBar.tsx new file mode 100644 index 0000000..b6a04f3 --- /dev/null +++ b/src/renderer/features/player-bar/PlayerBar.tsx @@ -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(); + 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 ( + <> + dispatch(autoIncrement())} + player1={player1Song} + player2={player2Song} + volume={volume} + muted={muted} + type={type} + crossfadeType={crossfadeType} + crossfadeDuration={crossfadeDuration} + /> + + + + + + + + + + + + + + + + + ); +}; + +export default PlayerBar; diff --git a/src/renderer/features/player-bar/components/CenterControls.tsx b/src/renderer/features/player-bar/components/CenterControls.tsx new file mode 100644 index 0000000..0e8341e --- /dev/null +++ b/src/renderer/features/player-bar/components/CenterControls.tsx @@ -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 ( + <> + + } + onClick={handleStop} + /> + } + onClick={handlePrevTrack} + /> + } + onClick={handleSkipBackward} + /> + + ) : ( + + ) + } + onClick={handlePlayPause} + /> + } + onClick={handleSkipForward} + /> + } + onClick={handleNextTrack} + /> + + + + {formattedTime} + + + { + handleSeekSlider(e); + setIsSeeking(false); + }} + toolTipType="time" + /> + + + {duration} + + + + ); +}; + +export default CenterControls; diff --git a/src/renderer/features/player-bar/components/LeftControls.tsx b/src/renderer/features/player-bar/components/LeftControls.tsx new file mode 100644 index 0000000..7120437 --- /dev/null +++ b/src/renderer/features/player-bar/components/LeftControls.tsx @@ -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 ( + + + + + + + component={Link} to="/nowplaying"> + {song?.title || 'N/a'} + + + + component={Link} to="/nowplaying"> + {song?.artist?.map((artist) => artist?.title) || 'N/a'} + + + + component={Link} to="/nowplaying"> + {song?.album || 'N/a'} + + + + + ); +}; + +export default LeftControls; diff --git a/src/renderer/features/player-bar/components/RightControls.tsx b/src/renderer/features/player-bar/components/RightControls.tsx new file mode 100644 index 0000000..7003838 --- /dev/null +++ b/src/renderer/features/player-bar/components/RightControls.tsx @@ -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 ( + + + + + setOpenConfig(false)} + position="top" + withArrow + target={ + } + onClick={() => setOpenConfig(!openConfig)} + /> + } + > + + dispatch(setType(e))} + > + + + +