mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
add new player config popover
This commit is contained in:
@@ -493,6 +493,9 @@
|
|||||||
"queue_moveToBottom": "move selected to top",
|
"queue_moveToBottom": "move selected to top",
|
||||||
"queue_moveToTop": "move selected to bottom",
|
"queue_moveToTop": "move selected to bottom",
|
||||||
"queue_remove": "remove selected",
|
"queue_remove": "remove selected",
|
||||||
|
"queueType": "queue type",
|
||||||
|
"queueType_default": "default",
|
||||||
|
"queueType_priority": "priority",
|
||||||
"repeat": "repeat",
|
"repeat": "repeat",
|
||||||
"repeat_all": "repeat all",
|
"repeat_all": "repeat all",
|
||||||
"repeat_off": "repeat disabled",
|
"repeat_off": "repeat disabled",
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { ListConfigTable } from '/@/renderer/features/shared/components/list-config-menu';
|
||||||
|
import {
|
||||||
|
usePlayerActions,
|
||||||
|
usePlayerData,
|
||||||
|
usePlayerProperties,
|
||||||
|
usePlayerQueueType,
|
||||||
|
usePlayerSpeed,
|
||||||
|
} from '/@/renderer/store';
|
||||||
|
import {
|
||||||
|
usePlaybackSettings,
|
||||||
|
useSettingsStore,
|
||||||
|
useSettingsStoreActions,
|
||||||
|
} from '/@/renderer/store/settings.store';
|
||||||
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
|
import { Popover } from '/@/shared/components/popover/popover';
|
||||||
|
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
|
||||||
|
import { Slider } from '/@/shared/components/slider/slider';
|
||||||
|
import { Switch } from '/@/shared/components/switch/switch';
|
||||||
|
import { PlayerQueueType, PlayerStyle, PlayerType } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
export const PlayerConfig = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { currentSong } = usePlayerData();
|
||||||
|
const speed = usePlayerSpeed();
|
||||||
|
const queueType = usePlayerQueueType();
|
||||||
|
const { crossfadeDuration, transitionType } = usePlayerProperties();
|
||||||
|
const { setCrossfadeDuration, setQueueType, setSpeed, setTransitionType } = usePlayerActions();
|
||||||
|
const playbackSettings = usePlaybackSettings();
|
||||||
|
const { setSettings } = useSettingsStoreActions();
|
||||||
|
const speedPreservePitch = useSettingsStore((state) => state.playback.preservePitch);
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
const formatPlaybackSpeedSliderLabel = (value: number) => {
|
||||||
|
const bpm = Number(currentSong?.bpm);
|
||||||
|
if (bpm > 0) {
|
||||||
|
return `${value} x / ${(bpm * value).toFixed(1)} BPM`;
|
||||||
|
}
|
||||||
|
return `${value} x`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allOptions = [
|
||||||
|
{
|
||||||
|
component: (
|
||||||
|
<SegmentedControl
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: t('player.queueType_default', { postProcess: 'titleCase' }),
|
||||||
|
value: PlayerQueueType.DEFAULT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('player.queueType_priority', { postProcess: 'titleCase' }),
|
||||||
|
value: PlayerQueueType.PRIORITY,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={(value) => setQueueType(value as PlayerQueueType)}
|
||||||
|
size="sm"
|
||||||
|
value={queueType}
|
||||||
|
w="100%"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
id: 'queueType',
|
||||||
|
label: t('player.queueType', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
|
||||||
|
...(playbackSettings.type === PlayerType.WEB
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
component: (
|
||||||
|
<SegmentedControl
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: t('setting.playbackStyle', {
|
||||||
|
context: 'optionNormal',
|
||||||
|
postProcess: 'titleCase',
|
||||||
|
}),
|
||||||
|
value: PlayerStyle.GAPLESS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('setting.playbackStyle', {
|
||||||
|
context: 'optionCrossFade',
|
||||||
|
postProcess: 'titleCase',
|
||||||
|
}),
|
||||||
|
value: PlayerStyle.CROSSFADE,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={(value) => setTransitionType(value as PlayerStyle)}
|
||||||
|
size="sm"
|
||||||
|
value={transitionType}
|
||||||
|
w="100%"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
id: 'transitionType',
|
||||||
|
label: t('setting.playbackStyle', {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
|
||||||
|
...(playbackSettings.type === PlayerType.WEB && transitionType === PlayerStyle.CROSSFADE
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
component: (
|
||||||
|
<Slider
|
||||||
|
defaultValue={crossfadeDuration}
|
||||||
|
marks={[
|
||||||
|
{ label: '3', value: 3 },
|
||||||
|
{ label: '6', value: 6 },
|
||||||
|
{ label: '9', value: 9 },
|
||||||
|
{ label: '12', value: 12 },
|
||||||
|
{ label: '15', value: 15 },
|
||||||
|
]}
|
||||||
|
max={15}
|
||||||
|
min={3}
|
||||||
|
onChangeEnd={setCrossfadeDuration}
|
||||||
|
styles={{
|
||||||
|
root: {},
|
||||||
|
}}
|
||||||
|
w="100%"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
id: 'crossfadeDuration',
|
||||||
|
label: t('setting.crossfadeDuration', {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
|
||||||
|
...(playbackSettings.type === PlayerType.WEB
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
component: (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={speedPreservePitch}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettings({
|
||||||
|
playback: {
|
||||||
|
...playbackSettings,
|
||||||
|
preservePitch: e.currentTarget.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
id: 'preservePitch',
|
||||||
|
label: t('setting.preservePitch', {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
|
||||||
|
{
|
||||||
|
component: (
|
||||||
|
<Slider
|
||||||
|
label={formatPlaybackSpeedSliderLabel}
|
||||||
|
marks={[
|
||||||
|
{ label: '0.5', value: 0.5 },
|
||||||
|
{ label: '0.75', value: 0.75 },
|
||||||
|
{ label: '1', value: 1 },
|
||||||
|
{ label: '1.25', value: 1.25 },
|
||||||
|
{ label: '1.5', value: 1.5 },
|
||||||
|
{ label: '1.75', value: 1.75 },
|
||||||
|
{ label: '2', value: 2 },
|
||||||
|
]}
|
||||||
|
max={2}
|
||||||
|
min={0.5}
|
||||||
|
onChange={setSpeed}
|
||||||
|
onDoubleClick={() => setSpeed(1)}
|
||||||
|
step={0.01}
|
||||||
|
styles={{
|
||||||
|
markLabel: {},
|
||||||
|
root: {},
|
||||||
|
}}
|
||||||
|
value={speed}
|
||||||
|
w="100%"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
id: 'playbackSpeed',
|
||||||
|
label: t('player.playbackSpeed', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return allOptions;
|
||||||
|
}, [
|
||||||
|
playbackSettings,
|
||||||
|
speedPreservePitch,
|
||||||
|
setSettings,
|
||||||
|
currentSong,
|
||||||
|
speed,
|
||||||
|
setSpeed,
|
||||||
|
queueType,
|
||||||
|
setQueueType,
|
||||||
|
transitionType,
|
||||||
|
setTransitionType,
|
||||||
|
crossfadeDuration,
|
||||||
|
setCrossfadeDuration,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover position="top-end" width={500} withArrow>
|
||||||
|
<Popover.Target>
|
||||||
|
<ActionIcon
|
||||||
|
icon="mediaSpeed"
|
||||||
|
iconProps={{
|
||||||
|
size: 'lg',
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
tooltip={{
|
||||||
|
label: t('common.setting_other', { postProcess: 'titleCase' }),
|
||||||
|
openDelay: 0,
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown p="md">
|
||||||
|
<ListConfigTable options={options} />
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useHotkeys, useMediaQuery } from '@mantine/hooks';
|
import { useHotkeys, useMediaQuery } from '@mantine/hooks';
|
||||||
import isElectron from 'is-electron';
|
import { useCallback, WheelEvent } from 'react';
|
||||||
import { useCallback, useEffect, WheelEvent } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { PlayerConfig } from '/@/renderer/features/player/components/player-config';
|
||||||
import { CustomPlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
import { CustomPlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
||||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
||||||
@@ -13,29 +13,17 @@ import {
|
|||||||
useCurrentServer,
|
useCurrentServer,
|
||||||
useGeneralSettings,
|
useGeneralSettings,
|
||||||
useHotkeySettings,
|
useHotkeySettings,
|
||||||
usePlaybackSettings,
|
|
||||||
usePlaybackType,
|
|
||||||
usePlayerData,
|
usePlayerData,
|
||||||
usePlayerMuted,
|
usePlayerMuted,
|
||||||
usePlayerSpeed,
|
|
||||||
usePlayerVolume,
|
usePlayerVolume,
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useSettingsStoreActions,
|
|
||||||
useSidebarStore,
|
useSidebarStore,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
|
||||||
import { Flex } from '/@/shared/components/flex/flex';
|
import { Flex } from '/@/shared/components/flex/flex';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Option } from '/@/shared/components/option/option';
|
|
||||||
import { Rating } from '/@/shared/components/rating/rating';
|
import { Rating } from '/@/shared/components/rating/rating';
|
||||||
import { Slider } from '/@/shared/components/slider/slider';
|
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||||
import { Switch } from '/@/shared/components/switch/switch';
|
|
||||||
import { LibraryItem, QueueSong, ServerType, Song } from '/@/shared/types/domain-types';
|
|
||||||
import { PlayerType } from '/@/shared/types/types';
|
|
||||||
|
|
||||||
const ipc = isElectron() ? window.api.ipc : null;
|
|
||||||
const remote = isElectron() ? window.api.remote : null;
|
|
||||||
|
|
||||||
const calculateVolumeUp = (volume: number, volumeWheelStep: number) => {
|
const calculateVolumeUp = (volume: number, volumeWheelStep: number) => {
|
||||||
let volumeToSet;
|
let volumeToSet;
|
||||||
@@ -71,14 +59,9 @@ export const RightControls = () => {
|
|||||||
const { setSideBar } = useAppStoreActions();
|
const { setSideBar } = useAppStoreActions();
|
||||||
const { rightExpanded: isQueueExpanded } = useSidebarStore();
|
const { rightExpanded: isQueueExpanded } = useSidebarStore();
|
||||||
const { bindings } = useHotkeySettings();
|
const { bindings } = useHotkeySettings();
|
||||||
const { setSettings } = useSettingsStoreActions();
|
|
||||||
const playbackSettings = usePlaybackSettings();
|
|
||||||
const playbackType = usePlaybackType();
|
|
||||||
const { volumeWheelStep } = useGeneralSettings();
|
const { volumeWheelStep } = useGeneralSettings();
|
||||||
const speed = usePlayerSpeed();
|
|
||||||
const volumeWidth = useSettingsStore((state) => state.general.volumeWidth);
|
const volumeWidth = useSettingsStore((state) => state.general.volumeWidth);
|
||||||
const speedPreservePitch = useSettingsStore((state) => state.playback.preservePitch);
|
const { mediaToggleMute, setVolume } = usePlayer();
|
||||||
const { mediaToggleMute, setSpeed, setVolume } = usePlayer();
|
|
||||||
const updateRatingMutation = useSetRating({});
|
const updateRatingMutation = useSetRating({});
|
||||||
const addToFavoritesMutation = useCreateFavorite({});
|
const addToFavoritesMutation = useCreateFavorite({});
|
||||||
const removeFromFavoritesMutation = useDeleteFavorite({});
|
const removeFromFavoritesMutation = useDeleteFavorite({});
|
||||||
@@ -101,8 +84,9 @@ export const RightControls = () => {
|
|||||||
updateRatingMutation.mutate({
|
updateRatingMutation.mutate({
|
||||||
apiClientProps: { serverId: currentSong?._serverId || '' },
|
apiClientProps: { serverId: currentSong?._serverId || '' },
|
||||||
query: {
|
query: {
|
||||||
item: [currentSong],
|
id: [currentSong.id],
|
||||||
rating,
|
rating,
|
||||||
|
type: LibraryItem.SONG,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -141,13 +125,6 @@ export const RightControls = () => {
|
|||||||
mediaToggleMute();
|
mediaToggleMute();
|
||||||
}, [mediaToggleMute]);
|
}, [mediaToggleMute]);
|
||||||
|
|
||||||
const handleSpeed = useCallback(
|
|
||||||
(e: number) => {
|
|
||||||
setSpeed(e);
|
|
||||||
},
|
|
||||||
[setSpeed],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleVolumeSlider = useCallback(
|
const handleVolumeSlider = useCallback(
|
||||||
(e: number) => {
|
(e: number) => {
|
||||||
setVolume(e);
|
setVolume(e);
|
||||||
@@ -173,14 +150,6 @@ export const RightControls = () => {
|
|||||||
setSideBar({ rightExpanded: !isQueueExpanded });
|
setSideBar({ rightExpanded: !isQueueExpanded });
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatPlaybackSpeedSliderLabel = (value: number) => {
|
|
||||||
const bpm = Number(currentSong?.bpm);
|
|
||||||
if (bpm > 0) {
|
|
||||||
return `${value} x / ${(bpm * value).toFixed(1)} BPM`;
|
|
||||||
}
|
|
||||||
return `${value} x`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSongDefined = Boolean(currentSong?.id);
|
const isSongDefined = Boolean(currentSong?.id);
|
||||||
const showRating =
|
const showRating =
|
||||||
isSongDefined &&
|
isSongDefined &&
|
||||||
@@ -223,49 +192,6 @@ export const RightControls = () => {
|
|||||||
[bindings.rate5.isGlobal ? '' : bindings.rate5.hotkey, () => handleUpdateRating(5)],
|
[bindings.rate5.isGlobal ? '' : bindings.rate5.hotkey, () => handleUpdateRating(5)],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (remote) {
|
|
||||||
remote.requestFavorite((_event, { favorite, id, serverId }) => {
|
|
||||||
const mutator = favorite ? addToFavoritesMutation : removeFromFavoritesMutation;
|
|
||||||
mutator.mutate({
|
|
||||||
apiClientProps: { serverId },
|
|
||||||
query: {
|
|
||||||
id: [id],
|
|
||||||
type: LibraryItem.SONG,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
remote.requestRating((_event, { id, rating, serverId }) => {
|
|
||||||
updateRatingMutation.mutate({
|
|
||||||
apiClientProps: { serverId },
|
|
||||||
query: {
|
|
||||||
item: [
|
|
||||||
{
|
|
||||||
_serverId: currentSong?._serverId || '',
|
|
||||||
id,
|
|
||||||
itemType: LibraryItem.SONG,
|
|
||||||
} as Song, // This is not a type-safe cast, but it works because those are all the prop
|
|
||||||
],
|
|
||||||
rating,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ipc?.removeAllListeners('request-favorite');
|
|
||||||
ipc?.removeAllListeners('request-rating');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {};
|
|
||||||
}, [
|
|
||||||
addToFavoritesMutation,
|
|
||||||
currentSong?._serverId,
|
|
||||||
removeFromFavoritesMutation,
|
|
||||||
updateRatingMutation,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex align="flex-end" direction="column" h="100%" px="1rem" py="0.5rem">
|
<Flex align="flex-end" direction="column" h="100%" px="1rem" py="0.5rem">
|
||||||
<Group h="calc(100% / 3)">
|
<Group h="calc(100% / 3)">
|
||||||
@@ -278,73 +204,7 @@ export const RightControls = () => {
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group align="center" gap="xs" wrap="nowrap">
|
<Group align="center" gap="xs" wrap="nowrap">
|
||||||
<DropdownMenu arrowOffset={12} offset={0} position="top-end" width={425} withArrow>
|
<PlayerConfig />
|
||||||
<DropdownMenu.Target>
|
|
||||||
<ActionIcon
|
|
||||||
icon="mediaSpeed"
|
|
||||||
iconProps={{
|
|
||||||
size: 'lg',
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
tooltip={{
|
|
||||||
label: t('player.playbackSpeed', { postProcess: 'sentenceCase' }),
|
|
||||||
openDelay: 0,
|
|
||||||
}}
|
|
||||||
variant="subtle"
|
|
||||||
/>
|
|
||||||
</DropdownMenu.Target>
|
|
||||||
<DropdownMenu.Dropdown>
|
|
||||||
{playbackType === PlayerType.WEB && (
|
|
||||||
<Option>
|
|
||||||
<Option.Label>
|
|
||||||
{t('setting.preservePitch', {
|
|
||||||
postProcess: 'sentenceCase',
|
|
||||||
})}
|
|
||||||
</Option.Label>
|
|
||||||
<Option.Control>
|
|
||||||
<Switch
|
|
||||||
defaultChecked={speedPreservePitch}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSettings({
|
|
||||||
playback: {
|
|
||||||
...playbackSettings,
|
|
||||||
preservePitch: e.currentTarget.checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Option.Control>
|
|
||||||
</Option>
|
|
||||||
)}
|
|
||||||
<Slider
|
|
||||||
label={formatPlaybackSpeedSliderLabel}
|
|
||||||
marks={[
|
|
||||||
{ label: '0.5', value: 0.5 },
|
|
||||||
{ label: '0.75', value: 0.75 },
|
|
||||||
{ label: '1', value: 1 },
|
|
||||||
{ label: '1.25', value: 1.25 },
|
|
||||||
{ label: '1.5', value: 1.5 },
|
|
||||||
]}
|
|
||||||
max={1.5}
|
|
||||||
min={0.5}
|
|
||||||
onChange={handleSpeed}
|
|
||||||
onDoubleClick={() => handleSpeed(1)}
|
|
||||||
step={0.01}
|
|
||||||
styles={{
|
|
||||||
markLabel: {
|
|
||||||
paddingTop: '0.5rem',
|
|
||||||
},
|
|
||||||
root: {
|
|
||||||
margin: '1rem 1rem 2rem 1rem',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
value={speed}
|
|
||||||
/>
|
|
||||||
</DropdownMenu.Dropdown>
|
|
||||||
</DropdownMenu>
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="favorite"
|
icon="favorite"
|
||||||
iconProps={{
|
iconProps={{
|
||||||
|
|||||||
@@ -1218,6 +1218,7 @@ export const usePlayerActions = () => {
|
|||||||
moveSelectedToNext: state.moveSelectedToNext,
|
moveSelectedToNext: state.moveSelectedToNext,
|
||||||
moveSelectedToTop: state.moveSelectedToTop,
|
moveSelectedToTop: state.moveSelectedToTop,
|
||||||
setCrossfadeDuration: state.setCrossfadeDuration,
|
setCrossfadeDuration: state.setCrossfadeDuration,
|
||||||
|
setQueueType: state.setQueueType,
|
||||||
setRepeat: state.setRepeat,
|
setRepeat: state.setRepeat,
|
||||||
setShuffle: state.setShuffle,
|
setShuffle: state.setShuffle,
|
||||||
setSpeed: state.setSpeed,
|
setSpeed: state.setSpeed,
|
||||||
|
|||||||
Reference in New Issue
Block a user