mirror of
https://github.com/antebudimir/feishin.git
synced 2026-01-01 02:13:33 +00:00
Lint all files
This commit is contained in:
parent
22af76b4d6
commit
30e52ebb54
334 changed files with 76519 additions and 75932 deletions
|
|
@ -5,34 +5,34 @@ import formatDuration from 'format-duration';
|
|||
import isElectron from 'is-electron';
|
||||
import { IoIosPause } from 'react-icons/io';
|
||||
import {
|
||||
RiMenuAddFill,
|
||||
RiPlayFill,
|
||||
RiRepeat2Line,
|
||||
RiRepeatOneLine,
|
||||
RiRewindFill,
|
||||
RiShuffleFill,
|
||||
RiSkipBackFill,
|
||||
RiSkipForwardFill,
|
||||
RiSpeedFill,
|
||||
RiStopFill,
|
||||
RiMenuAddFill,
|
||||
RiPlayFill,
|
||||
RiRepeat2Line,
|
||||
RiRepeatOneLine,
|
||||
RiRewindFill,
|
||||
RiShuffleFill,
|
||||
RiSkipBackFill,
|
||||
RiSkipForwardFill,
|
||||
RiSpeedFill,
|
||||
RiStopFill,
|
||||
} from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { Text } from '/@/renderer/components';
|
||||
import { useCenterControls } from '../hooks/use-center-controls';
|
||||
import { PlayerButton } from './player-button';
|
||||
import {
|
||||
useCurrentSong,
|
||||
useCurrentStatus,
|
||||
useCurrentPlayer,
|
||||
useSetCurrentTime,
|
||||
useRepeatStatus,
|
||||
useShuffleStatus,
|
||||
useCurrentTime,
|
||||
useCurrentSong,
|
||||
useCurrentStatus,
|
||||
useCurrentPlayer,
|
||||
useSetCurrentTime,
|
||||
useRepeatStatus,
|
||||
useShuffleStatus,
|
||||
useCurrentTime,
|
||||
} from '/@/renderer/store';
|
||||
import {
|
||||
useHotkeySettings,
|
||||
usePlayerType,
|
||||
useSettingsStore,
|
||||
useHotkeySettings,
|
||||
usePlayerType,
|
||||
useSettingsStore,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { PlayerStatus, PlaybackType, PlayerShuffle, PlayerRepeat } from '/@/renderer/types';
|
||||
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
||||
|
|
@ -40,282 +40,286 @@ import { openShuffleAllModal } from './shuffle-all-modal';
|
|||
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
|
||||
|
||||
interface CenterControlsProps {
|
||||
playersRef: any;
|
||||
playersRef: any;
|
||||
}
|
||||
|
||||
const ButtonsContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const SliderContainer = styled.div`
|
||||
display: flex;
|
||||
width: 95%;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
width: 95%;
|
||||
height: 20px;
|
||||
`;
|
||||
|
||||
const SliderValueWrapper = styled.div<{ position: 'left' | 'right' }>`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-self: flex-end;
|
||||
justify-content: center;
|
||||
max-width: 50px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-self: flex-end;
|
||||
justify-content: center;
|
||||
max-width: 50px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const SliderWrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 6;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex: 6;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 35px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 35px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
${ButtonsContainer} {
|
||||
gap: 0;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
${ButtonsContainer} {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
${SliderValueWrapper} {
|
||||
display: none;
|
||||
${SliderValueWrapper} {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const currentSong = useCurrentSong();
|
||||
const songDuration = currentSong?.duration;
|
||||
const skip = useSettingsStore((state) => state.general.skipButtons);
|
||||
const playerType = usePlayerType();
|
||||
const player1 = playersRef?.current?.player1;
|
||||
const player2 = playersRef?.current?.player2;
|
||||
const status = useCurrentStatus();
|
||||
const player = useCurrentPlayer();
|
||||
const setCurrentTime = useSetCurrentTime();
|
||||
const repeat = useRepeatStatus();
|
||||
const shuffle = useShuffleStatus();
|
||||
const { bindings } = useHotkeySettings();
|
||||
const queryClient = useQueryClient();
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const currentSong = useCurrentSong();
|
||||
const songDuration = currentSong?.duration;
|
||||
const skip = useSettingsStore((state) => state.general.skipButtons);
|
||||
const playerType = usePlayerType();
|
||||
const player1 = playersRef?.current?.player1;
|
||||
const player2 = playersRef?.current?.player2;
|
||||
const status = useCurrentStatus();
|
||||
const player = useCurrentPlayer();
|
||||
const setCurrentTime = useSetCurrentTime();
|
||||
const repeat = useRepeatStatus();
|
||||
const shuffle = useShuffleStatus();
|
||||
const { bindings } = useHotkeySettings();
|
||||
|
||||
const {
|
||||
handleNextTrack,
|
||||
handlePlayPause,
|
||||
handlePrevTrack,
|
||||
handleSeekSlider,
|
||||
handleSkipBackward,
|
||||
handleSkipForward,
|
||||
handleToggleRepeat,
|
||||
handleToggleShuffle,
|
||||
handleStop,
|
||||
handlePause,
|
||||
handlePlay,
|
||||
} = useCenterControls({ playersRef });
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const {
|
||||
handleNextTrack,
|
||||
handlePlayPause,
|
||||
handlePrevTrack,
|
||||
handleSeekSlider,
|
||||
handleSkipBackward,
|
||||
handleSkipForward,
|
||||
handleToggleRepeat,
|
||||
handleToggleShuffle,
|
||||
handleStop,
|
||||
handlePause,
|
||||
handlePlay,
|
||||
} = useCenterControls({ playersRef });
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const currentTime = useCurrentTime();
|
||||
const currentPlayerRef = player === 1 ? player1 : player2;
|
||||
const duration = formatDuration((songDuration || 0) * 1000);
|
||||
const formattedTime = formatDuration(currentTime * 1000 || 0);
|
||||
const currentTime = useCurrentTime();
|
||||
const currentPlayerRef = player === 1 ? player1 : player2;
|
||||
const duration = formatDuration((songDuration || 0) * 1000);
|
||||
const formattedTime = formatDuration(currentTime * 1000 || 0);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: any;
|
||||
useEffect(() => {
|
||||
let interval: any;
|
||||
|
||||
if (status === PlayerStatus.PLAYING && !isSeeking) {
|
||||
if (!isElectron() || playerType === PlaybackType.WEB) {
|
||||
interval = setInterval(() => {
|
||||
setCurrentTime(currentPlayerRef.getCurrentTime());
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentPlayerRef, isSeeking, setCurrentTime, playerType, status]);
|
||||
|
||||
const [seekValue, setSeekValue] = useState(0);
|
||||
|
||||
useHotkeys([
|
||||
[bindings.playPause.isGlobal ? '' : bindings.playPause.hotkey, handlePlayPause],
|
||||
[bindings.play.isGlobal ? '' : bindings.play.hotkey, handlePlay],
|
||||
[bindings.pause.isGlobal ? '' : bindings.pause.hotkey, handlePause],
|
||||
[bindings.stop.isGlobal ? '' : bindings.stop.hotkey, handleStop],
|
||||
[bindings.next.isGlobal ? '' : bindings.next.hotkey, handleNextTrack],
|
||||
[bindings.previous.isGlobal ? '' : bindings.previous.hotkey, handlePrevTrack],
|
||||
[bindings.toggleRepeat.isGlobal ? '' : bindings.toggleRepeat.hotkey, handleToggleRepeat],
|
||||
[bindings.toggleShuffle.isGlobal ? '' : bindings.toggleShuffle.hotkey, handleToggleShuffle],
|
||||
[
|
||||
bindings.skipBackward.isGlobal ? '' : bindings.skipBackward.hotkey,
|
||||
() => handleSkipBackward(skip?.skipBackwardSeconds || 5),
|
||||
],
|
||||
[
|
||||
bindings.skipForward.isGlobal ? '' : bindings.skipForward.hotkey,
|
||||
() => handleSkipForward(skip?.skipForwardSeconds || 5),
|
||||
],
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ControlsContainer>
|
||||
<ButtonsContainer>
|
||||
<PlayerButton
|
||||
icon={<RiStopFill size={15} />}
|
||||
tooltip={{
|
||||
label: 'Stop',
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
onClick={handleStop}
|
||||
/>
|
||||
<PlayerButton
|
||||
$isActive={shuffle !== PlayerShuffle.NONE}
|
||||
icon={<RiShuffleFill size={15} />}
|
||||
tooltip={{
|
||||
label:
|
||||
shuffle === PlayerShuffle.NONE
|
||||
? 'Shuffle disabled'
|
||||
: shuffle === PlayerShuffle.TRACK
|
||||
? 'Shuffle tracks'
|
||||
: 'Shuffle albums',
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
onClick={handleToggleShuffle}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<RiSkipBackFill size={15} />}
|
||||
tooltip={{ label: 'Previous track', openDelay: 500 }}
|
||||
variant="secondary"
|
||||
onClick={handlePrevTrack}
|
||||
/>
|
||||
{skip?.enabled && (
|
||||
<PlayerButton
|
||||
icon={<RiRewindFill size={15} />}
|
||||
tooltip={{
|
||||
label: `Skip backwards ${skip?.skipBackwardSeconds} seconds`,
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={() => handleSkipBackward(skip?.skipBackwardSeconds)}
|
||||
/>
|
||||
)}
|
||||
<PlayerButton
|
||||
icon={
|
||||
status === PlayerStatus.PAUSED ? <RiPlayFill size={20} /> : <IoIosPause size={20} />
|
||||
if (status === PlayerStatus.PLAYING && !isSeeking) {
|
||||
if (!isElectron() || playerType === PlaybackType.WEB) {
|
||||
interval = setInterval(() => {
|
||||
setCurrentTime(currentPlayerRef.getCurrentTime());
|
||||
}, 1000);
|
||||
}
|
||||
tooltip={{
|
||||
label: status === PlayerStatus.PAUSED ? 'Play' : 'Pause',
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="main"
|
||||
onClick={handlePlayPause}
|
||||
/>
|
||||
{skip?.enabled && (
|
||||
<PlayerButton
|
||||
icon={<RiSpeedFill size={15} />}
|
||||
tooltip={{
|
||||
label: `Skip forwards ${skip?.skipForwardSeconds} seconds`,
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={() => handleSkipForward(skip?.skipForwardSeconds)}
|
||||
/>
|
||||
)}
|
||||
<PlayerButton
|
||||
icon={<RiSkipForwardFill size={15} />}
|
||||
tooltip={{ label: 'Next track', openDelay: 500 }}
|
||||
variant="secondary"
|
||||
onClick={handleNextTrack}
|
||||
/>
|
||||
<PlayerButton
|
||||
$isActive={repeat !== PlayerRepeat.NONE}
|
||||
icon={
|
||||
repeat === PlayerRepeat.ONE ? (
|
||||
<RiRepeatOneLine size={15} />
|
||||
) : (
|
||||
<RiRepeat2Line size={15} />
|
||||
)
|
||||
}
|
||||
tooltip={{
|
||||
label: `${
|
||||
repeat === PlayerRepeat.NONE
|
||||
? 'Repeat disabled'
|
||||
: repeat === PlayerRepeat.ALL
|
||||
? 'Repeat all'
|
||||
: 'Repeat one'
|
||||
}`,
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
onClick={handleToggleRepeat}
|
||||
/>
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
<PlayerButton
|
||||
icon={<RiMenuAddFill size={15} />}
|
||||
tooltip={{
|
||||
label: 'Shuffle all',
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
onClick={() =>
|
||||
openShuffleAllModal({
|
||||
handlePlayQueueAdd,
|
||||
queryClient,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ButtonsContainer>
|
||||
</ControlsContainer>
|
||||
<SliderContainer>
|
||||
<SliderValueWrapper position="left">
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary
|
||||
size="xs"
|
||||
weight={600}
|
||||
>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
</SliderValueWrapper>
|
||||
<SliderWrapper>
|
||||
<PlayerbarSlider
|
||||
label={(value) => formatDuration(value * 1000)}
|
||||
max={songDuration}
|
||||
min={0}
|
||||
size={6}
|
||||
value={!isSeeking ? currentTime : seekValue}
|
||||
w="100%"
|
||||
onChange={(e) => {
|
||||
setIsSeeking(true);
|
||||
setSeekValue(e);
|
||||
}}
|
||||
onChangeEnd={(e) => {
|
||||
handleSeekSlider(e);
|
||||
setIsSeeking(false);
|
||||
}}
|
||||
/>
|
||||
</SliderWrapper>
|
||||
<SliderValueWrapper position="right">
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary
|
||||
size="xs"
|
||||
weight={600}
|
||||
>
|
||||
{duration}
|
||||
</Text>
|
||||
</SliderValueWrapper>
|
||||
</SliderContainer>
|
||||
</>
|
||||
);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentPlayerRef, isSeeking, setCurrentTime, playerType, status]);
|
||||
|
||||
const [seekValue, setSeekValue] = useState(0);
|
||||
|
||||
useHotkeys([
|
||||
[bindings.playPause.isGlobal ? '' : bindings.playPause.hotkey, handlePlayPause],
|
||||
[bindings.play.isGlobal ? '' : bindings.play.hotkey, handlePlay],
|
||||
[bindings.pause.isGlobal ? '' : bindings.pause.hotkey, handlePause],
|
||||
[bindings.stop.isGlobal ? '' : bindings.stop.hotkey, handleStop],
|
||||
[bindings.next.isGlobal ? '' : bindings.next.hotkey, handleNextTrack],
|
||||
[bindings.previous.isGlobal ? '' : bindings.previous.hotkey, handlePrevTrack],
|
||||
[bindings.toggleRepeat.isGlobal ? '' : bindings.toggleRepeat.hotkey, handleToggleRepeat],
|
||||
[bindings.toggleShuffle.isGlobal ? '' : bindings.toggleShuffle.hotkey, handleToggleShuffle],
|
||||
[
|
||||
bindings.skipBackward.isGlobal ? '' : bindings.skipBackward.hotkey,
|
||||
() => handleSkipBackward(skip?.skipBackwardSeconds || 5),
|
||||
],
|
||||
[
|
||||
bindings.skipForward.isGlobal ? '' : bindings.skipForward.hotkey,
|
||||
() => handleSkipForward(skip?.skipForwardSeconds || 5),
|
||||
],
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ControlsContainer>
|
||||
<ButtonsContainer>
|
||||
<PlayerButton
|
||||
icon={<RiStopFill size={15} />}
|
||||
tooltip={{
|
||||
label: 'Stop',
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
onClick={handleStop}
|
||||
/>
|
||||
<PlayerButton
|
||||
$isActive={shuffle !== PlayerShuffle.NONE}
|
||||
icon={<RiShuffleFill size={15} />}
|
||||
tooltip={{
|
||||
label:
|
||||
shuffle === PlayerShuffle.NONE
|
||||
? 'Shuffle disabled'
|
||||
: shuffle === PlayerShuffle.TRACK
|
||||
? 'Shuffle tracks'
|
||||
: 'Shuffle albums',
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
onClick={handleToggleShuffle}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<RiSkipBackFill size={15} />}
|
||||
tooltip={{ label: 'Previous track', openDelay: 500 }}
|
||||
variant="secondary"
|
||||
onClick={handlePrevTrack}
|
||||
/>
|
||||
{skip?.enabled && (
|
||||
<PlayerButton
|
||||
icon={<RiRewindFill size={15} />}
|
||||
tooltip={{
|
||||
label: `Skip backwards ${skip?.skipBackwardSeconds} seconds`,
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={() => handleSkipBackward(skip?.skipBackwardSeconds)}
|
||||
/>
|
||||
)}
|
||||
<PlayerButton
|
||||
icon={
|
||||
status === PlayerStatus.PAUSED ? (
|
||||
<RiPlayFill size={20} />
|
||||
) : (
|
||||
<IoIosPause size={20} />
|
||||
)
|
||||
}
|
||||
tooltip={{
|
||||
label: status === PlayerStatus.PAUSED ? 'Play' : 'Pause',
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="main"
|
||||
onClick={handlePlayPause}
|
||||
/>
|
||||
{skip?.enabled && (
|
||||
<PlayerButton
|
||||
icon={<RiSpeedFill size={15} />}
|
||||
tooltip={{
|
||||
label: `Skip forwards ${skip?.skipForwardSeconds} seconds`,
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={() => handleSkipForward(skip?.skipForwardSeconds)}
|
||||
/>
|
||||
)}
|
||||
<PlayerButton
|
||||
icon={<RiSkipForwardFill size={15} />}
|
||||
tooltip={{ label: 'Next track', openDelay: 500 }}
|
||||
variant="secondary"
|
||||
onClick={handleNextTrack}
|
||||
/>
|
||||
<PlayerButton
|
||||
$isActive={repeat !== PlayerRepeat.NONE}
|
||||
icon={
|
||||
repeat === PlayerRepeat.ONE ? (
|
||||
<RiRepeatOneLine size={15} />
|
||||
) : (
|
||||
<RiRepeat2Line size={15} />
|
||||
)
|
||||
}
|
||||
tooltip={{
|
||||
label: `${
|
||||
repeat === PlayerRepeat.NONE
|
||||
? 'Repeat disabled'
|
||||
: repeat === PlayerRepeat.ALL
|
||||
? 'Repeat all'
|
||||
: 'Repeat one'
|
||||
}`,
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
onClick={handleToggleRepeat}
|
||||
/>
|
||||
|
||||
<PlayerButton
|
||||
icon={<RiMenuAddFill size={15} />}
|
||||
tooltip={{
|
||||
label: 'Shuffle all',
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
onClick={() =>
|
||||
openShuffleAllModal({
|
||||
handlePlayQueueAdd,
|
||||
queryClient,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ButtonsContainer>
|
||||
</ControlsContainer>
|
||||
<SliderContainer>
|
||||
<SliderValueWrapper position="left">
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary
|
||||
size="xs"
|
||||
weight={600}
|
||||
>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
</SliderValueWrapper>
|
||||
<SliderWrapper>
|
||||
<PlayerbarSlider
|
||||
label={(value) => formatDuration(value * 1000)}
|
||||
max={songDuration}
|
||||
min={0}
|
||||
size={6}
|
||||
value={!isSeeking ? currentTime : seekValue}
|
||||
w="100%"
|
||||
onChange={(e) => {
|
||||
setIsSeeking(true);
|
||||
setSeekValue(e);
|
||||
}}
|
||||
onChangeEnd={(e) => {
|
||||
handleSeekSlider(e);
|
||||
setIsSeeking(false);
|
||||
}}
|
||||
/>
|
||||
</SliderWrapper>
|
||||
<SliderValueWrapper position="right">
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary
|
||||
size="xs"
|
||||
weight={600}
|
||||
>
|
||||
{duration}
|
||||
</Text>
|
||||
</SliderValueWrapper>
|
||||
</SliderContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ import { Badge, Text, TextTitle } from '/@/renderer/components';
|
|||
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
PlayerData,
|
||||
useFullScreenPlayerStore,
|
||||
usePlayerData,
|
||||
usePlayerStore,
|
||||
PlayerData,
|
||||
useFullScreenPlayerStore,
|
||||
usePlayerData,
|
||||
usePlayerStore,
|
||||
} from '/@/renderer/store';
|
||||
|
||||
const Image = styled(motion.img)<{ $useAspectRatio: boolean }>`
|
||||
|
|
@ -28,247 +28,249 @@ const Image = styled(motion.img)<{ $useAspectRatio: boolean }>`
|
|||
`;
|
||||
|
||||
const ImageContainer = styled(motion.div)`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
height: 65%;
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
height: 65%;
|
||||
margin-bottom: 1rem;
|
||||
`;
|
||||
|
||||
const MetadataContainer = styled(Stack)`
|
||||
h1 {
|
||||
font-size: 3.5vh;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3.5vh;
|
||||
}
|
||||
`;
|
||||
|
||||
const PlayerContainer = styled(Flex)`
|
||||
@media screen and (max-height: 640px) {
|
||||
.full-screen-player-image-metadata {
|
||||
display: none;
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@media screen and (max-height: 640px) {
|
||||
.full-screen-player-image-metadata {
|
||||
display: none;
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
${ImageContainer} {
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
${ImageContainer} {
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const imageVariants: Variants = {
|
||||
closed: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: 'linear',
|
||||
closed: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: 'linear',
|
||||
},
|
||||
},
|
||||
initial: {
|
||||
opacity: 0,
|
||||
},
|
||||
open: (custom) => {
|
||||
const { isOpen } = custom;
|
||||
return {
|
||||
opacity: isOpen ? 1 : 0,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: 'linear',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
initial: {
|
||||
opacity: 0,
|
||||
},
|
||||
open: (custom) => {
|
||||
const { isOpen } = custom;
|
||||
return {
|
||||
opacity: isOpen ? 1 : 0,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: 'linear',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const scaleImageUrl = (url?: string | null) => {
|
||||
return url
|
||||
?.replace(/&size=\d+/, '&size=500')
|
||||
.replace(/\?width=\d+/, '?width=500')
|
||||
.replace(/&height=\d+/, '&height=500');
|
||||
return url
|
||||
?.replace(/&size=\d+/, '&size=500')
|
||||
.replace(/\?width=\d+/, '?width=500')
|
||||
.replace(/&height=\d+/, '&height=500');
|
||||
};
|
||||
|
||||
const ImageWithPlaceholder = ({
|
||||
useAspectRatio,
|
||||
...props
|
||||
useAspectRatio,
|
||||
...props
|
||||
}: HTMLMotionProps<'img'> & { useAspectRatio: boolean }) => {
|
||||
if (!props.src) {
|
||||
return (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
size="25%"
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
if (!props.src) {
|
||||
return (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
size="25%"
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
$useAspectRatio={useAspectRatio}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Image
|
||||
$useAspectRatio={useAspectRatio}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const FullScreenPlayerImage = () => {
|
||||
const { queue } = usePlayerData();
|
||||
const useImageAspectRatio = useFullScreenPlayerStore((state) => state.useImageAspectRatio);
|
||||
const currentSong = queue.current;
|
||||
const background = useFastAverageColor(queue.current?.imageUrl, true, 'dominant');
|
||||
const imageKey = `image-${background}`;
|
||||
const { queue } = usePlayerData();
|
||||
const useImageAspectRatio = useFullScreenPlayerStore((state) => state.useImageAspectRatio);
|
||||
const currentSong = queue.current;
|
||||
const background = useFastAverageColor(queue.current?.imageUrl, true, 'dominant');
|
||||
const imageKey = `image-${background}`;
|
||||
|
||||
const [imageState, setImageState] = useSetState({
|
||||
bottomImage: scaleImageUrl(queue.next?.imageUrl),
|
||||
current: 0,
|
||||
topImage: scaleImageUrl(queue.current?.imageUrl),
|
||||
});
|
||||
const [imageState, setImageState] = useSetState({
|
||||
bottomImage: scaleImageUrl(queue.next?.imageUrl),
|
||||
current: 0,
|
||||
topImage: scaleImageUrl(queue.current?.imageUrl),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubSongChange = usePlayerStore.subscribe(
|
||||
(state) => [state.current.song, state.actions.getPlayerData().queue],
|
||||
(state) => {
|
||||
const isTop = imageState.current === 0;
|
||||
const queue = state[1] as PlayerData['queue'];
|
||||
useEffect(() => {
|
||||
const unsubSongChange = usePlayerStore.subscribe(
|
||||
(state) => [state.current.song, state.actions.getPlayerData().queue],
|
||||
(state) => {
|
||||
const isTop = imageState.current === 0;
|
||||
const queue = state[1] as PlayerData['queue'];
|
||||
|
||||
const currentImageUrl = scaleImageUrl(queue.current?.imageUrl);
|
||||
const nextImageUrl = scaleImageUrl(queue.next?.imageUrl);
|
||||
const currentImageUrl = scaleImageUrl(queue.current?.imageUrl);
|
||||
const nextImageUrl = scaleImageUrl(queue.next?.imageUrl);
|
||||
|
||||
setImageState({
|
||||
bottomImage: isTop ? currentImageUrl : nextImageUrl,
|
||||
current: isTop ? 1 : 0,
|
||||
topImage: isTop ? nextImageUrl : currentImageUrl,
|
||||
});
|
||||
},
|
||||
{ equalityFn: (a, b) => (a[0] as QueueSong)?.id === (b[0] as QueueSong)?.id },
|
||||
);
|
||||
setImageState({
|
||||
bottomImage: isTop ? currentImageUrl : nextImageUrl,
|
||||
current: isTop ? 1 : 0,
|
||||
topImage: isTop ? nextImageUrl : currentImageUrl,
|
||||
});
|
||||
},
|
||||
{ equalityFn: (a, b) => (a[0] as QueueSong)?.id === (b[0] as QueueSong)?.id },
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubSongChange();
|
||||
};
|
||||
}, [imageState, queue, setImageState]);
|
||||
return () => {
|
||||
unsubSongChange();
|
||||
};
|
||||
}, [imageState, queue, setImageState]);
|
||||
|
||||
return (
|
||||
<PlayerContainer
|
||||
align="center"
|
||||
className="full-screen-player-image-container"
|
||||
direction="column"
|
||||
justify="flex-start"
|
||||
p="1rem"
|
||||
>
|
||||
<ImageContainer>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="sync"
|
||||
>
|
||||
{imageState.current === 0 && (
|
||||
<ImageWithPlaceholder
|
||||
key={imageKey}
|
||||
animate="open"
|
||||
className="full-screen-player-image"
|
||||
custom={{ isOpen: imageState.current === 0 }}
|
||||
draggable={false}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
placeholder="var(--placeholder-bg)"
|
||||
src={imageState.topImage || ''}
|
||||
useAspectRatio={useImageAspectRatio}
|
||||
variants={imageVariants}
|
||||
/>
|
||||
)}
|
||||
|
||||
{imageState.current === 1 && (
|
||||
<ImageWithPlaceholder
|
||||
key={imageKey}
|
||||
animate="open"
|
||||
className="full-screen-player-image"
|
||||
custom={{ isOpen: imageState.current === 1 }}
|
||||
draggable={false}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
placeholder="var(--placeholder-bg)"
|
||||
src={imageState.bottomImage || ''}
|
||||
useAspectRatio={useImageAspectRatio}
|
||||
variants={imageVariants}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</ImageContainer>
|
||||
<MetadataContainer
|
||||
className="full-screen-player-image-metadata"
|
||||
maw="100%"
|
||||
spacing="xs"
|
||||
>
|
||||
<TextTitle
|
||||
align="center"
|
||||
order={1}
|
||||
overflow="hidden"
|
||||
w="100%"
|
||||
weight={900}
|
||||
>
|
||||
{currentSong?.name}
|
||||
</TextTitle>
|
||||
<TextTitle
|
||||
$link
|
||||
$secondary
|
||||
align="center"
|
||||
component={Link}
|
||||
order={2}
|
||||
overflow="hidden"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: currentSong?.albumId || '',
|
||||
})}
|
||||
w="100%"
|
||||
weight={600}
|
||||
>
|
||||
{currentSong?.album}{' '}
|
||||
</TextTitle>
|
||||
{currentSong?.artists?.map((artist, index) => (
|
||||
<TextTitle
|
||||
key={`fs-artist-${artist.id}`}
|
||||
$secondary
|
||||
return (
|
||||
<PlayerContainer
|
||||
align="center"
|
||||
order={4}
|
||||
>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 0.5rem',
|
||||
}}
|
||||
>
|
||||
•
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
weight={600}
|
||||
className="full-screen-player-image-container"
|
||||
direction="column"
|
||||
justify="flex-start"
|
||||
p="1rem"
|
||||
>
|
||||
<ImageContainer>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="sync"
|
||||
>
|
||||
{imageState.current === 0 && (
|
||||
<ImageWithPlaceholder
|
||||
key={imageKey}
|
||||
animate="open"
|
||||
className="full-screen-player-image"
|
||||
custom={{ isOpen: imageState.current === 0 }}
|
||||
draggable={false}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
placeholder="var(--placeholder-bg)"
|
||||
src={imageState.topImage || ''}
|
||||
useAspectRatio={useImageAspectRatio}
|
||||
variants={imageVariants}
|
||||
/>
|
||||
)}
|
||||
|
||||
{imageState.current === 1 && (
|
||||
<ImageWithPlaceholder
|
||||
key={imageKey}
|
||||
animate="open"
|
||||
className="full-screen-player-image"
|
||||
custom={{ isOpen: imageState.current === 1 }}
|
||||
draggable={false}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
placeholder="var(--placeholder-bg)"
|
||||
src={imageState.bottomImage || ''}
|
||||
useAspectRatio={useImageAspectRatio}
|
||||
variants={imageVariants}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</ImageContainer>
|
||||
<MetadataContainer
|
||||
className="full-screen-player-image-metadata"
|
||||
maw="100%"
|
||||
spacing="xs"
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
</TextTitle>
|
||||
))}
|
||||
<Group position="center">
|
||||
{currentSong?.container && (
|
||||
<Badge size="lg">
|
||||
{currentSong?.container} {currentSong?.bitRate}
|
||||
</Badge>
|
||||
)}
|
||||
{currentSong?.releaseYear && <Badge size="lg">{currentSong?.releaseYear}</Badge>}
|
||||
</Group>
|
||||
</MetadataContainer>
|
||||
</PlayerContainer>
|
||||
);
|
||||
<TextTitle
|
||||
align="center"
|
||||
order={1}
|
||||
overflow="hidden"
|
||||
w="100%"
|
||||
weight={900}
|
||||
>
|
||||
{currentSong?.name}
|
||||
</TextTitle>
|
||||
<TextTitle
|
||||
$link
|
||||
$secondary
|
||||
align="center"
|
||||
component={Link}
|
||||
order={2}
|
||||
overflow="hidden"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: currentSong?.albumId || '',
|
||||
})}
|
||||
w="100%"
|
||||
weight={600}
|
||||
>
|
||||
{currentSong?.album}{' '}
|
||||
</TextTitle>
|
||||
{currentSong?.artists?.map((artist, index) => (
|
||||
<TextTitle
|
||||
key={`fs-artist-${artist.id}`}
|
||||
$secondary
|
||||
align="center"
|
||||
order={4}
|
||||
>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 0.5rem',
|
||||
}}
|
||||
>
|
||||
•
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
weight={600}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
</TextTitle>
|
||||
))}
|
||||
<Group position="center">
|
||||
{currentSong?.container && (
|
||||
<Badge size="lg">
|
||||
{currentSong?.container} {currentSong?.bitRate}
|
||||
</Badge>
|
||||
)}
|
||||
{currentSong?.releaseYear && (
|
||||
<Badge size="lg">{currentSong?.releaseYear}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</MetadataContainer>
|
||||
</PlayerContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,122 +6,122 @@ import styled from 'styled-components';
|
|||
import { Button, TextTitle } from '/@/renderer/components';
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing';
|
||||
import {
|
||||
useFullScreenPlayerStore,
|
||||
useFullScreenPlayerStoreActions,
|
||||
useFullScreenPlayerStore,
|
||||
useFullScreenPlayerStoreActions,
|
||||
} from '/@/renderer/store/full-screen-player.store';
|
||||
import { Lyrics } from '/@/renderer/features/lyrics/lyrics';
|
||||
|
||||
const QueueContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
--ag-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
--ag-odd-row-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
}
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
--ag-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
--ag-odd-row-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
}
|
||||
|
||||
.ag-header {
|
||||
display: none !important;
|
||||
}
|
||||
.ag-header {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const ActiveTabIndicator = styled(motion.div)`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--main-fg);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--main-fg);
|
||||
`;
|
||||
|
||||
const HeaderItemWrapper = styled.div`
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
`;
|
||||
|
||||
const GridContainer = styled.div`
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
grid-template-columns: 1fr;
|
||||
`;
|
||||
|
||||
export const FullScreenPlayerQueue = () => {
|
||||
const { activeTab } = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
const { activeTab } = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
|
||||
const headerItems = [
|
||||
{
|
||||
active: activeTab === 'queue',
|
||||
icon: <RiFileMusicLine size="1.5rem" />,
|
||||
label: 'Up Next',
|
||||
onClick: () => setStore({ activeTab: 'queue' }),
|
||||
},
|
||||
{
|
||||
active: activeTab === 'related',
|
||||
icon: <HiOutlineQueueList size="1.5rem" />,
|
||||
label: 'Related',
|
||||
onClick: () => setStore({ activeTab: 'related' }),
|
||||
},
|
||||
{
|
||||
active: activeTab === 'lyrics',
|
||||
icon: <RiFileTextLine size="1.5rem" />,
|
||||
label: 'Lyrics',
|
||||
onClick: () => setStore({ activeTab: 'lyrics' }),
|
||||
},
|
||||
];
|
||||
const headerItems = [
|
||||
{
|
||||
active: activeTab === 'queue',
|
||||
icon: <RiFileMusicLine size="1.5rem" />,
|
||||
label: 'Up Next',
|
||||
onClick: () => setStore({ activeTab: 'queue' }),
|
||||
},
|
||||
{
|
||||
active: activeTab === 'related',
|
||||
icon: <HiOutlineQueueList size="1.5rem" />,
|
||||
label: 'Related',
|
||||
onClick: () => setStore({ activeTab: 'related' }),
|
||||
},
|
||||
{
|
||||
active: activeTab === 'lyrics',
|
||||
icon: <RiFileTextLine size="1.5rem" />,
|
||||
label: 'Lyrics',
|
||||
onClick: () => setStore({ activeTab: 'lyrics' }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<GridContainer className="full-screen-player-queue-container">
|
||||
<Group
|
||||
grow
|
||||
align="center"
|
||||
position="center"
|
||||
>
|
||||
{headerItems.map((item) => (
|
||||
<HeaderItemWrapper key={`tab-${item.label}`}>
|
||||
<Button
|
||||
fullWidth
|
||||
uppercase
|
||||
fw="600"
|
||||
pos="relative"
|
||||
size="lg"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
color: item.active
|
||||
? 'var(--main-fg) !important'
|
||||
: 'var(--main-fg-secondary) !important',
|
||||
letterSpacing: '1px',
|
||||
}}
|
||||
variant="subtle"
|
||||
onClick={item.onClick}
|
||||
return (
|
||||
<GridContainer className="full-screen-player-queue-container">
|
||||
<Group
|
||||
grow
|
||||
align="center"
|
||||
position="center"
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
{item.active ? <ActiveTabIndicator layoutId="underline" /> : null}
|
||||
</HeaderItemWrapper>
|
||||
))}
|
||||
</Group>
|
||||
{activeTab === 'queue' ? (
|
||||
<QueueContainer>
|
||||
<PlayQueue type="fullScreen" />
|
||||
</QueueContainer>
|
||||
) : activeTab === 'related' ? (
|
||||
<Center>
|
||||
<Group>
|
||||
<RiInformationFill size="2rem" />
|
||||
<TextTitle
|
||||
order={3}
|
||||
weight={700}
|
||||
>
|
||||
COMING SOON
|
||||
</TextTitle>
|
||||
</Group>
|
||||
</Center>
|
||||
) : activeTab === 'lyrics' ? (
|
||||
<Lyrics />
|
||||
) : null}
|
||||
</GridContainer>
|
||||
);
|
||||
{headerItems.map((item) => (
|
||||
<HeaderItemWrapper key={`tab-${item.label}`}>
|
||||
<Button
|
||||
fullWidth
|
||||
uppercase
|
||||
fw="600"
|
||||
pos="relative"
|
||||
size="lg"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
color: item.active
|
||||
? 'var(--main-fg) !important'
|
||||
: 'var(--main-fg-secondary) !important',
|
||||
letterSpacing: '1px',
|
||||
}}
|
||||
variant="subtle"
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
{item.active ? <ActiveTabIndicator layoutId="underline" /> : null}
|
||||
</HeaderItemWrapper>
|
||||
))}
|
||||
</Group>
|
||||
{activeTab === 'queue' ? (
|
||||
<QueueContainer>
|
||||
<PlayQueue type="fullScreen" />
|
||||
</QueueContainer>
|
||||
) : activeTab === 'related' ? (
|
||||
<Center>
|
||||
<Group>
|
||||
<RiInformationFill size="2rem" />
|
||||
<TextTitle
|
||||
order={3}
|
||||
weight={700}
|
||||
>
|
||||
COMING SOON
|
||||
</TextTitle>
|
||||
</Group>
|
||||
</Center>
|
||||
) : activeTab === 'lyrics' ? (
|
||||
<Lyrics />
|
||||
) : null}
|
||||
</GridContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import { useLocation } from 'react-router';
|
|||
import styled from 'styled-components';
|
||||
import { Button, Option, Popover, Switch } from '/@/renderer/components';
|
||||
import {
|
||||
useCurrentSong,
|
||||
useFullScreenPlayerStore,
|
||||
useFullScreenPlayerStoreActions,
|
||||
useWindowSettings,
|
||||
useCurrentSong,
|
||||
useFullScreenPlayerStore,
|
||||
useFullScreenPlayerStoreActions,
|
||||
useWindowSettings,
|
||||
} from '/@/renderer/store';
|
||||
import { useFastAverageColor } from '../../../hooks/use-fast-average-color';
|
||||
import { FullScreenPlayerImage } from '/@/renderer/features/player/components/full-screen-player-image';
|
||||
|
|
@ -19,197 +19,197 @@ import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
|
|||
import { Platform } from '/@/renderer/types';
|
||||
|
||||
const Container = styled(motion.div)`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
padding: 2rem 2rem 1rem;
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
padding: 2rem 2rem 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const ResponsiveContainer = styled.div`
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 2rem 2rem;
|
||||
width: 100%;
|
||||
max-width: 2560px;
|
||||
margin-top: 5rem;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 2rem 2rem;
|
||||
width: 100%;
|
||||
max-width: 2560px;
|
||||
margin-top: 5rem;
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
margin-top: 0;
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
margin-top: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const BackgroundImageOverlay = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(20, 21, 23, 40%), var(--main-bg));
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(20, 21, 23, 40%), var(--main-bg));
|
||||
`;
|
||||
|
||||
const Controls = () => {
|
||||
const { dynamicBackground, expanded, useImageAspectRatio } = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
const { dynamicBackground, expanded, useImageAspectRatio } = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
|
||||
const handleToggleFullScreenPlayer = () => {
|
||||
setStore({ expanded: !expanded });
|
||||
};
|
||||
const handleToggleFullScreenPlayer = () => {
|
||||
setStore({ expanded: !expanded });
|
||||
};
|
||||
|
||||
useHotkeys([['Escape', handleToggleFullScreenPlayer]]);
|
||||
useHotkeys([['Escape', handleToggleFullScreenPlayer]]);
|
||||
|
||||
return (
|
||||
<Group
|
||||
p="1rem"
|
||||
pos="absolute"
|
||||
spacing="sm"
|
||||
sx={{
|
||||
left: 0,
|
||||
top: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Minimize' }}
|
||||
variant="subtle"
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
>
|
||||
<RiArrowDownSLine size="2rem" />
|
||||
</Button>
|
||||
<Popover position="bottom-start">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Configure' }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Line size="1.5rem" />
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Option>
|
||||
<Option.Label>Dynamic Background</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={dynamicBackground}
|
||||
onChange={(e) =>
|
||||
setStore({
|
||||
dynamicBackground: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Use image aspect ratio</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={useImageAspectRatio}
|
||||
onChange={(e) =>
|
||||
setStore({
|
||||
useImageAspectRatio: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<TableConfigDropdown type="fullScreen" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
);
|
||||
return (
|
||||
<Group
|
||||
p="1rem"
|
||||
pos="absolute"
|
||||
spacing="sm"
|
||||
sx={{
|
||||
left: 0,
|
||||
top: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Minimize' }}
|
||||
variant="subtle"
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
>
|
||||
<RiArrowDownSLine size="2rem" />
|
||||
</Button>
|
||||
<Popover position="bottom-start">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Configure' }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Line size="1.5rem" />
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Option>
|
||||
<Option.Label>Dynamic Background</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={dynamicBackground}
|
||||
onChange={(e) =>
|
||||
setStore({
|
||||
dynamicBackground: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Use image aspect ratio</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={useImageAspectRatio}
|
||||
onChange={(e) =>
|
||||
setStore({
|
||||
useImageAspectRatio: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<TableConfigDropdown type="fullScreen" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const containerVariants: Variants = {
|
||||
closed: (custom) => {
|
||||
const { windowBarStyle } = custom;
|
||||
return {
|
||||
height:
|
||||
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
|
||||
? 'calc(100vh - 120px)'
|
||||
: 'calc(100vh - 90px)',
|
||||
position: 'absolute',
|
||||
top: '100vh',
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
width: '100vw',
|
||||
y: -100,
|
||||
};
|
||||
},
|
||||
open: (custom) => {
|
||||
const { dynamicBackground, background, windowBarStyle } = custom;
|
||||
return {
|
||||
background: dynamicBackground ? background : 'var(--main-bg)',
|
||||
height:
|
||||
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
|
||||
? 'calc(100vh - 120px)'
|
||||
: 'calc(100vh - 90px)',
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transition: {
|
||||
background: {
|
||||
duration: 0.5,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
delay: 0.1,
|
||||
duration: 0.5,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
width: '100vw',
|
||||
y: 0,
|
||||
};
|
||||
},
|
||||
closed: (custom) => {
|
||||
const { windowBarStyle } = custom;
|
||||
return {
|
||||
height:
|
||||
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
|
||||
? 'calc(100vh - 120px)'
|
||||
: 'calc(100vh - 90px)',
|
||||
position: 'absolute',
|
||||
top: '100vh',
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
width: '100vw',
|
||||
y: -100,
|
||||
};
|
||||
},
|
||||
open: (custom) => {
|
||||
const { dynamicBackground, background, windowBarStyle } = custom;
|
||||
return {
|
||||
background: dynamicBackground ? background : 'var(--main-bg)',
|
||||
height:
|
||||
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
|
||||
? 'calc(100vh - 120px)'
|
||||
: 'calc(100vh - 90px)',
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transition: {
|
||||
background: {
|
||||
duration: 0.5,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
delay: 0.1,
|
||||
duration: 0.5,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
width: '100vw',
|
||||
y: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const FullScreenPlayer = () => {
|
||||
const { dynamicBackground } = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const { dynamicBackground } = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
|
||||
const location = useLocation();
|
||||
const isOpenedRef = useRef<boolean | null>(null);
|
||||
const location = useLocation();
|
||||
const isOpenedRef = useRef<boolean | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isOpenedRef.current !== null) {
|
||||
setStore({ expanded: false });
|
||||
}
|
||||
useLayoutEffect(() => {
|
||||
if (isOpenedRef.current !== null) {
|
||||
setStore({ expanded: false });
|
||||
}
|
||||
|
||||
isOpenedRef.current = true;
|
||||
}, [location, setStore]);
|
||||
isOpenedRef.current = true;
|
||||
}, [location, setStore]);
|
||||
|
||||
const currentSong = useCurrentSong();
|
||||
const background = useFastAverageColor(currentSong?.imageUrl, true, 'dominant');
|
||||
const currentSong = useCurrentSong();
|
||||
const background = useFastAverageColor(currentSong?.imageUrl, true, 'dominant');
|
||||
|
||||
return (
|
||||
<Container
|
||||
animate="open"
|
||||
custom={{ background, dynamicBackground, windowBarStyle }}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
transition={{ duration: 2 }}
|
||||
variants={containerVariants}
|
||||
>
|
||||
<Controls />
|
||||
{dynamicBackground && <BackgroundImageOverlay />}
|
||||
<ResponsiveContainer>
|
||||
<FullScreenPlayerImage />
|
||||
<FullScreenPlayerQueue />
|
||||
</ResponsiveContainer>
|
||||
</Container>
|
||||
);
|
||||
return (
|
||||
<Container
|
||||
animate="open"
|
||||
custom={{ background, dynamicBackground, windowBarStyle }}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
transition={{ duration: 2 }}
|
||||
variants={containerVariants}
|
||||
>
|
||||
<Controls />
|
||||
{dynamicBackground && <BackgroundImageOverlay />}
|
||||
<ResponsiveContainer>
|
||||
<FullScreenPlayerImage />
|
||||
<FullScreenPlayerQueue />
|
||||
</ResponsiveContainer>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@ import styled from 'styled-components';
|
|||
import { Button, Text, Tooltip } from '/@/renderer/components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
useAppStoreActions,
|
||||
useCurrentSong,
|
||||
useSetFullScreenPlayerStore,
|
||||
useFullScreenPlayerStore,
|
||||
useSidebarStore,
|
||||
useHotkeySettings,
|
||||
useAppStoreActions,
|
||||
useCurrentSong,
|
||||
useSetFullScreenPlayerStore,
|
||||
useFullScreenPlayerStore,
|
||||
useSidebarStore,
|
||||
useHotkeySettings,
|
||||
} from '/@/renderer/store';
|
||||
import { fadeIn } from '/@/renderer/styles';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
|
|
@ -21,254 +21,261 @@ import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/conte
|
|||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
|
||||
|
||||
const ImageWrapper = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem 1rem 1rem 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem 1rem 1rem 0;
|
||||
`;
|
||||
|
||||
const MetadataStack = styled(motion.div)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Image = styled(motion.div)`
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: var(--placeholder-bg);
|
||||
cursor: pointer;
|
||||
filter: drop-shadow(0 5px 6px rgb(0, 0, 0, 50%));
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: var(--placeholder-bg);
|
||||
cursor: pointer;
|
||||
filter: drop-shadow(0 5px 6px rgb(0, 0, 0, 50%));
|
||||
|
||||
${fadeIn};
|
||||
animation: fadein 0.2s ease-in-out;
|
||||
${fadeIn};
|
||||
animation: fadein 0.2s ease-in-out;
|
||||
|
||||
button {
|
||||
display: none;
|
||||
}
|
||||
button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover button {
|
||||
display: block;
|
||||
}
|
||||
&:hover button {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
const PlayerbarImage = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
const LineItem = styled.div<{ $secondary?: boolean }>`
|
||||
display: inline-block;
|
||||
width: 95%;
|
||||
max-width: 20vw;
|
||||
overflow: hidden;
|
||||
color: ${(props) => props.$secondary && 'var(--main-fg-secondary)'};
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
width: 95%;
|
||||
max-width: 20vw;
|
||||
overflow: hidden;
|
||||
color: ${(props) => props.$secondary && 'var(--main-fg-secondary)'};
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
a {
|
||||
color: ${(props) => props.$secondary && 'var(--text-secondary)'};
|
||||
}
|
||||
a {
|
||||
color: ${(props) => props.$secondary && 'var(--text-secondary)'};
|
||||
}
|
||||
`;
|
||||
|
||||
const LeftControlsContainer = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-left: 1rem;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-left: 1rem;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
${ImageWrapper} {
|
||||
display: none;
|
||||
@media (max-width: 640px) {
|
||||
${ImageWrapper} {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LeftControls = () => {
|
||||
const { setSideBar } = useAppStoreActions();
|
||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||
const { image, collapsed } = useSidebarStore();
|
||||
const hideImage = image && !collapsed;
|
||||
const currentSong = useCurrentSong();
|
||||
const title = currentSong?.name;
|
||||
const artists = currentSong?.artists;
|
||||
const { bindings } = useHotkeySettings();
|
||||
const { setSideBar } = useAppStoreActions();
|
||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||
const { image, collapsed } = useSidebarStore();
|
||||
const hideImage = image && !collapsed;
|
||||
const currentSong = useCurrentSong();
|
||||
const title = currentSong?.name;
|
||||
const artists = currentSong?.artists;
|
||||
const { bindings } = useHotkeySettings();
|
||||
|
||||
const isSongDefined = Boolean(currentSong?.id);
|
||||
const isSongDefined = Boolean(currentSong?.id);
|
||||
|
||||
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
||||
LibraryItem.SONG,
|
||||
SONG_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
||||
LibraryItem.SONG,
|
||||
SONG_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
const handleToggleFullScreenPlayer = (e?: MouseEvent<HTMLDivElement> | KeyboardEvent) => {
|
||||
e?.stopPropagation();
|
||||
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
|
||||
};
|
||||
const handleToggleFullScreenPlayer = (e?: MouseEvent<HTMLDivElement> | KeyboardEvent) => {
|
||||
e?.stopPropagation();
|
||||
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
|
||||
};
|
||||
|
||||
const handleToggleSidebarImage = (e?: MouseEvent<HTMLButtonElement>) => {
|
||||
e?.stopPropagation();
|
||||
setSideBar({ image: true });
|
||||
};
|
||||
const handleToggleSidebarImage = (e?: MouseEvent<HTMLButtonElement>) => {
|
||||
e?.stopPropagation();
|
||||
setSideBar({ image: true });
|
||||
};
|
||||
|
||||
useHotkeys([
|
||||
[
|
||||
bindings.toggleFullscreenPlayer.allowGlobal ? '' : bindings.toggleFullscreenPlayer.hotkey,
|
||||
handleToggleFullScreenPlayer,
|
||||
],
|
||||
]);
|
||||
useHotkeys([
|
||||
[
|
||||
bindings.toggleFullscreenPlayer.allowGlobal
|
||||
? ''
|
||||
: bindings.toggleFullscreenPlayer.hotkey,
|
||||
handleToggleFullScreenPlayer,
|
||||
],
|
||||
]);
|
||||
|
||||
return (
|
||||
<LeftControlsContainer>
|
||||
<LayoutGroup>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{!hideImage && (
|
||||
<ImageWrapper>
|
||||
<Image
|
||||
key="playerbar-image"
|
||||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
role="button"
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
>
|
||||
<Tooltip
|
||||
label="Toggle fullscreen player"
|
||||
openDelay={500}
|
||||
return (
|
||||
<LeftControlsContainer>
|
||||
<LayoutGroup>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{currentSong?.imageUrl ? (
|
||||
<PlayerbarImage
|
||||
loading="eager"
|
||||
src={currentSong?.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<RiDiscLine
|
||||
color="var(--placeholder-fg)"
|
||||
size={50}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
</Tooltip>
|
||||
{!hideImage && (
|
||||
<ImageWrapper>
|
||||
<Image
|
||||
key="playerbar-image"
|
||||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
role="button"
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
>
|
||||
<Tooltip
|
||||
label="Toggle fullscreen player"
|
||||
openDelay={500}
|
||||
>
|
||||
{currentSong?.imageUrl ? (
|
||||
<PlayerbarImage
|
||||
loading="eager"
|
||||
src={currentSong?.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<RiDiscLine
|
||||
color="var(--placeholder-fg)"
|
||||
size={50}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
{!collapsed && (
|
||||
<Button
|
||||
compact
|
||||
opacity={0.8}
|
||||
radius={50}
|
||||
size="md"
|
||||
sx={{ cursor: 'default', position: 'absolute', right: 2, top: 2 }}
|
||||
tooltip={{ label: 'Expand', openDelay: 500 }}
|
||||
variant="default"
|
||||
onClick={handleToggleSidebarImage}
|
||||
>
|
||||
<RiArrowUpSLine
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Image>
|
||||
</ImageWrapper>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<MetadataStack layout="position">
|
||||
<LineItem>
|
||||
<Group
|
||||
noWrap
|
||||
align="flex-start"
|
||||
spacing="xs"
|
||||
>
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={AppRoute.NOW_PLAYING}
|
||||
weight={500}
|
||||
>
|
||||
{title || '—'}
|
||||
</Text>
|
||||
{isSongDefined && (
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
onClick={(e) => handleGeneralContextMenu(e, [currentSong!])}
|
||||
>
|
||||
<RiMore2Fill size="1.2rem" />
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</LineItem>
|
||||
<LineItem $secondary>
|
||||
{artists?.map((artist, index) => (
|
||||
<React.Fragment key={`bar-${artist.id}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
size="md"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={
|
||||
artist.id
|
||||
? generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
weight={500}
|
||||
>
|
||||
{artist.name || '—'}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</LineItem>
|
||||
<LineItem $secondary>
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={
|
||||
currentSong?.albumId
|
||||
? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: currentSong.albumId,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
weight={500}
|
||||
>
|
||||
{currentSong?.album || '—'}
|
||||
</Text>
|
||||
</LineItem>
|
||||
</MetadataStack>
|
||||
</LayoutGroup>
|
||||
</LeftControlsContainer>
|
||||
);
|
||||
{!collapsed && (
|
||||
<Button
|
||||
compact
|
||||
opacity={0.8}
|
||||
radius={50}
|
||||
size="md"
|
||||
sx={{
|
||||
cursor: 'default',
|
||||
position: 'absolute',
|
||||
right: 2,
|
||||
top: 2,
|
||||
}}
|
||||
tooltip={{ label: 'Expand', openDelay: 500 }}
|
||||
variant="default"
|
||||
onClick={handleToggleSidebarImage}
|
||||
>
|
||||
<RiArrowUpSLine
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Image>
|
||||
</ImageWrapper>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<MetadataStack layout="position">
|
||||
<LineItem>
|
||||
<Group
|
||||
noWrap
|
||||
align="flex-start"
|
||||
spacing="xs"
|
||||
>
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={AppRoute.NOW_PLAYING}
|
||||
weight={500}
|
||||
>
|
||||
{title || '—'}
|
||||
</Text>
|
||||
{isSongDefined && (
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
onClick={(e) => handleGeneralContextMenu(e, [currentSong!])}
|
||||
>
|
||||
<RiMore2Fill size="1.2rem" />
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</LineItem>
|
||||
<LineItem $secondary>
|
||||
{artists?.map((artist, index) => (
|
||||
<React.Fragment key={`bar-${artist.id}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
size="md"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={
|
||||
artist.id
|
||||
? generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
weight={500}
|
||||
>
|
||||
{artist.name || '—'}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</LineItem>
|
||||
<LineItem $secondary>
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={
|
||||
currentSong?.albumId
|
||||
? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: currentSong.albumId,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
weight={500}
|
||||
>
|
||||
{currentSong?.album || '—'}
|
||||
</Text>
|
||||
</LineItem>
|
||||
</MetadataStack>
|
||||
</LayoutGroup>
|
||||
</LeftControlsContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,153 +8,154 @@ import { Tooltip } from '/@/renderer/components';
|
|||
|
||||
type MantineButtonProps = UnstyledButtonProps & ComponentPropsWithoutRef<'button'>;
|
||||
interface PlayerButtonProps extends MantineButtonProps {
|
||||
$isActive?: boolean;
|
||||
icon: ReactNode;
|
||||
tooltip?: Omit<TooltipProps, 'children'>;
|
||||
variant: 'main' | 'secondary' | 'tertiary';
|
||||
$isActive?: boolean;
|
||||
icon: ReactNode;
|
||||
tooltip?: Omit<TooltipProps, 'children'>;
|
||||
variant: 'main' | 'secondary' | 'tertiary';
|
||||
}
|
||||
|
||||
const WrapperMainVariant = css`
|
||||
margin: 0 0.5rem;
|
||||
margin: 0 0.5rem;
|
||||
`;
|
||||
|
||||
type MotionWrapperProps = { variant: PlayerButtonProps['variant'] };
|
||||
|
||||
const MotionWrapper = styled(motion.div)<MotionWrapperProps>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
${({ variant }) => variant === 'main' && WrapperMainVariant};
|
||||
${({ variant }) => variant === 'main' && WrapperMainVariant};
|
||||
`;
|
||||
|
||||
const ButtonMainVariant = css`
|
||||
padding: 0.5rem;
|
||||
background: var(--playerbar-btn-main-bg);
|
||||
border-radius: 50%;
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
fill: var(--playerbar-btn-main-fg);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background: var(--playerbar-btn-main-bg-hover);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--playerbar-btn-main-bg-hover);
|
||||
padding: 0.5rem;
|
||||
background: var(--playerbar-btn-main-bg);
|
||||
border-radius: 50%;
|
||||
|
||||
svg {
|
||||
fill: var(--playerbar-btn-main-fg-hover);
|
||||
display: flex;
|
||||
fill: var(--playerbar-btn-main-fg);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background: var(--playerbar-btn-main-bg-hover);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--playerbar-btn-main-bg-hover);
|
||||
|
||||
svg {
|
||||
fill: var(--playerbar-btn-main-fg-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ButtonSecondaryVariant = css`
|
||||
padding: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
`;
|
||||
|
||||
const ButtonTertiaryVariant = css`
|
||||
padding: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
svg {
|
||||
fill: var(--playerbar-btn-fg-hover);
|
||||
stroke: var(--playerbar-btn-fg-hover);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
svg {
|
||||
fill: var(--playerbar-btn-fg-hover);
|
||||
stroke: var(--playerbar-btn-fg-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type StyledPlayerButtonProps = Omit<PlayerButtonProps, 'icon'>;
|
||||
|
||||
const StyledPlayerButton = styled(UnstyledButton)<StyledPlayerButtonProps>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
overflow: visible;
|
||||
background: var(--playerbar-btn-bg-hover);
|
||||
all: unset;
|
||||
cursor: default;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
overflow: visible;
|
||||
background: var(--playerbar-btn-bg-hover);
|
||||
outline: 1px var(--primary-color) solid;
|
||||
}
|
||||
all: unset;
|
||||
cursor: default;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
button {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
fill: ${({ $isActive }) => ($isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)')};
|
||||
stroke: var(--playerbar-btn-fg);
|
||||
}
|
||||
&:focus-visible {
|
||||
background: var(--playerbar-btn-bg-hover);
|
||||
outline: 1px var(--primary-color) solid;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--playerbar-btn-bg-hover);
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: ${({ $isActive }) =>
|
||||
$isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg-hover)'};
|
||||
display: flex;
|
||||
fill: ${({ $isActive }) =>
|
||||
$isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)'};
|
||||
stroke: var(--playerbar-btn-fg);
|
||||
}
|
||||
}
|
||||
|
||||
${({ variant }) =>
|
||||
variant === 'main'
|
||||
? ButtonMainVariant
|
||||
: variant === 'secondary'
|
||||
? ButtonSecondaryVariant
|
||||
: ButtonTertiaryVariant};
|
||||
&:hover {
|
||||
background: var(--playerbar-btn-bg-hover);
|
||||
|
||||
svg {
|
||||
fill: ${({ $isActive }) =>
|
||||
$isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg-hover)'};
|
||||
}
|
||||
}
|
||||
|
||||
${({ variant }) =>
|
||||
variant === 'main'
|
||||
? ButtonMainVariant
|
||||
: variant === 'secondary'
|
||||
? ButtonSecondaryVariant
|
||||
: ButtonTertiaryVariant};
|
||||
`;
|
||||
|
||||
export const PlayerButton = forwardRef<HTMLDivElement, PlayerButtonProps>(
|
||||
({ tooltip, variant, icon, ...rest }: PlayerButtonProps, ref) => {
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip {...tooltip}>
|
||||
<MotionWrapper
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
>
|
||||
<StyledPlayerButton
|
||||
variant={variant}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
</StyledPlayerButton>
|
||||
</MotionWrapper>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
({ tooltip, variant, icon, ...rest }: PlayerButtonProps, ref) => {
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip {...tooltip}>
|
||||
<MotionWrapper
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
>
|
||||
<StyledPlayerButton
|
||||
variant={variant}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
</StyledPlayerButton>
|
||||
</MotionWrapper>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MotionWrapper
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
>
|
||||
<StyledPlayerButton
|
||||
variant={variant}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
</StyledPlayerButton>
|
||||
</MotionWrapper>
|
||||
);
|
||||
},
|
||||
return (
|
||||
<MotionWrapper
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
>
|
||||
<StyledPlayerButton
|
||||
variant={variant}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
</StyledPlayerButton>
|
||||
</MotionWrapper>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PlayerButton.defaultProps = {
|
||||
$isActive: false,
|
||||
tooltip: undefined,
|
||||
$isActive: false,
|
||||
tooltip: undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,46 +1,46 @@
|
|||
import { rem, Slider, SliderProps } from '@mantine/core';
|
||||
|
||||
export const PlayerbarSlider = ({ ...props }: SliderProps) => {
|
||||
return (
|
||||
<Slider
|
||||
styles={{
|
||||
bar: {
|
||||
backgroundColor: 'var(--playerbar-slider-track-progress-bg)',
|
||||
transition: 'background-color 0.2s ease',
|
||||
},
|
||||
label: {
|
||||
backgroundColor: 'var(--tooltip-bg)',
|
||||
color: 'var(--tooltip-fg)',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
padding: '0 1rem',
|
||||
},
|
||||
root: {
|
||||
'&:hover': {
|
||||
'& .mantine-Slider-bar': {
|
||||
backgroundColor: 'var(--primary-color)',
|
||||
},
|
||||
'& .mantine-Slider-thumb': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
thumb: {
|
||||
backgroundColor: 'var(--slider-thumb-bg)',
|
||||
borderColor: 'var(--primary-color)',
|
||||
borderWidth: rem(1),
|
||||
height: '1rem',
|
||||
opacity: 0,
|
||||
width: '1rem',
|
||||
},
|
||||
track: {
|
||||
'&::before': {
|
||||
backgroundColor: 'var(--playerbar-slider-track-bg)',
|
||||
right: 'calc(0.1rem * -1)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Slider
|
||||
styles={{
|
||||
bar: {
|
||||
backgroundColor: 'var(--playerbar-slider-track-progress-bg)',
|
||||
transition: 'background-color 0.2s ease',
|
||||
},
|
||||
label: {
|
||||
backgroundColor: 'var(--tooltip-bg)',
|
||||
color: 'var(--tooltip-fg)',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
padding: '0 1rem',
|
||||
},
|
||||
root: {
|
||||
'&:hover': {
|
||||
'& .mantine-Slider-bar': {
|
||||
backgroundColor: 'var(--primary-color)',
|
||||
},
|
||||
'& .mantine-Slider-thumb': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
thumb: {
|
||||
backgroundColor: 'var(--slider-thumb-bg)',
|
||||
borderColor: 'var(--primary-color)',
|
||||
borderWidth: rem(1),
|
||||
height: '1rem',
|
||||
opacity: 0,
|
||||
width: '1rem',
|
||||
},
|
||||
track: {
|
||||
'&::before': {
|
||||
backgroundColor: 'var(--playerbar-slider-track-bg)',
|
||||
right: 'calc(0.1rem * -1)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ import { useSettingsStore } from '/@/renderer/store/settings.store';
|
|||
import { PlaybackType } from '/@/renderer/types';
|
||||
import { AudioPlayer } from '/@/renderer/components';
|
||||
import {
|
||||
useCurrentPlayer,
|
||||
useCurrentStatus,
|
||||
useMuted,
|
||||
usePlayer1Data,
|
||||
usePlayer2Data,
|
||||
usePlayerControls,
|
||||
useVolume,
|
||||
useCurrentPlayer,
|
||||
useCurrentStatus,
|
||||
useMuted,
|
||||
usePlayer1Data,
|
||||
usePlayer2Data,
|
||||
usePlayerControls,
|
||||
useVolume,
|
||||
} from '/@/renderer/store';
|
||||
import { CenterControls } from './center-controls';
|
||||
import { LeftControls } from './left-controls';
|
||||
|
|
@ -19,97 +19,97 @@ import { RightControls } from './right-controls';
|
|||
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
|
||||
|
||||
const PlayerbarContainer = styled.div`
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
border-top: var(--playerbar-border-top);
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
border-top: var(--playerbar-border-top);
|
||||
`;
|
||||
|
||||
const PlayerbarControlsGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: minmax(0, 0.5fr) minmax(0, 1fr) minmax(0, 0.5fr);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: minmax(0, 0.5fr) minmax(0, 1fr) minmax(0, 0.5fr);
|
||||
}
|
||||
`;
|
||||
|
||||
const RightGridItem = styled.div`
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const LeftGridItem = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const CenterGridItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const utils = isElectron() ? window.electron.utils : null;
|
||||
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
||||
|
||||
export const Playerbar = () => {
|
||||
const playersRef = PlayersRef;
|
||||
const settings = useSettingsStore((state) => state.playback);
|
||||
const volume = useVolume();
|
||||
const player1 = usePlayer1Data();
|
||||
const player2 = usePlayer2Data();
|
||||
const status = useCurrentStatus();
|
||||
const player = useCurrentPlayer();
|
||||
const muted = useMuted();
|
||||
const { autoNext } = usePlayerControls();
|
||||
const playersRef = PlayersRef;
|
||||
const settings = useSettingsStore((state) => state.playback);
|
||||
const volume = useVolume();
|
||||
const player1 = usePlayer1Data();
|
||||
const player2 = usePlayer2Data();
|
||||
const status = useCurrentStatus();
|
||||
const player = useCurrentPlayer();
|
||||
const muted = useMuted();
|
||||
const { autoNext } = usePlayerControls();
|
||||
|
||||
const autoNextFn = useCallback(() => {
|
||||
const playerData = autoNext();
|
||||
mpris?.updateSong({
|
||||
currentTime: 0,
|
||||
song: playerData.current.song,
|
||||
});
|
||||
}, [autoNext]);
|
||||
const autoNextFn = useCallback(() => {
|
||||
const playerData = autoNext();
|
||||
mpris?.updateSong({
|
||||
currentTime: 0,
|
||||
song: playerData.current.song,
|
||||
});
|
||||
}, [autoNext]);
|
||||
|
||||
return (
|
||||
<PlayerbarContainer>
|
||||
<PlayerbarControlsGrid>
|
||||
<LeftGridItem>
|
||||
<LeftControls />
|
||||
</LeftGridItem>
|
||||
<CenterGridItem>
|
||||
<CenterControls playersRef={playersRef} />
|
||||
</CenterGridItem>
|
||||
<RightGridItem>
|
||||
<RightControls />
|
||||
</RightGridItem>
|
||||
</PlayerbarControlsGrid>
|
||||
{settings.type === PlaybackType.WEB && (
|
||||
<AudioPlayer
|
||||
ref={playersRef}
|
||||
autoNext={autoNextFn}
|
||||
crossfadeDuration={settings.crossfadeDuration}
|
||||
crossfadeStyle={settings.crossfadeStyle}
|
||||
currentPlayer={player}
|
||||
muted={muted}
|
||||
playbackStyle={settings.style}
|
||||
player1={player1}
|
||||
player2={player2}
|
||||
status={status}
|
||||
style={settings.style}
|
||||
volume={(volume / 100) ** 2}
|
||||
/>
|
||||
)}
|
||||
</PlayerbarContainer>
|
||||
);
|
||||
return (
|
||||
<PlayerbarContainer>
|
||||
<PlayerbarControlsGrid>
|
||||
<LeftGridItem>
|
||||
<LeftControls />
|
||||
</LeftGridItem>
|
||||
<CenterGridItem>
|
||||
<CenterControls playersRef={playersRef} />
|
||||
</CenterGridItem>
|
||||
<RightGridItem>
|
||||
<RightControls />
|
||||
</RightGridItem>
|
||||
</PlayerbarControlsGrid>
|
||||
{settings.type === PlaybackType.WEB && (
|
||||
<AudioPlayer
|
||||
ref={playersRef}
|
||||
autoNext={autoNextFn}
|
||||
crossfadeDuration={settings.crossfadeDuration}
|
||||
crossfadeStyle={settings.crossfadeStyle}
|
||||
currentPlayer={player}
|
||||
muted={muted}
|
||||
playbackStyle={settings.style}
|
||||
player1={player1}
|
||||
player2={player2}
|
||||
status={status}
|
||||
style={settings.style}
|
||||
volume={(volume / 100) ** 2}
|
||||
/>
|
||||
)}
|
||||
</PlayerbarContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,20 +3,20 @@ import { Flex, Group } from '@mantine/core';
|
|||
import { useHotkeys, useMediaQuery } from '@mantine/hooks';
|
||||
import { HiOutlineQueueList } from 'react-icons/hi2';
|
||||
import {
|
||||
RiVolumeUpFill,
|
||||
RiVolumeDownFill,
|
||||
RiVolumeMuteFill,
|
||||
RiHeartLine,
|
||||
RiHeartFill,
|
||||
RiVolumeUpFill,
|
||||
RiVolumeDownFill,
|
||||
RiVolumeMuteFill,
|
||||
RiHeartLine,
|
||||
RiHeartFill,
|
||||
} from 'react-icons/ri';
|
||||
import {
|
||||
useAppStoreActions,
|
||||
useCurrentServer,
|
||||
useCurrentSong,
|
||||
useHotkeySettings,
|
||||
useMuted,
|
||||
useSidebarStore,
|
||||
useVolume,
|
||||
useAppStoreActions,
|
||||
useCurrentServer,
|
||||
useCurrentSong,
|
||||
useHotkeySettings,
|
||||
useMuted,
|
||||
useSidebarStore,
|
||||
useVolume,
|
||||
} from '/@/renderer/store';
|
||||
import { useRightControls } from '../hooks/use-right-controls';
|
||||
import { PlayerButton } from './player-button';
|
||||
|
|
@ -26,178 +26,180 @@ import { Rating } from '/@/renderer/components';
|
|||
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
||||
|
||||
export const RightControls = () => {
|
||||
const isMinWidth = useMediaQuery('(max-width: 480px)');
|
||||
const volume = useVolume();
|
||||
const muted = useMuted();
|
||||
const server = useCurrentServer();
|
||||
const currentSong = useCurrentSong();
|
||||
const { setSideBar } = useAppStoreActions();
|
||||
const { rightExpanded: isQueueExpanded } = useSidebarStore();
|
||||
const { bindings } = useHotkeySettings();
|
||||
const { handleVolumeSlider, handleVolumeWheel, handleMute, handleVolumeDown, handleVolumeUp } =
|
||||
useRightControls();
|
||||
const isMinWidth = useMediaQuery('(max-width: 480px)');
|
||||
const volume = useVolume();
|
||||
const muted = useMuted();
|
||||
const server = useCurrentServer();
|
||||
const currentSong = useCurrentSong();
|
||||
const { setSideBar } = useAppStoreActions();
|
||||
const { rightExpanded: isQueueExpanded } = useSidebarStore();
|
||||
const { bindings } = useHotkeySettings();
|
||||
const { handleVolumeSlider, handleVolumeWheel, handleMute, handleVolumeDown, handleVolumeUp } =
|
||||
useRightControls();
|
||||
|
||||
const updateRatingMutation = useSetRating({});
|
||||
const addToFavoritesMutation = useCreateFavorite({});
|
||||
const removeFromFavoritesMutation = useDeleteFavorite({});
|
||||
const updateRatingMutation = useSetRating({});
|
||||
const addToFavoritesMutation = useCreateFavorite({});
|
||||
const removeFromFavoritesMutation = useDeleteFavorite({});
|
||||
|
||||
const handleAddToFavorites = () => {
|
||||
if (!currentSong) return;
|
||||
const handleAddToFavorites = () => {
|
||||
if (!currentSong) return;
|
||||
|
||||
addToFavoritesMutation.mutate({
|
||||
query: {
|
||||
id: [currentSong.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!currentSong) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
query: {
|
||||
item: [currentSong],
|
||||
rating,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearRating = (_e: MouseEvent<HTMLDivElement>, rating?: number) => {
|
||||
if (!currentSong || !rating) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
query: {
|
||||
item: [currentSong],
|
||||
rating: 0,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = () => {
|
||||
if (!currentSong) return;
|
||||
|
||||
removeFromFavoritesMutation.mutate({
|
||||
query: {
|
||||
id: [currentSong.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
if (!currentSong) return;
|
||||
|
||||
if (currentSong.userFavorite) {
|
||||
handleRemoveFromFavorites();
|
||||
} else {
|
||||
handleAddToFavorites();
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleQueue = () => {
|
||||
setSideBar({ rightExpanded: !isQueueExpanded });
|
||||
};
|
||||
|
||||
const isSongDefined = Boolean(currentSong?.id);
|
||||
const showRating = isSongDefined && server?.type === ServerType.NAVIDROME;
|
||||
|
||||
useHotkeys([
|
||||
[bindings.volumeDown.isGlobal ? '' : bindings.volumeDown.hotkey, handleVolumeDown],
|
||||
[bindings.volumeUp.isGlobal ? '' : bindings.volumeUp.hotkey, handleVolumeUp],
|
||||
[bindings.volumeMute.isGlobal ? '' : bindings.volumeMute.hotkey, handleMute],
|
||||
[bindings.toggleQueue.isGlobal ? '' : bindings.toggleQueue.hotkey, handleToggleQueue],
|
||||
]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="flex-end"
|
||||
direction="column"
|
||||
h="100%"
|
||||
px="1rem"
|
||||
py="0.5rem"
|
||||
>
|
||||
<Group h="calc(100% / 3)">
|
||||
{showRating && (
|
||||
<Rating
|
||||
size="sm"
|
||||
value={currentSong?.userRating || 0}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Group
|
||||
noWrap
|
||||
align="center"
|
||||
spacing="xs"
|
||||
>
|
||||
<PlayerButton
|
||||
icon={
|
||||
currentSong?.userFavorite ? (
|
||||
<RiHeartFill
|
||||
color="var(--primary-color)"
|
||||
size="1.1rem"
|
||||
/>
|
||||
) : (
|
||||
<RiHeartLine size="1.1rem" />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
svg: {
|
||||
fill: !currentSong?.userFavorite ? undefined : 'var(--primary-color) !important',
|
||||
addToFavoritesMutation.mutate({
|
||||
query: {
|
||||
id: [currentSong.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
}}
|
||||
tooltip={{
|
||||
label: currentSong?.userFavorite ? 'Unfavorite' : 'Favorite',
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={handleToggleFavorite}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<HiOutlineQueueList size="1.1rem" />}
|
||||
tooltip={{ label: 'View queue', openDelay: 500 }}
|
||||
variant="secondary"
|
||||
onClick={handleToggleQueue}
|
||||
/>
|
||||
<Group
|
||||
noWrap
|
||||
spacing="xs"
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!currentSong) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
query: {
|
||||
item: [currentSong],
|
||||
rating,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearRating = (_e: MouseEvent<HTMLDivElement>, rating?: number) => {
|
||||
if (!currentSong || !rating) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
query: {
|
||||
item: [currentSong],
|
||||
rating: 0,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = () => {
|
||||
if (!currentSong) return;
|
||||
|
||||
removeFromFavoritesMutation.mutate({
|
||||
query: {
|
||||
id: [currentSong.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
if (!currentSong) return;
|
||||
|
||||
if (currentSong.userFavorite) {
|
||||
handleRemoveFromFavorites();
|
||||
} else {
|
||||
handleAddToFavorites();
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleQueue = () => {
|
||||
setSideBar({ rightExpanded: !isQueueExpanded });
|
||||
};
|
||||
|
||||
const isSongDefined = Boolean(currentSong?.id);
|
||||
const showRating = isSongDefined && server?.type === ServerType.NAVIDROME;
|
||||
|
||||
useHotkeys([
|
||||
[bindings.volumeDown.isGlobal ? '' : bindings.volumeDown.hotkey, handleVolumeDown],
|
||||
[bindings.volumeUp.isGlobal ? '' : bindings.volumeUp.hotkey, handleVolumeUp],
|
||||
[bindings.volumeMute.isGlobal ? '' : bindings.volumeMute.hotkey, handleMute],
|
||||
[bindings.toggleQueue.isGlobal ? '' : bindings.toggleQueue.hotkey, handleToggleQueue],
|
||||
]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="flex-end"
|
||||
direction="column"
|
||||
h="100%"
|
||||
px="1rem"
|
||||
py="0.5rem"
|
||||
>
|
||||
<PlayerButton
|
||||
icon={
|
||||
muted ? (
|
||||
<RiVolumeMuteFill size="1.2rem" />
|
||||
) : volume > 50 ? (
|
||||
<RiVolumeUpFill size="1.2rem" />
|
||||
) : (
|
||||
<RiVolumeDownFill size="1.2rem" />
|
||||
)
|
||||
}
|
||||
tooltip={{ label: muted ? 'Muted' : volume, openDelay: 500 }}
|
||||
variant="secondary"
|
||||
onClick={handleMute}
|
||||
onWheel={handleVolumeWheel}
|
||||
/>
|
||||
{!isMinWidth ? (
|
||||
<PlayerbarSlider
|
||||
max={100}
|
||||
min={0}
|
||||
size={6}
|
||||
value={volume}
|
||||
w="60px"
|
||||
onChange={handleVolumeSlider}
|
||||
onWheel={handleVolumeWheel}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
<Group h="calc(100% / 3)" />
|
||||
</Flex>
|
||||
);
|
||||
<Group h="calc(100% / 3)">
|
||||
{showRating && (
|
||||
<Rating
|
||||
size="sm"
|
||||
value={currentSong?.userRating || 0}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Group
|
||||
noWrap
|
||||
align="center"
|
||||
spacing="xs"
|
||||
>
|
||||
<PlayerButton
|
||||
icon={
|
||||
currentSong?.userFavorite ? (
|
||||
<RiHeartFill
|
||||
color="var(--primary-color)"
|
||||
size="1.1rem"
|
||||
/>
|
||||
) : (
|
||||
<RiHeartLine size="1.1rem" />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
svg: {
|
||||
fill: !currentSong?.userFavorite
|
||||
? undefined
|
||||
: 'var(--primary-color) !important',
|
||||
},
|
||||
}}
|
||||
tooltip={{
|
||||
label: currentSong?.userFavorite ? 'Unfavorite' : 'Favorite',
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={handleToggleFavorite}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<HiOutlineQueueList size="1.1rem" />}
|
||||
tooltip={{ label: 'View queue', openDelay: 500 }}
|
||||
variant="secondary"
|
||||
onClick={handleToggleQueue}
|
||||
/>
|
||||
<Group
|
||||
noWrap
|
||||
spacing="xs"
|
||||
>
|
||||
<PlayerButton
|
||||
icon={
|
||||
muted ? (
|
||||
<RiVolumeMuteFill size="1.2rem" />
|
||||
) : volume > 50 ? (
|
||||
<RiVolumeUpFill size="1.2rem" />
|
||||
) : (
|
||||
<RiVolumeDownFill size="1.2rem" />
|
||||
)
|
||||
}
|
||||
tooltip={{ label: muted ? 'Muted' : volume, openDelay: 500 }}
|
||||
variant="secondary"
|
||||
onClick={handleMute}
|
||||
onWheel={handleVolumeWheel}
|
||||
/>
|
||||
{!isMinWidth ? (
|
||||
<PlayerbarSlider
|
||||
max={100}
|
||||
min={0}
|
||||
size={6}
|
||||
value={volume}
|
||||
w="60px"
|
||||
onChange={handleVolumeSlider}
|
||||
onWheel={handleVolumeWheel}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
<Group h="calc(100% / 3)" />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ import { create } from 'zustand';
|
|||
import { persist } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import {
|
||||
GenreListResponse,
|
||||
RandomSongListQuery,
|
||||
MusicFolderListResponse,
|
||||
ServerType,
|
||||
GenreListResponse,
|
||||
RandomSongListQuery,
|
||||
MusicFolderListResponse,
|
||||
ServerType,
|
||||
} from '/@/renderer/api/types';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
|
|
@ -20,240 +20,240 @@ import { queryKeys } from '/@/renderer/api/query-keys';
|
|||
import { Play, PlayQueueAddOptions, ServerListItem } from '/@/renderer/types';
|
||||
|
||||
interface ShuffleAllSlice extends RandomSongListQuery {
|
||||
actions: {
|
||||
setStore: (data: Partial<ShuffleAllSlice>) => void;
|
||||
};
|
||||
enableMaxYear: boolean;
|
||||
enableMinYear: boolean;
|
||||
actions: {
|
||||
setStore: (data: Partial<ShuffleAllSlice>) => void;
|
||||
};
|
||||
enableMaxYear: boolean;
|
||||
enableMinYear: boolean;
|
||||
}
|
||||
|
||||
const useShuffleAllStore = create<ShuffleAllSlice>()(
|
||||
persist(
|
||||
immer((set, get) => ({
|
||||
actions: {
|
||||
setStore: (data) => {
|
||||
set({ ...get(), ...data });
|
||||
persist(
|
||||
immer((set, get) => ({
|
||||
actions: {
|
||||
setStore: (data) => {
|
||||
set({ ...get(), ...data });
|
||||
},
|
||||
},
|
||||
enableMaxYear: false,
|
||||
enableMinYear: false,
|
||||
genre: '',
|
||||
maxYear: 2020,
|
||||
minYear: 2000,
|
||||
musicFolder: '',
|
||||
songCount: 100,
|
||||
})),
|
||||
{
|
||||
merge: (persistedState, currentState) => merge(currentState, persistedState),
|
||||
name: 'store_shuffle_all',
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
enableMaxYear: false,
|
||||
enableMinYear: false,
|
||||
genre: '',
|
||||
maxYear: 2020,
|
||||
minYear: 2000,
|
||||
musicFolder: '',
|
||||
songCount: 100,
|
||||
})),
|
||||
{
|
||||
merge: (persistedState, currentState) => merge(currentState, persistedState),
|
||||
name: 'store_shuffle_all',
|
||||
version: 1,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
export const useShuffleAllStoreActions = () => useShuffleAllStore((state) => state.actions);
|
||||
|
||||
interface ShuffleAllModalProps {
|
||||
genres: GenreListResponse | undefined;
|
||||
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
|
||||
musicFolders: MusicFolderListResponse | undefined;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem | null;
|
||||
genres: GenreListResponse | undefined;
|
||||
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
|
||||
musicFolders: MusicFolderListResponse | undefined;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem | null;
|
||||
}
|
||||
|
||||
export const ShuffleAllModal = ({
|
||||
handlePlayQueueAdd,
|
||||
queryClient,
|
||||
server,
|
||||
genres,
|
||||
musicFolders,
|
||||
handlePlayQueueAdd,
|
||||
queryClient,
|
||||
server,
|
||||
genres,
|
||||
musicFolders,
|
||||
}: ShuffleAllModalProps) => {
|
||||
const { genre, limit, maxYear, minYear, enableMaxYear, enableMinYear, musicFolderId } =
|
||||
useShuffleAllStore();
|
||||
const { setStore } = useShuffleAllStoreActions();
|
||||
const { genre, limit, maxYear, minYear, enableMaxYear, enableMinYear, musicFolderId } =
|
||||
useShuffleAllStore();
|
||||
const { setStore } = useShuffleAllStoreActions();
|
||||
|
||||
const handlePlay = async (playType: Play) => {
|
||||
const res = await queryClient.fetchQuery({
|
||||
cacheTime: 0,
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getRandomSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
genre: genre || undefined,
|
||||
limit,
|
||||
maxYear: enableMaxYear ? maxYear || undefined : undefined,
|
||||
minYear: enableMinYear ? minYear || undefined : undefined,
|
||||
musicFolderId: musicFolderId || undefined,
|
||||
},
|
||||
}),
|
||||
queryKey: queryKeys.songs.randomSongList(server?.id),
|
||||
staleTime: 0,
|
||||
});
|
||||
const handlePlay = async (playType: Play) => {
|
||||
const res = await queryClient.fetchQuery({
|
||||
cacheTime: 0,
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getRandomSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
genre: genre || undefined,
|
||||
limit,
|
||||
maxYear: enableMaxYear ? maxYear || undefined : undefined,
|
||||
minYear: enableMinYear ? minYear || undefined : undefined,
|
||||
musicFolderId: musicFolderId || undefined,
|
||||
},
|
||||
}),
|
||||
queryKey: queryKeys.songs.randomSongList(server?.id),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byData: res?.items || [],
|
||||
playType,
|
||||
});
|
||||
handlePlayQueueAdd?.({
|
||||
byData: res?.items || [],
|
||||
playType,
|
||||
});
|
||||
|
||||
closeAllModals();
|
||||
};
|
||||
closeAllModals();
|
||||
};
|
||||
|
||||
const genreData = useMemo(() => {
|
||||
if (!genres) return [];
|
||||
const genreData = useMemo(() => {
|
||||
if (!genres) return [];
|
||||
|
||||
return genres.items.map((genre) => {
|
||||
const value =
|
||||
server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC
|
||||
? genre.name
|
||||
: genre.id;
|
||||
return {
|
||||
label: genre.name,
|
||||
value,
|
||||
};
|
||||
});
|
||||
}, [genres, server?.type]);
|
||||
return genres.items.map((genre) => {
|
||||
const value =
|
||||
server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC
|
||||
? genre.name
|
||||
: genre.id;
|
||||
return {
|
||||
label: genre.name,
|
||||
value,
|
||||
};
|
||||
});
|
||||
}, [genres, server?.type]);
|
||||
|
||||
const musicFolderData = useMemo(() => {
|
||||
if (!musicFolders) return [];
|
||||
return musicFolders.items.map((musicFolder) => ({
|
||||
label: musicFolder.name,
|
||||
value: String(musicFolder.id),
|
||||
}));
|
||||
}, [musicFolders]);
|
||||
const musicFolderData = useMemo(() => {
|
||||
if (!musicFolders) return [];
|
||||
return musicFolders.items.map((musicFolder) => ({
|
||||
label: musicFolder.name,
|
||||
value: String(musicFolder.id),
|
||||
}));
|
||||
}, [musicFolders]);
|
||||
|
||||
return (
|
||||
<Stack spacing="md">
|
||||
<NumberInput
|
||||
required
|
||||
label="How many tracks?"
|
||||
max={500}
|
||||
min={1}
|
||||
value={limit}
|
||||
onChange={(e) => setStore({ limit: e ? Number(e) : 0 })}
|
||||
/>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="From year"
|
||||
max={2050}
|
||||
min={1850}
|
||||
rightSection={
|
||||
<Checkbox
|
||||
checked={enableMinYear}
|
||||
mr="0.5rem"
|
||||
onChange={(e) => setStore({ enableMinYear: e.currentTarget.checked })}
|
||||
return (
|
||||
<Stack spacing="md">
|
||||
<NumberInput
|
||||
required
|
||||
label="How many tracks?"
|
||||
max={500}
|
||||
min={1}
|
||||
value={limit}
|
||||
onChange={(e) => setStore({ limit: e ? Number(e) : 0 })}
|
||||
/>
|
||||
}
|
||||
value={minYear}
|
||||
onChange={(e) => setStore({ minYear: e ? Number(e) : 0 })}
|
||||
/>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="From year"
|
||||
max={2050}
|
||||
min={1850}
|
||||
rightSection={
|
||||
<Checkbox
|
||||
checked={enableMinYear}
|
||||
mr="0.5rem"
|
||||
onChange={(e) => setStore({ enableMinYear: e.currentTarget.checked })}
|
||||
/>
|
||||
}
|
||||
value={minYear}
|
||||
onChange={(e) => setStore({ minYear: e ? Number(e) : 0 })}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="To year"
|
||||
max={2050}
|
||||
min={1850}
|
||||
rightSection={
|
||||
<Checkbox
|
||||
checked={enableMaxYear}
|
||||
mr="0.5rem"
|
||||
onChange={(e) => setStore({ enableMaxYear: e.currentTarget.checked })}
|
||||
<NumberInput
|
||||
label="To year"
|
||||
max={2050}
|
||||
min={1850}
|
||||
rightSection={
|
||||
<Checkbox
|
||||
checked={enableMaxYear}
|
||||
mr="0.5rem"
|
||||
onChange={(e) => setStore({ enableMaxYear: e.currentTarget.checked })}
|
||||
/>
|
||||
}
|
||||
value={maxYear}
|
||||
onChange={(e) => setStore({ maxYear: e ? Number(e) : 0 })}
|
||||
/>
|
||||
</Group>
|
||||
<Select
|
||||
clearable
|
||||
data={genreData}
|
||||
label="Genre"
|
||||
value={genre}
|
||||
onChange={(e) => setStore({ genre: e || '' })}
|
||||
/>
|
||||
}
|
||||
value={maxYear}
|
||||
onChange={(e) => setStore({ maxYear: e ? Number(e) : 0 })}
|
||||
/>
|
||||
</Group>
|
||||
<Select
|
||||
clearable
|
||||
data={genreData}
|
||||
label="Genre"
|
||||
value={genre}
|
||||
onChange={(e) => setStore({ genre: e || '' })}
|
||||
/>
|
||||
<Select
|
||||
clearable
|
||||
data={musicFolderData}
|
||||
label="Music folder"
|
||||
value={musicFolderId}
|
||||
onChange={(e) => {
|
||||
setStore({ musicFolderId: e ? String(e) : '' });
|
||||
}}
|
||||
/>
|
||||
<Divider />
|
||||
<Group grow>
|
||||
<Button
|
||||
leftIcon={<RiAddBoxFill size="1rem" />}
|
||||
type="submit"
|
||||
variant="default"
|
||||
onClick={() => handlePlay(Play.LAST)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<RiAddCircleFill size="1rem" />}
|
||||
type="submit"
|
||||
variant="default"
|
||||
onClick={() => handlePlay(Play.NEXT)}
|
||||
>
|
||||
Add next
|
||||
</Button>
|
||||
</Group>
|
||||
<Button
|
||||
leftIcon={<RiPlayFill size="1rem" />}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
onClick={() => handlePlay(Play.NOW)}
|
||||
>
|
||||
Play
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
<Select
|
||||
clearable
|
||||
data={musicFolderData}
|
||||
label="Music folder"
|
||||
value={musicFolderId}
|
||||
onChange={(e) => {
|
||||
setStore({ musicFolderId: e ? String(e) : '' });
|
||||
}}
|
||||
/>
|
||||
<Divider />
|
||||
<Group grow>
|
||||
<Button
|
||||
leftIcon={<RiAddBoxFill size="1rem" />}
|
||||
type="submit"
|
||||
variant="default"
|
||||
onClick={() => handlePlay(Play.LAST)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<RiAddCircleFill size="1rem" />}
|
||||
type="submit"
|
||||
variant="default"
|
||||
onClick={() => handlePlay(Play.NEXT)}
|
||||
>
|
||||
Add next
|
||||
</Button>
|
||||
</Group>
|
||||
<Button
|
||||
leftIcon={<RiPlayFill size="1rem" />}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
onClick={() => handlePlay(Play.NOW)}
|
||||
>
|
||||
Play
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const openShuffleAllModal = async (
|
||||
props: Pick<ShuffleAllModalProps, 'handlePlayQueueAdd' | 'queryClient'>,
|
||||
props: Pick<ShuffleAllModalProps, 'handlePlayQueueAdd' | 'queryClient'>,
|
||||
) => {
|
||||
const server = useAuthStore.getState().currentServer;
|
||||
const server = useAuthStore.getState().currentServer;
|
||||
|
||||
const genres = await props.queryClient.fetchQuery({
|
||||
cacheTime: 1000 * 60 * 60 * 4,
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getGenreList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: null,
|
||||
}),
|
||||
queryKey: queryKeys.genres.list(server?.id),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
const genres = await props.queryClient.fetchQuery({
|
||||
cacheTime: 1000 * 60 * 60 * 4,
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getGenreList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: null,
|
||||
}),
|
||||
queryKey: queryKeys.genres.list(server?.id),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
const musicFolders = await props.queryClient.fetchQuery({
|
||||
cacheTime: 1000 * 60 * 60 * 4,
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getMusicFolderList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
}),
|
||||
queryKey: queryKeys.musicFolders.list(server?.id),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
const musicFolders = await props.queryClient.fetchQuery({
|
||||
cacheTime: 1000 * 60 * 60 * 4,
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getMusicFolderList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
}),
|
||||
queryKey: queryKeys.musicFolders.list(server?.id),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
openModal({
|
||||
children: (
|
||||
<ShuffleAllModal
|
||||
genres={genres}
|
||||
musicFolders={musicFolders}
|
||||
server={server}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
size: 'sm',
|
||||
title: 'Shuffle all',
|
||||
});
|
||||
openModal({
|
||||
children: (
|
||||
<ShuffleAllModal
|
||||
genres={genres}
|
||||
musicFolders={musicFolders}
|
||||
server={server}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
size: 'sm',
|
||||
title: 'Shuffle all',
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createContext } from 'react';
|
|||
import { PlayQueueAddOptions } from '/@/renderer/types';
|
||||
|
||||
export const PlayQueueHandlerContext = createContext<{
|
||||
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
|
||||
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
|
||||
}>({
|
||||
handlePlayQueueAdd: undefined,
|
||||
handlePlayQueueAdd: undefined,
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -7,43 +7,43 @@ import { toast } from '/@/renderer/components/toast/index';
|
|||
import isElectron from 'is-electron';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
import {
|
||||
LibraryItem,
|
||||
QueueSong,
|
||||
Song,
|
||||
SongListResponse,
|
||||
instanceOfCancellationError,
|
||||
LibraryItem,
|
||||
QueueSong,
|
||||
Song,
|
||||
SongListResponse,
|
||||
instanceOfCancellationError,
|
||||
} from '/@/renderer/api/types';
|
||||
import {
|
||||
getPlaylistSongsById,
|
||||
getSongById,
|
||||
getAlbumSongsById,
|
||||
getAlbumArtistSongsById,
|
||||
getSongsByQuery,
|
||||
getPlaylistSongsById,
|
||||
getSongById,
|
||||
getAlbumSongsById,
|
||||
getAlbumArtistSongsById,
|
||||
getSongsByQuery,
|
||||
} from '/@/renderer/features/player/utils';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
|
||||
const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
|
||||
let queryKey;
|
||||
let queryKey;
|
||||
|
||||
switch (itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
queryKey = queryKeys.songs.list(serverId);
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
queryKey = queryKeys.songs.list(serverId);
|
||||
break;
|
||||
case LibraryItem.PLAYLIST:
|
||||
queryKey = queryKeys.playlists.songList(serverId);
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
queryKey = queryKeys.songs.list(serverId);
|
||||
break;
|
||||
default:
|
||||
queryKey = queryKeys.songs.list(serverId);
|
||||
break;
|
||||
}
|
||||
switch (itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
queryKey = queryKeys.songs.list(serverId);
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
queryKey = queryKeys.songs.list(serverId);
|
||||
break;
|
||||
case LibraryItem.PLAYLIST:
|
||||
queryKey = queryKeys.playlists.songList(serverId);
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
queryKey = queryKeys.songs.list(serverId);
|
||||
break;
|
||||
default:
|
||||
queryKey = queryKeys.songs.list(serverId);
|
||||
break;
|
||||
}
|
||||
|
||||
return queryKey;
|
||||
return queryKey;
|
||||
};
|
||||
|
||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||
|
|
@ -53,118 +53,133 @@ const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
|||
const addToQueue = usePlayerStore.getState().actions.addToQueue;
|
||||
|
||||
export const useHandlePlayQueueAdd = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const playerType = usePlayerType();
|
||||
const server = useCurrentServer();
|
||||
const { play } = usePlayerControls();
|
||||
const timeoutIds = useRef<Record<string, ReturnType<typeof setTimeout>> | null>({});
|
||||
const queryClient = useQueryClient();
|
||||
const playerType = usePlayerType();
|
||||
const server = useCurrentServer();
|
||||
const { play } = usePlayerControls();
|
||||
const timeoutIds = useRef<Record<string, ReturnType<typeof setTimeout>> | null>({});
|
||||
|
||||
const handlePlayQueueAdd = useCallback(
|
||||
async (options: PlayQueueAddOptions) => {
|
||||
if (!server) return toast.error({ message: 'No server selected', type: 'error' });
|
||||
const { initialIndex, initialSongId, playType, byData, byItemType, query } = options;
|
||||
let songs: QueueSong[] | null = null;
|
||||
let initialSongIndex = 0;
|
||||
const handlePlayQueueAdd = useCallback(
|
||||
async (options: PlayQueueAddOptions) => {
|
||||
if (!server) return toast.error({ message: 'No server selected', type: 'error' });
|
||||
const { initialIndex, initialSongId, playType, byData, byItemType, query } = options;
|
||||
let songs: QueueSong[] | null = null;
|
||||
let initialSongIndex = 0;
|
||||
|
||||
if (byItemType) {
|
||||
let songList: SongListResponse | undefined;
|
||||
const { type: itemType, id } = byItemType;
|
||||
if (byItemType) {
|
||||
let songList: SongListResponse | undefined;
|
||||
const { type: itemType, id } = byItemType;
|
||||
|
||||
const fetchId = nanoid();
|
||||
timeoutIds.current = {
|
||||
...timeoutIds.current,
|
||||
[fetchId]: setTimeout(() => {
|
||||
toast.info({
|
||||
autoClose: false,
|
||||
id: fetchId,
|
||||
message: 'This is taking a while... close the notification to cancel the request',
|
||||
onClose: () => {
|
||||
queryClient.cancelQueries({
|
||||
exact: false,
|
||||
queryKey: getRootQueryKey(itemType, server?.id),
|
||||
});
|
||||
},
|
||||
title: 'Adding to queue',
|
||||
});
|
||||
}, 2000),
|
||||
};
|
||||
const fetchId = nanoid();
|
||||
timeoutIds.current = {
|
||||
...timeoutIds.current,
|
||||
[fetchId]: setTimeout(() => {
|
||||
toast.info({
|
||||
autoClose: false,
|
||||
id: fetchId,
|
||||
message:
|
||||
'This is taking a while... close the notification to cancel the request',
|
||||
onClose: () => {
|
||||
queryClient.cancelQueries({
|
||||
exact: false,
|
||||
queryKey: getRootQueryKey(itemType, server?.id),
|
||||
});
|
||||
},
|
||||
title: 'Adding to queue',
|
||||
});
|
||||
}, 2000),
|
||||
};
|
||||
|
||||
try {
|
||||
if (itemType === LibraryItem.PLAYLIST) {
|
||||
songList = await getPlaylistSongsById({ id: id?.[0], query, queryClient, server });
|
||||
} else if (itemType === LibraryItem.ALBUM) {
|
||||
songList = await getAlbumSongsById({ id, query, queryClient, server });
|
||||
} else if (itemType === LibraryItem.ALBUM_ARTIST) {
|
||||
songList = await getAlbumArtistSongsById({ id, query, queryClient, server });
|
||||
} else if (itemType === LibraryItem.SONG) {
|
||||
if (id?.length === 1) {
|
||||
songList = await getSongById({ id: id?.[0], queryClient, server });
|
||||
} else {
|
||||
songList = await getSongsByQuery({ query, queryClient, server });
|
||||
try {
|
||||
if (itemType === LibraryItem.PLAYLIST) {
|
||||
songList = await getPlaylistSongsById({
|
||||
id: id?.[0],
|
||||
query,
|
||||
queryClient,
|
||||
server,
|
||||
});
|
||||
} else if (itemType === LibraryItem.ALBUM) {
|
||||
songList = await getAlbumSongsById({ id, query, queryClient, server });
|
||||
} else if (itemType === LibraryItem.ALBUM_ARTIST) {
|
||||
songList = await getAlbumArtistSongsById({
|
||||
id,
|
||||
query,
|
||||
queryClient,
|
||||
server,
|
||||
});
|
||||
} else if (itemType === LibraryItem.SONG) {
|
||||
if (id?.length === 1) {
|
||||
songList = await getSongById({ id: id?.[0], queryClient, server });
|
||||
} else {
|
||||
songList = await getSongsByQuery({ query, queryClient, server });
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);
|
||||
delete timeoutIds.current[fetchId];
|
||||
toast.hide(fetchId);
|
||||
} catch (err: any) {
|
||||
if (instanceOfCancellationError(err)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);
|
||||
delete timeoutIds.current[fetchId];
|
||||
toast.hide(fetchId);
|
||||
|
||||
return toast.error({
|
||||
message: err.message,
|
||||
title: 'Play queue add failed',
|
||||
});
|
||||
}
|
||||
|
||||
songs =
|
||||
songList?.items?.map((song: Song) => ({ ...song, uniqueId: nanoid() })) || null;
|
||||
} else if (byData) {
|
||||
songs = byData.map((song) => ({ ...song, uniqueId: nanoid() })) || null;
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);
|
||||
delete timeoutIds.current[fetchId];
|
||||
toast.hide(fetchId);
|
||||
} catch (err: any) {
|
||||
if (instanceOfCancellationError(err)) {
|
||||
if (!songs || songs?.length === 0)
|
||||
return toast.warn({
|
||||
message: 'The query returned no results',
|
||||
title: 'No tracks added',
|
||||
});
|
||||
|
||||
if (initialIndex) {
|
||||
initialSongIndex = initialIndex;
|
||||
} else if (initialSongId) {
|
||||
initialSongIndex = songs.findIndex((song) => song.id === initialSongId);
|
||||
}
|
||||
|
||||
const playerData = addToQueue({ initialIndex: initialSongIndex, playType, songs });
|
||||
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
mpvPlayer?.volume(usePlayerStore.getState().volume);
|
||||
|
||||
if (playType === Play.NEXT || playType === Play.LAST) {
|
||||
mpvPlayer?.setQueueNext(playerData);
|
||||
}
|
||||
|
||||
if (playType === Play.NOW) {
|
||||
mpvPlayer?.setQueue(playerData);
|
||||
mpvPlayer?.play();
|
||||
}
|
||||
}
|
||||
|
||||
play();
|
||||
|
||||
mpris?.updateSong({
|
||||
currentTime: usePlayerStore.getState().current.time,
|
||||
repeat: usePlayerStore.getState().repeat,
|
||||
shuffle: usePlayerStore.getState().shuffle,
|
||||
song: playerData.current.song,
|
||||
status: 'Playing',
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[play, playerType, queryClient, server],
|
||||
);
|
||||
|
||||
clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);
|
||||
delete timeoutIds.current[fetchId];
|
||||
toast.hide(fetchId);
|
||||
|
||||
return toast.error({
|
||||
message: err.message,
|
||||
title: 'Play queue add failed',
|
||||
});
|
||||
}
|
||||
|
||||
songs = songList?.items?.map((song: Song) => ({ ...song, uniqueId: nanoid() })) || null;
|
||||
} else if (byData) {
|
||||
songs = byData.map((song) => ({ ...song, uniqueId: nanoid() })) || null;
|
||||
}
|
||||
|
||||
if (!songs || songs?.length === 0)
|
||||
return toast.warn({ message: 'The query returned no results', title: 'No tracks added' });
|
||||
|
||||
if (initialIndex) {
|
||||
initialSongIndex = initialIndex;
|
||||
} else if (initialSongId) {
|
||||
initialSongIndex = songs.findIndex((song) => song.id === initialSongId);
|
||||
}
|
||||
|
||||
const playerData = addToQueue({ initialIndex: initialSongIndex, playType, songs });
|
||||
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
mpvPlayer?.volume(usePlayerStore.getState().volume);
|
||||
|
||||
if (playType === Play.NEXT || playType === Play.LAST) {
|
||||
mpvPlayer?.setQueueNext(playerData);
|
||||
}
|
||||
|
||||
if (playType === Play.NOW) {
|
||||
mpvPlayer?.setQueue(playerData);
|
||||
mpvPlayer?.play();
|
||||
}
|
||||
}
|
||||
|
||||
play();
|
||||
|
||||
mpris?.updateSong({
|
||||
currentTime: usePlayerStore.getState().current.time,
|
||||
repeat: usePlayerStore.getState().repeat,
|
||||
shuffle: usePlayerStore.getState().shuffle,
|
||||
song: playerData.current.song,
|
||||
status: 'Playing',
|
||||
});
|
||||
|
||||
return null;
|
||||
},
|
||||
[play, playerType, queryClient, server],
|
||||
);
|
||||
|
||||
return handlePlayQueueAdd;
|
||||
return handlePlayQueueAdd;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ import { useContext } from 'react';
|
|||
import { PlayQueueHandlerContext } from '/@/renderer/features/player/context/play-queue-handler-context';
|
||||
|
||||
export const usePlayQueueAdd = () => {
|
||||
const { handlePlayQueueAdd } = useContext(PlayQueueHandlerContext);
|
||||
return handlePlayQueueAdd;
|
||||
const { handlePlayQueueAdd } = useContext(PlayQueueHandlerContext);
|
||||
return handlePlayQueueAdd;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,123 +10,123 @@ const utils = isElectron() ? window.electron.utils : null;
|
|||
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
||||
|
||||
const calculateVolumeUp = (volume: number, volumeWheelStep: number) => {
|
||||
let volumeToSet;
|
||||
const newVolumeGreaterThanHundred = volume + volumeWheelStep > 100;
|
||||
if (newVolumeGreaterThanHundred) {
|
||||
volumeToSet = 100;
|
||||
} else {
|
||||
volumeToSet = volume + volumeWheelStep;
|
||||
}
|
||||
let volumeToSet;
|
||||
const newVolumeGreaterThanHundred = volume + volumeWheelStep > 100;
|
||||
if (newVolumeGreaterThanHundred) {
|
||||
volumeToSet = 100;
|
||||
} else {
|
||||
volumeToSet = volume + volumeWheelStep;
|
||||
}
|
||||
|
||||
return volumeToSet;
|
||||
return volumeToSet;
|
||||
};
|
||||
|
||||
const calculateVolumeDown = (volume: number, volumeWheelStep: number) => {
|
||||
let volumeToSet;
|
||||
const newVolumeLessThanZero = volume - volumeWheelStep < 0;
|
||||
if (newVolumeLessThanZero) {
|
||||
volumeToSet = 0;
|
||||
} else {
|
||||
volumeToSet = volume - volumeWheelStep;
|
||||
}
|
||||
let volumeToSet;
|
||||
const newVolumeLessThanZero = volume - volumeWheelStep < 0;
|
||||
if (newVolumeLessThanZero) {
|
||||
volumeToSet = 0;
|
||||
} else {
|
||||
volumeToSet = volume - volumeWheelStep;
|
||||
}
|
||||
|
||||
return volumeToSet;
|
||||
return volumeToSet;
|
||||
};
|
||||
|
||||
export const useRightControls = () => {
|
||||
const { setVolume, setMuted } = usePlayerControls();
|
||||
const volume = useVolume();
|
||||
const muted = useMuted();
|
||||
const { volumeWheelStep } = useGeneralSettings();
|
||||
const { setVolume, setMuted } = usePlayerControls();
|
||||
const volume = useVolume();
|
||||
const muted = useMuted();
|
||||
const { volumeWheelStep } = useGeneralSettings();
|
||||
|
||||
// Ensure that the mpv player volume is set on startup
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
mpvPlayer.volume(volume);
|
||||
mpris?.updateVolume(volume / 100);
|
||||
// Ensure that the mpv player volume is set on startup
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
mpvPlayer.volume(volume);
|
||||
mpris?.updateVolume(volume / 100);
|
||||
|
||||
if (muted) {
|
||||
mpvPlayer.mute();
|
||||
}
|
||||
}
|
||||
if (muted) {
|
||||
mpvPlayer.mute();
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleVolumeSlider = (e: number) => {
|
||||
mpvPlayer?.volume(e);
|
||||
mpris?.updateVolume(e / 100);
|
||||
setVolume(e);
|
||||
};
|
||||
|
||||
const handleVolumeSliderState = (e: number) => {
|
||||
mpris?.updateVolume(e / 100);
|
||||
setVolume(e);
|
||||
};
|
||||
|
||||
const handleVolumeDown = useCallback(() => {
|
||||
const volumeToSet = calculateVolumeDown(volume, volumeWheelStep);
|
||||
mpvPlayer?.volume(volumeToSet);
|
||||
mpris?.updateVolume(volumeToSet / 100);
|
||||
setVolume(volumeToSet);
|
||||
}, [setVolume, volume, volumeWheelStep]);
|
||||
|
||||
const handleVolumeUp = useCallback(() => {
|
||||
const volumeToSet = calculateVolumeUp(volume, volumeWheelStep);
|
||||
mpvPlayer?.volume(volumeToSet);
|
||||
mpris?.updateVolume(volumeToSet / 100);
|
||||
setVolume(volumeToSet);
|
||||
}, [setVolume, volume, volumeWheelStep]);
|
||||
|
||||
const handleVolumeWheel = useCallback(
|
||||
(e: WheelEvent<HTMLDivElement | HTMLButtonElement>) => {
|
||||
let volumeToSet;
|
||||
if (e.deltaY > 0) {
|
||||
volumeToSet = calculateVolumeDown(volume, volumeWheelStep);
|
||||
} else {
|
||||
volumeToSet = calculateVolumeUp(volume, volumeWheelStep);
|
||||
}
|
||||
|
||||
mpvPlayer?.volume(volumeToSet);
|
||||
mpris?.updateVolume(volumeToSet / 100);
|
||||
setVolume(volumeToSet);
|
||||
},
|
||||
[setVolume, volume, volumeWheelStep],
|
||||
);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
setMuted(!muted);
|
||||
mpvPlayer?.mute();
|
||||
}, [muted, setMuted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
mpvPlayerListener?.rendererVolumeMute(() => {
|
||||
handleMute();
|
||||
});
|
||||
|
||||
mpvPlayerListener?.rendererVolumeUp(() => {
|
||||
handleVolumeUp();
|
||||
});
|
||||
|
||||
mpvPlayerListener?.rendererVolumeDown(() => {
|
||||
handleVolumeDown();
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
ipc?.removeAllListeners('renderer-player-volume-mute');
|
||||
ipc?.removeAllListeners('renderer-player-volume-up');
|
||||
ipc?.removeAllListeners('renderer-player-volume-down');
|
||||
const handleVolumeSlider = (e: number) => {
|
||||
mpvPlayer?.volume(e);
|
||||
mpris?.updateVolume(e / 100);
|
||||
setVolume(e);
|
||||
};
|
||||
}, [handleMute, handleVolumeDown, handleVolumeUp]);
|
||||
|
||||
return {
|
||||
handleMute,
|
||||
handleVolumeDown,
|
||||
handleVolumeSlider,
|
||||
handleVolumeSliderState,
|
||||
handleVolumeUp,
|
||||
handleVolumeWheel,
|
||||
};
|
||||
const handleVolumeSliderState = (e: number) => {
|
||||
mpris?.updateVolume(e / 100);
|
||||
setVolume(e);
|
||||
};
|
||||
|
||||
const handleVolumeDown = useCallback(() => {
|
||||
const volumeToSet = calculateVolumeDown(volume, volumeWheelStep);
|
||||
mpvPlayer?.volume(volumeToSet);
|
||||
mpris?.updateVolume(volumeToSet / 100);
|
||||
setVolume(volumeToSet);
|
||||
}, [setVolume, volume, volumeWheelStep]);
|
||||
|
||||
const handleVolumeUp = useCallback(() => {
|
||||
const volumeToSet = calculateVolumeUp(volume, volumeWheelStep);
|
||||
mpvPlayer?.volume(volumeToSet);
|
||||
mpris?.updateVolume(volumeToSet / 100);
|
||||
setVolume(volumeToSet);
|
||||
}, [setVolume, volume, volumeWheelStep]);
|
||||
|
||||
const handleVolumeWheel = useCallback(
|
||||
(e: WheelEvent<HTMLDivElement | HTMLButtonElement>) => {
|
||||
let volumeToSet;
|
||||
if (e.deltaY > 0) {
|
||||
volumeToSet = calculateVolumeDown(volume, volumeWheelStep);
|
||||
} else {
|
||||
volumeToSet = calculateVolumeUp(volume, volumeWheelStep);
|
||||
}
|
||||
|
||||
mpvPlayer?.volume(volumeToSet);
|
||||
mpris?.updateVolume(volumeToSet / 100);
|
||||
setVolume(volumeToSet);
|
||||
},
|
||||
[setVolume, volume, volumeWheelStep],
|
||||
);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
setMuted(!muted);
|
||||
mpvPlayer?.mute();
|
||||
}, [muted, setMuted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
mpvPlayerListener?.rendererVolumeMute(() => {
|
||||
handleMute();
|
||||
});
|
||||
|
||||
mpvPlayerListener?.rendererVolumeUp(() => {
|
||||
handleVolumeUp();
|
||||
});
|
||||
|
||||
mpvPlayerListener?.rendererVolumeDown(() => {
|
||||
handleVolumeDown();
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
ipc?.removeAllListeners('renderer-player-volume-mute');
|
||||
ipc?.removeAllListeners('renderer-player-volume-up');
|
||||
ipc?.removeAllListeners('renderer-player-volume-down');
|
||||
};
|
||||
}, [handleMute, handleVolumeDown, handleVolumeUp]);
|
||||
|
||||
return {
|
||||
handleMute,
|
||||
handleVolumeDown,
|
||||
handleVolumeSlider,
|
||||
handleVolumeSliderState,
|
||||
handleVolumeUp,
|
||||
handleVolumeWheel,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,286 +34,294 @@ Progress Events (Jellyfin only):
|
|||
*/
|
||||
|
||||
const checkScrobbleConditions = (args: {
|
||||
scrobbleAtDuration: number;
|
||||
scrobbleAtPercentage: number;
|
||||
songCompletedDuration: number;
|
||||
songDuration: number;
|
||||
scrobbleAtDuration: number;
|
||||
scrobbleAtPercentage: number;
|
||||
songCompletedDuration: number;
|
||||
songDuration: number;
|
||||
}) => {
|
||||
const { scrobbleAtDuration, scrobbleAtPercentage, songCompletedDuration, songDuration } = args;
|
||||
const percentageOfSongCompleted = songDuration ? (songCompletedDuration / songDuration) * 100 : 0;
|
||||
const { scrobbleAtDuration, scrobbleAtPercentage, songCompletedDuration, songDuration } = args;
|
||||
const percentageOfSongCompleted = songDuration
|
||||
? (songCompletedDuration / songDuration) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
percentageOfSongCompleted >= scrobbleAtPercentage || songCompletedDuration >= scrobbleAtDuration
|
||||
);
|
||||
return (
|
||||
percentageOfSongCompleted >= scrobbleAtPercentage ||
|
||||
songCompletedDuration >= scrobbleAtDuration
|
||||
);
|
||||
};
|
||||
|
||||
export const useScrobble = () => {
|
||||
const status = useCurrentStatus();
|
||||
const scrobbleSettings = usePlaybackSettings().scrobble;
|
||||
const isScrobbleEnabled = scrobbleSettings?.enabled;
|
||||
const sendScrobble = useSendScrobble();
|
||||
const status = useCurrentStatus();
|
||||
const scrobbleSettings = usePlaybackSettings().scrobble;
|
||||
const isScrobbleEnabled = scrobbleSettings?.enabled;
|
||||
const sendScrobble = useSendScrobble();
|
||||
|
||||
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
|
||||
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
|
||||
|
||||
const handleScrobbleFromSeek = useCallback(
|
||||
(currentTime: number) => {
|
||||
if (!isScrobbleEnabled) return;
|
||||
const handleScrobbleFromSeek = useCallback(
|
||||
(currentTime: number) => {
|
||||
if (!isScrobbleEnabled) return;
|
||||
|
||||
const currentSong = usePlayerStore.getState().current.song;
|
||||
const currentSong = usePlayerStore.getState().current.song;
|
||||
|
||||
if (!currentSong?.id || currentSong?.serverType !== ServerType.JELLYFIN) return;
|
||||
if (!currentSong?.id || currentSong?.serverType !== ServerType.JELLYFIN) return;
|
||||
|
||||
const position =
|
||||
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
|
||||
const position =
|
||||
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
|
||||
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
event: 'timeupdate',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: false,
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
event: 'timeupdate',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: false,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
},
|
||||
[isScrobbleEnabled, sendScrobble],
|
||||
);
|
||||
|
||||
const progressIntervalId = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const songChangeTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const handleScrobbleFromSongChange = useCallback(
|
||||
(current: (QueueSong | number | undefined)[], previous: (QueueSong | number | undefined)[]) => {
|
||||
if (!isScrobbleEnabled) return;
|
||||
|
||||
if (progressIntervalId.current) {
|
||||
clearInterval(progressIntervalId.current);
|
||||
}
|
||||
|
||||
// const currentSong = current[0] as QueueSong | undefined;
|
||||
const previousSong = previous[0] as QueueSong;
|
||||
const previousSongTime = previous[1] as number;
|
||||
|
||||
// Send completion scrobble when song changes and a previous song exists
|
||||
if (previousSong?.id) {
|
||||
const shouldSubmitScrobble = checkScrobbleConditions({
|
||||
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
||||
songCompletedDuration: previousSongTime,
|
||||
songDuration: previousSong.duration,
|
||||
});
|
||||
|
||||
if (
|
||||
(!isCurrentSongScrobbled && shouldSubmitScrobble) ||
|
||||
previousSong?.serverType === ServerType.JELLYFIN
|
||||
) {
|
||||
const position =
|
||||
previousSong?.serverType === ServerType.JELLYFIN ? previousSongTime * 1e7 : undefined;
|
||||
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
id: previousSong.id,
|
||||
position,
|
||||
submission: true,
|
||||
},
|
||||
serverId: previousSong?.serverId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setIsCurrentSongScrobbled(false);
|
||||
|
||||
// Use a timeout to prevent spamming the server with scrobble events when switching through songs quickly
|
||||
clearTimeout(songChangeTimeoutId.current as ReturnType<typeof setTimeout>);
|
||||
songChangeTimeoutId.current = setTimeout(() => {
|
||||
const currentSong = current[0] as QueueSong | undefined;
|
||||
|
||||
// Send start scrobble when song changes and the new song is playing
|
||||
if (status === PlayerStatus.PLAYING && currentSong?.id) {
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
event: 'start',
|
||||
id: currentSong.id,
|
||||
position: 0,
|
||||
submission: false,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
|
||||
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
progressIntervalId.current = setInterval(() => {
|
||||
const currentTime = usePlayerStore.getState().current.time;
|
||||
handleScrobbleFromSeek(currentTime);
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
[
|
||||
isScrobbleEnabled,
|
||||
scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleSettings?.scrobbleAtPercentage,
|
||||
isCurrentSongScrobbled,
|
||||
sendScrobble,
|
||||
status,
|
||||
handleScrobbleFromSeek,
|
||||
],
|
||||
);
|
||||
|
||||
const handleScrobbleFromStatusChange = useCallback(
|
||||
(status: PlayerStatus | undefined) => {
|
||||
if (!isScrobbleEnabled) return;
|
||||
|
||||
const currentSong = usePlayerStore.getState().current.song;
|
||||
|
||||
if (!currentSong?.id) return;
|
||||
|
||||
const position =
|
||||
currentSong?.serverType === ServerType.JELLYFIN
|
||||
? usePlayerStore.getState().current.time * 1e7
|
||||
: undefined;
|
||||
|
||||
// Whenever the player is restarted, send a 'start' scrobble
|
||||
if (status === PlayerStatus.PLAYING) {
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
event: 'unpause',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: false,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
|
||||
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
progressIntervalId.current = setInterval(() => {
|
||||
const currentTime = usePlayerStore.getState().current.time;
|
||||
handleScrobbleFromSeek(currentTime);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Jellyfin is the only one that needs to send a 'pause' event to the server
|
||||
} else if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
event: 'pause',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: false,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
|
||||
if (progressIntervalId.current) {
|
||||
clearInterval(progressIntervalId.current as ReturnType<typeof setInterval>);
|
||||
}
|
||||
} else {
|
||||
// If not already scrobbled, send a 'submission' scrobble if conditions are met
|
||||
const shouldSubmitScrobble = checkScrobbleConditions({
|
||||
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
||||
songCompletedDuration: usePlayerStore.getState().current.time,
|
||||
songDuration: currentSong.duration,
|
||||
});
|
||||
|
||||
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
id: currentSong.id,
|
||||
submission: true,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
|
||||
setIsCurrentSongScrobbled(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
isScrobbleEnabled,
|
||||
sendScrobble,
|
||||
handleScrobbleFromSeek,
|
||||
scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleSettings?.scrobbleAtPercentage,
|
||||
isCurrentSongScrobbled,
|
||||
],
|
||||
);
|
||||
|
||||
// When pressing the "Previous Track" button, the player will restart the current song if the
|
||||
// currentTime is >= 10 seconds. Since the song / status change events are not triggered, we will
|
||||
// need to perform another check to see if the scrobble conditions are met
|
||||
const handleScrobbleFromSongRestart = useCallback(
|
||||
(currentTime: number) => {
|
||||
if (!isScrobbleEnabled) return;
|
||||
|
||||
const currentSong = usePlayerStore.getState().current.song;
|
||||
|
||||
if (!currentSong?.id) return;
|
||||
|
||||
const position =
|
||||
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
|
||||
|
||||
const shouldSubmitScrobble = checkScrobbleConditions({
|
||||
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
||||
songCompletedDuration: currentTime,
|
||||
songDuration: currentSong.duration,
|
||||
});
|
||||
|
||||
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: true,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
}
|
||||
|
||||
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
event: 'start',
|
||||
id: currentSong.id,
|
||||
position: 0,
|
||||
submission: false,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
}
|
||||
|
||||
setIsCurrentSongScrobbled(false);
|
||||
},
|
||||
[
|
||||
isScrobbleEnabled,
|
||||
scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleSettings?.scrobbleAtPercentage,
|
||||
isCurrentSongScrobbled,
|
||||
sendScrobble,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubSongChange = usePlayerStore.subscribe(
|
||||
(state) => [state.current.song, state.current.time],
|
||||
handleScrobbleFromSongChange,
|
||||
{
|
||||
// We need the current time to check the scrobble condition, but we only want to
|
||||
// trigger the callback when the song changes
|
||||
equalityFn: (a, b) => (a[0] as QueueSong)?.id === (b[0] as QueueSong)?.id,
|
||||
},
|
||||
[isScrobbleEnabled, sendScrobble],
|
||||
);
|
||||
|
||||
const unsubStatusChange = usePlayerStore.subscribe(
|
||||
(state) => state.current.status,
|
||||
handleScrobbleFromStatusChange,
|
||||
const progressIntervalId = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const songChangeTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const handleScrobbleFromSongChange = useCallback(
|
||||
(
|
||||
current: (QueueSong | number | undefined)[],
|
||||
previous: (QueueSong | number | undefined)[],
|
||||
) => {
|
||||
if (!isScrobbleEnabled) return;
|
||||
|
||||
if (progressIntervalId.current) {
|
||||
clearInterval(progressIntervalId.current);
|
||||
}
|
||||
|
||||
// const currentSong = current[0] as QueueSong | undefined;
|
||||
const previousSong = previous[0] as QueueSong;
|
||||
const previousSongTime = previous[1] as number;
|
||||
|
||||
// Send completion scrobble when song changes and a previous song exists
|
||||
if (previousSong?.id) {
|
||||
const shouldSubmitScrobble = checkScrobbleConditions({
|
||||
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
||||
songCompletedDuration: previousSongTime,
|
||||
songDuration: previousSong.duration,
|
||||
});
|
||||
|
||||
if (
|
||||
(!isCurrentSongScrobbled && shouldSubmitScrobble) ||
|
||||
previousSong?.serverType === ServerType.JELLYFIN
|
||||
) {
|
||||
const position =
|
||||
previousSong?.serverType === ServerType.JELLYFIN
|
||||
? previousSongTime * 1e7
|
||||
: undefined;
|
||||
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
id: previousSong.id,
|
||||
position,
|
||||
submission: true,
|
||||
},
|
||||
serverId: previousSong?.serverId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setIsCurrentSongScrobbled(false);
|
||||
|
||||
// Use a timeout to prevent spamming the server with scrobble events when switching through songs quickly
|
||||
clearTimeout(songChangeTimeoutId.current as ReturnType<typeof setTimeout>);
|
||||
songChangeTimeoutId.current = setTimeout(() => {
|
||||
const currentSong = current[0] as QueueSong | undefined;
|
||||
|
||||
// Send start scrobble when song changes and the new song is playing
|
||||
if (status === PlayerStatus.PLAYING && currentSong?.id) {
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
event: 'start',
|
||||
id: currentSong.id,
|
||||
position: 0,
|
||||
submission: false,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
|
||||
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
progressIntervalId.current = setInterval(() => {
|
||||
const currentTime = usePlayerStore.getState().current.time;
|
||||
handleScrobbleFromSeek(currentTime);
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
[
|
||||
isScrobbleEnabled,
|
||||
scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleSettings?.scrobbleAtPercentage,
|
||||
isCurrentSongScrobbled,
|
||||
sendScrobble,
|
||||
status,
|
||||
handleScrobbleFromSeek,
|
||||
],
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubSongChange();
|
||||
unsubStatusChange();
|
||||
};
|
||||
}, [handleScrobbleFromSongChange, handleScrobbleFromStatusChange]);
|
||||
const handleScrobbleFromStatusChange = useCallback(
|
||||
(status: PlayerStatus | undefined) => {
|
||||
if (!isScrobbleEnabled) return;
|
||||
|
||||
return { handleScrobbleFromSeek, handleScrobbleFromSongRestart };
|
||||
const currentSong = usePlayerStore.getState().current.song;
|
||||
|
||||
if (!currentSong?.id) return;
|
||||
|
||||
const position =
|
||||
currentSong?.serverType === ServerType.JELLYFIN
|
||||
? usePlayerStore.getState().current.time * 1e7
|
||||
: undefined;
|
||||
|
||||
// Whenever the player is restarted, send a 'start' scrobble
|
||||
if (status === PlayerStatus.PLAYING) {
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
event: 'unpause',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: false,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
|
||||
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
progressIntervalId.current = setInterval(() => {
|
||||
const currentTime = usePlayerStore.getState().current.time;
|
||||
handleScrobbleFromSeek(currentTime);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Jellyfin is the only one that needs to send a 'pause' event to the server
|
||||
} else if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
event: 'pause',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: false,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
|
||||
if (progressIntervalId.current) {
|
||||
clearInterval(progressIntervalId.current as ReturnType<typeof setInterval>);
|
||||
}
|
||||
} else {
|
||||
// If not already scrobbled, send a 'submission' scrobble if conditions are met
|
||||
const shouldSubmitScrobble = checkScrobbleConditions({
|
||||
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
||||
songCompletedDuration: usePlayerStore.getState().current.time,
|
||||
songDuration: currentSong.duration,
|
||||
});
|
||||
|
||||
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
id: currentSong.id,
|
||||
submission: true,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
|
||||
setIsCurrentSongScrobbled(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
isScrobbleEnabled,
|
||||
sendScrobble,
|
||||
handleScrobbleFromSeek,
|
||||
scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleSettings?.scrobbleAtPercentage,
|
||||
isCurrentSongScrobbled,
|
||||
],
|
||||
);
|
||||
|
||||
// When pressing the "Previous Track" button, the player will restart the current song if the
|
||||
// currentTime is >= 10 seconds. Since the song / status change events are not triggered, we will
|
||||
// need to perform another check to see if the scrobble conditions are met
|
||||
const handleScrobbleFromSongRestart = useCallback(
|
||||
(currentTime: number) => {
|
||||
if (!isScrobbleEnabled) return;
|
||||
|
||||
const currentSong = usePlayerStore.getState().current.song;
|
||||
|
||||
if (!currentSong?.id) return;
|
||||
|
||||
const position =
|
||||
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
|
||||
|
||||
const shouldSubmitScrobble = checkScrobbleConditions({
|
||||
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
||||
songCompletedDuration: currentTime,
|
||||
songDuration: currentSong.duration,
|
||||
});
|
||||
|
||||
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: true,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
}
|
||||
|
||||
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
sendScrobble.mutate({
|
||||
query: {
|
||||
event: 'start',
|
||||
id: currentSong.id,
|
||||
position: 0,
|
||||
submission: false,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
}
|
||||
|
||||
setIsCurrentSongScrobbled(false);
|
||||
},
|
||||
[
|
||||
isScrobbleEnabled,
|
||||
scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleSettings?.scrobbleAtPercentage,
|
||||
isCurrentSongScrobbled,
|
||||
sendScrobble,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubSongChange = usePlayerStore.subscribe(
|
||||
(state) => [state.current.song, state.current.time],
|
||||
handleScrobbleFromSongChange,
|
||||
{
|
||||
// We need the current time to check the scrobble condition, but we only want to
|
||||
// trigger the callback when the song changes
|
||||
equalityFn: (a, b) => (a[0] as QueueSong)?.id === (b[0] as QueueSong)?.id,
|
||||
},
|
||||
);
|
||||
|
||||
const unsubStatusChange = usePlayerStore.subscribe(
|
||||
(state) => state.current.status,
|
||||
handleScrobbleFromStatusChange,
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubSongChange();
|
||||
unsubStatusChange();
|
||||
};
|
||||
}, [handleScrobbleFromSongChange, handleScrobbleFromStatusChange]);
|
||||
|
||||
return { handleScrobbleFromSeek, handleScrobbleFromSongRestart };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,25 +6,25 @@ import { MutationOptions } from '/@/renderer/lib/react-query';
|
|||
import { getServerById, useIncrementQueuePlayCount } from '/@/renderer/store';
|
||||
|
||||
export const useSendScrobble = (options?: MutationOptions) => {
|
||||
const incrementPlayCount = useIncrementQueuePlayCount();
|
||||
const incrementPlayCount = useIncrementQueuePlayCount();
|
||||
|
||||
return useMutation<
|
||||
ScrobbleResponse,
|
||||
AxiosError,
|
||||
Omit<ScrobbleArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.scrobble({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Manually increment the play count for the song in the queue if scrobble was submitted
|
||||
if (variables.query.submission) {
|
||||
incrementPlayCount([variables.query.id]);
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
return useMutation<
|
||||
ScrobbleResponse,
|
||||
AxiosError,
|
||||
Omit<ScrobbleArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.scrobble({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Manually increment the play count for the song in the queue if scrobble was submitted
|
||||
if (variables.query.submission) {
|
||||
incrementPlayCount([variables.query.id]);
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,195 +2,195 @@ import { QueryClient } from '@tanstack/react-query';
|
|||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
PlaylistSongListQuery,
|
||||
SongDetailQuery,
|
||||
SongListQuery,
|
||||
SongListResponse,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
PlaylistSongListQuery,
|
||||
SongDetailQuery,
|
||||
SongListQuery,
|
||||
SongListResponse,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
|
||||
export const getPlaylistSongsById = async (args: {
|
||||
id: string;
|
||||
query?: Partial<PlaylistSongListQuery>;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
id: string;
|
||||
query?: Partial<PlaylistSongListQuery>;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
}) => {
|
||||
const { id, queryClient, server, query } = args;
|
||||
const { id, queryClient, server, query } = args;
|
||||
|
||||
const queryFilter: PlaylistSongListQuery = {
|
||||
id,
|
||||
sortBy: SongListSort.ID,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
...query,
|
||||
};
|
||||
const queryFilter: PlaylistSongListQuery = {
|
||||
id,
|
||||
sortBy: SongListSort.ID,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
...query,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(server?.id, id, queryFilter);
|
||||
const queryKey = queryKeys.playlists.songList(server?.id, id, queryFilter);
|
||||
|
||||
const res = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
const res = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
}),
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: queryFilter,
|
||||
}),
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
return res;
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getAlbumSongsById = async (args: {
|
||||
id: string[];
|
||||
orderByIds?: boolean;
|
||||
query?: Partial<SongListQuery>;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
id: string[];
|
||||
orderByIds?: boolean;
|
||||
query?: Partial<SongListQuery>;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
}) => {
|
||||
const { id, queryClient, server, query } = args;
|
||||
const { id, queryClient, server, query } = args;
|
||||
|
||||
const queryFilter: SongListQuery = {
|
||||
albumIds: id,
|
||||
sortBy: SongListSort.ALBUM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
...query,
|
||||
};
|
||||
const queryFilter: SongListQuery = {
|
||||
albumIds: id,
|
||||
sortBy: SongListSort.ALBUM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
...query,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
||||
|
||||
const res = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
const res = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
}),
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: queryFilter,
|
||||
}),
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
return res;
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getAlbumArtistSongsById = async (args: {
|
||||
id: string[];
|
||||
orderByIds?: boolean;
|
||||
query?: Partial<SongListQuery>;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
id: string[];
|
||||
orderByIds?: boolean;
|
||||
query?: Partial<SongListQuery>;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
}) => {
|
||||
const { id, queryClient, server, query } = args;
|
||||
const { id, queryClient, server, query } = args;
|
||||
|
||||
const queryFilter: SongListQuery = {
|
||||
artistIds: id || [],
|
||||
sortBy: SongListSort.ALBUM_ARTIST,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
...query,
|
||||
};
|
||||
const queryFilter: SongListQuery = {
|
||||
artistIds: id || [],
|
||||
sortBy: SongListSort.ALBUM_ARTIST,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
...query,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
||||
|
||||
const res = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
const res = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
}),
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: queryFilter,
|
||||
}),
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
return res;
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getSongsByQuery = async (args: {
|
||||
query?: Partial<SongListQuery>;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
query?: Partial<SongListQuery>;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
}) => {
|
||||
const { queryClient, server, query } = args;
|
||||
const { queryClient, server, query } = args;
|
||||
|
||||
const queryFilter: SongListQuery = {
|
||||
sortBy: SongListSort.ALBUM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
...query,
|
||||
};
|
||||
const queryFilter: SongListQuery = {
|
||||
sortBy: SongListSort.ALBUM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
...query,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
||||
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
|
||||
|
||||
const res = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
const res = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
}),
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: queryFilter,
|
||||
}),
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
return res;
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getSongById = async (args: {
|
||||
id: string;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
id: string;
|
||||
queryClient: QueryClient;
|
||||
server: ServerListItem;
|
||||
}): Promise<SongListResponse> => {
|
||||
const { id, queryClient, server } = args;
|
||||
const { id, queryClient, server } = args;
|
||||
|
||||
const queryFilter: SongDetailQuery = { id };
|
||||
const queryFilter: SongDetailQuery = { id };
|
||||
|
||||
const queryKey = queryKeys.songs.detail(server?.id, queryFilter);
|
||||
const queryKey = queryKeys.songs.detail(server?.id, queryFilter);
|
||||
|
||||
const res = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongDetail({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
const res = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongDetail({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
}),
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: queryFilter,
|
||||
}),
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
if (!res) throw new Error('Song not found');
|
||||
if (!res) throw new Error('Song not found');
|
||||
|
||||
return {
|
||||
items: [res],
|
||||
startIndex: 0,
|
||||
totalRecordCount: 1,
|
||||
};
|
||||
return {
|
||||
items: [res],
|
||||
startIndex: 0,
|
||||
totalRecordCount: 1,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue