فهرست منبع

feat: basic mobile UI

    * feat: client list
    * fix: playhead height
    * feat: logo
    * feat: track number and current master info
    * feat: activate first song by artist/album when hitting enter
    * feat: seekable playhead
    * feat: basic song info
    * feat: mobile buttons
    * feat: separate component for mobile UI
Fela Maslen 5 سال پیش
والد
کامیت
69c2536819

+ 2 - 0
gmus-web/package.json

@@ -65,6 +65,7 @@
     "@types/pluralize": "^0.0.29",
     "@types/react": "^16.9.53",
     "@types/react-dom": "^16.9.8",
+    "@types/react-responsive": "^8.0.2",
     "@types/react-virtualized-auto-sizer": "^1.0.0",
     "@types/react-window": "^1.8.2",
     "@types/styled-components": "^5.1.5",
@@ -83,6 +84,7 @@
     "prettier": "^2.2.1",
     "react": "^17.0.1",
     "react-dom": "^17.0.1",
+    "react-responsive": "^8.2.0",
     "react-scripts": "4.0.1",
     "react-spinners": "^0.9.0",
     "react-storage-hooks": "^4.0.1",

+ 8 - 6
gmus-web/src/components/app.tsx

@@ -1,9 +1,9 @@
-import React, { Suspense, useCallback, useContext } from 'react';
+import React, { Suspense, useCallback, useContext, useMemo } from 'react';
+import { useMediaQuery } from 'react-responsive';
 import { StateInspector } from 'reinspect';
 
 import { stateSet } from '../actions';
 import { DispatchContext, StateContext } from '../context/state';
-import { useMaster } from '../hooks/master';
 import { usePlayQueue } from '../hooks/queue';
 import { useKeepalive } from '../hooks/socket';
 import { useCurrentlyPlayingSongInfo } from '../hooks/status';
@@ -20,12 +20,8 @@ export type Props = {
   interacted: boolean;
 } & InteractProps;
 
-const uiProvider = UIProvider.Cmus;
-const UI = uiProviders[uiProvider];
-
 export const App: React.FC<Props> = ({ socket, interacted, setInteracted }) => {
   useKeepalive(socket);
-  useMaster();
   useCurrentlyPlayingSongInfo();
 
   const state = useContext(StateContext);
@@ -40,6 +36,12 @@ export const App: React.FC<Props> = ({ socket, interacted, setInteracted }) => {
 
   const { onNext, onPrev } = usePlayQueue();
 
+  const isDesktop = useMediaQuery({ query: '(min-device-width: 1280px)' });
+
+  const UI = useMemo(() => uiProviders[isDesktop ? UIProvider.Cmus : UIProvider.Mobile], [
+    isDesktop,
+  ]);
+
   return (
     <>
       {isMaster(state) && !!state.player.songId && (

+ 23 - 0
gmus-web/src/components/logo.tsx

@@ -0,0 +1,23 @@
+import { rem } from 'polished';
+import React from 'react';
+import styled from 'styled-components';
+
+import logo1x from '../images/logo1x.png';
+import logo2x from '../images/logo2x.png';
+
+export type Props = {
+  size?: number;
+};
+
+const Picture = styled.picture<Required<Props>>`
+  img {
+    height: ${({ size }): string => rem(size)};
+    width: ${({ size }): string => rem(size)};
+  }
+`;
+
+export const Logo: React.FC<Props> = ({ size = 256 }) => (
+  <Picture size={size}>
+    <img srcSet={`${logo2x} 2x, ${logo1x} 1x`} alt="logo" />
+  </Picture>
+);

+ 26 - 0
gmus-web/src/components/ui/cmus/reducer/keypress.spec.ts

@@ -178,6 +178,32 @@ describe(ActionTypeKeyPressed, () => {
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.enter };
 
     describe('when in library view', () => {
+      describe('when in the artist list mode', () => {
+        it('should set the globalAction to play the first song by the artist', () => {
+          expect.assertions(1);
+
+          const result = cmusUIReducer(
+            {
+              ...stateWithActiveArtist,
+              library: {
+                ...stateWithActiveArtist.library,
+                modeWindow: LibraryModeWindow.ArtistList,
+              },
+            },
+            action,
+          );
+
+          expect(result.globalAction).toStrictEqual(
+            stateSet({
+              playing: true,
+              songId: 184,
+              currentTime: 0,
+              seekTime: 0,
+            }),
+          );
+        });
+      });
+
       describe('when in the songs list mode', () => {
         const state: CmusUIState = {
           ...initialCmusUIState,

+ 18 - 16
gmus-web/src/components/ui/cmus/reducer/keypress.ts

@@ -35,28 +35,30 @@ function toggleExpandArtist(library: CmusUIState['library']): CmusUIState['libra
   return { ...library, expandedArtists: [...library.expandedArtists, library.activeArtist] };
 }
 
-const activateSong = (state: CmusUIState, songId: number): CmusUIState =>
-  withGlobalAction(
-    state,
-    stateSet({
-      playing: true,
-      songId,
-      currentTime: 0,
-      seekTime: 0,
-    }),
-  );
+const activateSong = (state: CmusUIState, songId: number | null): CmusUIState =>
+  songId
+    ? withGlobalAction(
+        state,
+        stateSet({
+          playing: true,
+          songId,
+          currentTime: 0,
+          seekTime: 0,
+        }),
+      )
+    : state;
 
 function handleActivate(state: CmusUIState): CmusUIState {
   switch (state.view) {
     case View.Library:
-      if (state.library.modeWindow === LibraryModeWindow.SongList) {
-        if (!state.library.activeSongId) {
+      switch (state.library.modeWindow) {
+        case LibraryModeWindow.SongList:
+          return activateSong(state, state.library.activeSongId);
+        case LibraryModeWindow.ArtistList:
+          return activateSong(state, getFilteredSongs(state)[0]?.id ?? null);
+        default:
           return state;
-        }
-
-        return activateSong(state, state.library.activeSongId);
       }
-      return state;
 
     case View.Queue:
       if (!state.queue.active) {

+ 3 - 0
gmus-web/src/components/ui/cmus/wrapper.tsx

@@ -2,6 +2,7 @@ import React, { useContext, useEffect, useRef } from 'react';
 import { useReducer } from 'reinspect';
 
 import { DispatchContext } from '../../../context/state';
+import { useMaster } from '../../../hooks/master';
 import { useVimBindings } from '../../../hooks/vim';
 import { init } from '../../../utils/state';
 
@@ -28,6 +29,8 @@ import * as Styled from './wrapper.styles';
 const viewTitles = Object.values(View);
 
 export const CmusUIProvider: UIProviderComponent = ({ currentSong, nextSong, prevSong }) => {
+  useMaster();
+
   const dispatch = useContext(DispatchContext);
   const [stateUI, dispatchUI] = useReducer(cmusUIReducer, initialCmusUIState, init, 'ui');
 

+ 1 - 0
gmus-web/src/components/ui/index.ts

@@ -3,4 +3,5 @@ import { UIProvider, UIProviders } from './types';
 
 export const uiProviders: UIProviders = {
   [UIProvider.Cmus]: lazy(() => import('./cmus')),
+  [UIProvider.Mobile]: lazy(() => import('./mobile')),
 };

+ 71 - 0
gmus-web/src/components/ui/mobile/buttons.tsx

@@ -0,0 +1,71 @@
+import { rem } from 'polished';
+import React from 'react';
+import styled from 'styled-components';
+
+export type Props = {
+  playing: boolean;
+  onPrev: () => void;
+  onPlayPause: () => void;
+  onNext: () => void;
+};
+
+const buttonColor = 'white';
+
+const Container = styled.div`
+  box-sizing: border-box;
+  display: flex;
+  padding: ${rem(8)} ${rem(32)};
+  width: 100%;
+`;
+
+const Button = styled.button`
+  appearance: none;
+  background: none;
+  display: flex;
+  flex: 3;
+  flex-flow: column;
+  justify-content: center;
+  border: none;
+  outline: none;
+
+  svg {
+    border: 2px solid ${buttonColor};
+    border-radius: 100%;
+    flex: 0 0 auto;
+    width: 100%;
+  }
+`;
+
+const PlayPauseButton = styled(Button)`
+  flex: 4;
+`;
+
+export const Buttons: React.FC<Props> = ({ onPrev, onPlayPause, onNext, playing }) => (
+  <Container>
+    <Button onClick={onPrev}>
+      <svg viewBox="0 0 100 100" preserveAspectRatio="xMinYMin">
+        <path d="M48,50 L48,20 L14,50 L48,80 L48,50" stroke="none" fill={buttonColor} />
+        <path d="M78,50 L78,20 L46,50 L78,80 L78,50" stroke="none" fill={buttonColor} />
+      </svg>
+    </Button>
+    <PlayPauseButton onClick={onPlayPause}>
+      <svg viewBox="0 0 100 100">
+        {playing && (
+          <>
+            <path d="M33,50 L33,28 L45,28 L45,72 L33,72 L33,50" stroke="none" fill={buttonColor} />
+            <path d="M55,50 L55,28 L67,28 L67,72 L55,72 L55,50" stroke="none" fill={buttonColor} />
+          </>
+        )}
+        {!playing && (
+          <path d="M36,50 L36,20 L76,50 L36,80 L36,50" stroke="none" fill={buttonColor} />
+        )}
+      </svg>
+    </PlayPauseButton>
+    <Button onClick={onNext}>
+      <svg viewBox="0 0 100 100">
+        <path d="M22,50 L22,20 L56,50 L22,80 L22,50" stroke="none" fill={buttonColor} />
+        <path d="M54,50 L54,20 L86,50 L54,80 L54,50" stroke="none" fill={buttonColor} />
+      </svg>
+    </Button>
+  </Container>
+);

+ 85 - 0
gmus-web/src/components/ui/mobile/clients.tsx

@@ -0,0 +1,85 @@
+import { rem } from 'polished';
+import React, { useCallback, useContext, useMemo } from 'react';
+import styled from 'styled-components';
+import { masterSet } from '../../../actions';
+
+import { DispatchContext, StateContext } from '../../../context/state';
+import { Member } from '../../../types';
+
+const Container = styled.ul`
+  flex: 1;
+  list-style: none;
+  margin: ${rem(8)};
+  min-width: 60%;
+  padding: 0;
+`;
+
+type ClientProps = {
+  isMaster: boolean;
+  isUs: boolean;
+  isPaused: boolean;
+};
+
+const Client = styled.li<ClientProps>`
+  align-items: center;
+  background: ${({ isMaster }): string => (isMaster ? 'white' : '#ececec')};
+  border-radius: ${rem(4)};
+  display: flex;
+  height: ${rem(32)};
+  justify-content: center;
+  margin: 0 0 ${rem(4)} 0;
+  padding: 0 ${rem(16)};
+
+  button {
+    appearance: none;
+    background: none;
+    border: none;
+    font-style: inherit;
+    font-weight: inherit;
+    height: 100%;
+    outline: none;
+    width: 100%;
+  }
+`;
+
+const ClientMeta = styled.div`
+  flex: 0 0 ${rem(32)};
+  white-space: nowrap;
+`;
+
+export const ClientList: React.FC = () => {
+  const state = useContext(StateContext);
+  const dispatch = useContext(DispatchContext);
+
+  const onSwitchClient = useCallback(
+    (client: string) => {
+      dispatch(masterSet(client));
+    },
+    [dispatch],
+  );
+
+  const sortedClientList = useMemo<Member[]>(
+    () => state.clientList.slice().sort((a, b) => (a.name < b.name ? -1 : 1)),
+    [state.clientList],
+  );
+
+  return (
+    <Container>
+      {sortedClientList.map(({ name }) => (
+        <Client
+          key={name}
+          isMaster={name === state.player.master}
+          isUs={name === state.myClientName}
+          isPaused={!state.player.playing}
+          onClick={(): void => onSwitchClient(name)}
+        >
+          <button onClick={(): void => onSwitchClient(name)}>{name}</button>
+          <ClientMeta>
+            {name === state.player.master && (state.player.playing ? '🔊' : '🔈')}
+            {name === state.myClientName ? '🏠' : '📶'}
+          </ClientMeta>
+        </Client>
+      ))}
+    </Container>
+  );
+};

+ 3 - 0
gmus-web/src/components/ui/mobile/index.ts

@@ -0,0 +1,3 @@
+import { MobileUIProvider } from './wrapper';
+
+export default MobileUIProvider;

+ 134 - 0
gmus-web/src/components/ui/mobile/info.tsx

@@ -0,0 +1,134 @@
+import { rem } from 'polished';
+import React, { useCallback, useRef } from 'react';
+import styled from 'styled-components';
+
+import { MusicPlayer, Song } from '../../../types';
+import { formatTime } from '../../../utils/time';
+
+export type Props = {
+  song: Song | null;
+  player: MusicPlayer;
+  onSeek: (time: number) => void;
+};
+
+const Wrapper = styled.div`
+  width: 100%;
+`;
+
+const Container = styled.div`
+  align-items: center;
+  background: #e9fff3;
+  border-radius: ${rem(4)};
+  box-sizing: border-box;
+  display: flex;
+  flex-flow: column;
+  justify-content: center;
+  margin: ${rem(8)} ${rem(16)};
+  padding: ${rem(8)} 0 0 0;
+`;
+
+const Title = styled.span`
+  font-weight: bold;
+  margin-bottom: ${rem(4)};
+`;
+
+const Meta = styled.span`
+  box-sizing: border-box;
+  padding: 0 ${rem(4)};
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  width: 100%;
+`;
+const Artist = styled.span``;
+const Album = styled.span``;
+
+const Dash = styled.span`
+  margin: 0 ${rem(8)};
+`;
+
+const Seeker = styled.div`
+  background: rgba(0, 0, 0, 0.5);
+  color: white;
+  display: flex;
+  flex: 0 0 ${rem(16)};
+  margin-top: ${rem(8)};
+  width: 100%;
+`;
+
+const Time = styled.span`
+  align-items: center;
+  display: flex;
+  flex: 0 0 auto;
+  font-size: ${rem(12)};
+  height: 100%;
+  padding: 0 ${rem(8)};
+`;
+
+const TotalTime = styled(Time)`
+  text-align: right;
+`;
+
+const Gutter = styled.div`
+  display: flex;
+  flex: 1;
+  height: ${rem(16)};
+`;
+
+const Progress = styled.div`
+  align-items: flex-end;
+  display: flex;
+  justify-content: flex-end;
+  height: 100%;
+`;
+
+const PlayHead = styled.span`
+  background: white;
+  flex: 0 0 ${rem(2)};
+  height: 100%;
+`;
+
+export const SongInfo: React.FC<Props> = ({ song, player, onSeek }) => {
+  const gutter = useRef<HTMLDivElement>(null);
+  const seekToTouch = useCallback(
+    (event: React.TouchEvent) => {
+      if (!(gutter.current && song?.time && event.changedTouches.length)) {
+        return;
+      }
+      const fraction =
+        (event.changedTouches[0].pageX - gutter.current.offsetLeft) / gutter.current.offsetWidth;
+      onSeek(song.time * fraction);
+    },
+    [onSeek, song?.time],
+  );
+
+  return (
+    <Wrapper>
+      <Container>
+        {song && (
+          <>
+            <Title>
+              {song.track ? `${song.track} - ` : ''}
+              {song.title}
+            </Title>
+            <Meta>
+              <Artist>{song.artist || 'Unknown Artist'}</Artist>
+              <Dash>-</Dash>
+              <Album>{song.album || 'Unknown Album'}</Album>
+            </Meta>
+            <Seeker>
+              <Time>{formatTime(player.currentTime)}</Time>
+              <Gutter ref={gutter} onTouchEnd={seekToTouch}>
+                <Progress style={{ flexBasis: `${(100 * player.currentTime) / song.time}%` }}>
+                  <PlayHead />
+                </Progress>
+              </Gutter>
+              <TotalTime>{formatTime(song.time)}</TotalTime>
+            </Seeker>
+          </>
+        )}
+        {!song && <Dash>-</Dash>}
+      </Container>
+    </Wrapper>
+  );
+};

+ 16 - 0
gmus-web/src/components/ui/mobile/wrapper.styles.ts

@@ -0,0 +1,16 @@
+import { rem } from 'polished';
+import styled from 'styled-components';
+import { systemColors } from '../../../constants/theme';
+
+export const Container = styled.div`
+  align-items: center;
+  background: ${systemColors.background};
+  bottom: 0;
+  display: flex;
+  flex-flow: column;
+  font: ${rem(16)} sans-serif;
+  position: absolute;
+  right: 0;
+  top: 0;
+  width: 100%;
+`;

+ 33 - 0
gmus-web/src/components/ui/mobile/wrapper.tsx

@@ -0,0 +1,33 @@
+import React, { useCallback, useContext } from 'react';
+import { playPaused, seeked } from '../../../actions';
+
+import { DispatchContext, StateContext } from '../../../context/state';
+import { Logo } from '../../logo';
+import { UIProviderComponent } from '../types';
+import { Buttons } from './buttons';
+import { ClientList } from './clients';
+import { SongInfo } from './info';
+
+import * as Styled from './wrapper.styles';
+
+export const MobileUIProvider: UIProviderComponent = ({ prevSong, nextSong, currentSong }) => {
+  const dispatch = useContext(DispatchContext);
+  const state = useContext(StateContext);
+
+  const onPlayPause = useCallback(() => dispatch(playPaused()), [dispatch]);
+  const onSeek = useCallback((time: number) => dispatch(seeked(time)), [dispatch]);
+
+  return (
+    <Styled.Container>
+      <Logo size={128} />
+      <SongInfo song={currentSong} player={state.player} onSeek={onSeek} />
+      <ClientList />
+      <Buttons
+        playing={state.player.playing}
+        onPrev={prevSong}
+        onPlayPause={onPlayPause}
+        onNext={nextSong}
+      />
+    </Styled.Container>
+  );
+};

+ 1 - 0
gmus-web/src/components/ui/types.ts

@@ -4,6 +4,7 @@ import { Song } from '../../types';
 
 export enum UIProvider {
   Cmus = 'Cmus',
+  Mobile = 'Mobile',
 }
 
 export type UIProps = {

+ 5 - 0
gmus-web/src/constants/theme.ts

@@ -0,0 +1,5 @@
+import { rgb } from 'polished';
+
+export const systemColors = {
+  background: rgb(57, 0, 93),
+};

BIN
gmus-web/src/images/logo1x.png


BIN
gmus-web/src/images/logo2x.png


+ 6 - 5
gmus-web/src/reducer/reducer.spec.ts

@@ -330,14 +330,14 @@ describe(globalReducer.name, () => {
         myClientName: 'some-master-client',
       };
 
-      it('should set the seek time', () => {
+      it('should set the current and seek time', () => {
         expect.assertions(1);
         const result = globalReducer(stateMaster, action);
 
         expect(result.player).toStrictEqual<MusicPlayer>({
           songId: 123,
           playing: true,
-          currentTime: 31,
+          currentTime: 173,
           seekTime: 173,
           master: 'some-master-client',
           queue: [],
@@ -356,11 +356,12 @@ describe(globalReducer.name, () => {
         myClientName: 'my-client',
       };
 
-      it('should not update the player', () => {
-        expect.assertions(1);
+      it('should only set the current time (optimistically)', () => {
+        expect.assertions(2);
         const result = globalReducer(stateSlave, action);
 
-        expect(result.player).toBe(stateSlave.player);
+        expect(result.player.seekTime).toBe(stateSlave.player.seekTime);
+        expect(result.player.currentTime).toBe(173);
       });
     });
   });

+ 8 - 4
gmus-web/src/reducer/reducer.ts

@@ -90,10 +90,14 @@ export function globalReducer(state: GlobalState, action: AnyAction): GlobalStat
       return onLocalStateSet(state, action);
 
     case ActionTypeLocal.Seeked:
-      if (!isMaster(state)) {
-        return state;
-      }
-      return { ...state, player: { ...state.player, seekTime: action.payload } };
+      return {
+        ...state,
+        player: {
+          ...state.player,
+          currentTime: action.payload,
+          seekTime: isMaster(state) ? action.payload : state.player.seekTime,
+        },
+      };
 
     case ActionTypeLocal.MasterSet:
       if (action.payload) {

+ 40 - 1
gmus-web/yarn.lock

@@ -1964,6 +1964,13 @@
   dependencies:
     "@types/react" "*"
 
+"@types/react-responsive@^8.0.2":
+  version "8.0.2"
+  resolved "https://registry.yarnpkg.com/@types/react-responsive/-/react-responsive-8.0.2.tgz#959fdc32f72e38b807e32be617a74be2c315081b"
+  integrity sha512-DTvm3Hb77v0hme7L4GYzRjLQqlZP+zNImFBzdKbSH7CsQ5c7QebGnSQX2Xf3BaA0rm/TQE57eFMhMGLcMe/A9w==
+  dependencies:
+    "@types/react" "*"
+
 "@types/react-virtualized-auto-sizer@^1.0.0":
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.0.tgz#fc32f30a8dab527b5816f3a757e1e1d040c8f272"
@@ -3940,6 +3947,11 @@ css-loader@4.3.0:
     schema-utils "^2.7.1"
     semver "^7.3.2"
 
+css-mediaquery@^0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/css-mediaquery/-/css-mediaquery-0.1.2.tgz#6a2c37344928618631c54bd33cedd301da18bea0"
+  integrity sha1-aiw3NEkoYYYxxUvTPO3TAdoYvqA=
+
 css-prefers-color-scheme@^3.1.1:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/css-prefers-color-scheme/-/css-prefers-color-scheme-3.1.1.tgz#6f830a2714199d4f0d0d0bb8a27916ed65cff1f4"
@@ -5972,6 +5984,11 @@ husky@^4.3.6:
     slash "^3.0.0"
     which-pm-runs "^1.0.0"
 
+hyphenate-style-name@^1.0.0:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz#691879af8e220aea5750e8827db4ef62a54e361d"
+  integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==
+
 iconv-lite@0.4.24:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -7439,6 +7456,13 @@ map-visit@^1.0.0:
   dependencies:
     object-visit "^1.0.0"
 
+matchmediaquery@^0.3.0:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/matchmediaquery/-/matchmediaquery-0.3.1.tgz#8247edc47e499ebb7c58f62a9ff9ccf5b815c6d7"
+  integrity sha512-Hlk20WQHRIm9EE9luN1kjRjYXAQToHOIAHPJn9buxBwuhfTHoKUcX+lXBbxc85DVQfXYbEQ4HcwQdd128E3qHQ==
+  dependencies:
+    css-mediaquery "^0.1.2"
+
 md5.js@^1.3.4:
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f"
@@ -9240,7 +9264,7 @@ prompts@2.4.0, prompts@^2.0.1:
     kleur "^3.0.3"
     sisteransi "^1.0.5"
 
-prop-types@^15.7.2:
+prop-types@^15.6.1, prop-types@^15.7.2:
   version "15.7.2"
   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
   integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@@ -9470,6 +9494,16 @@ react-refresh@^0.8.3:
   resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
   integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg==
 
+react-responsive@^8.2.0:
+  version "8.2.0"
+  resolved "https://registry.yarnpkg.com/react-responsive/-/react-responsive-8.2.0.tgz#e0ffb306cfd8f38c9c12e26725b9e1245fa9debc"
+  integrity sha512-iagCqVrw4QSjhxKp3I/YK6+ODkWY6G+YPElvdYKiUUbywwh9Ds0M7r26Fj2/7dWFFbOpcGnJE6uE7aMck8j5Qg==
+  dependencies:
+    hyphenate-style-name "^1.0.0"
+    matchmediaquery "^0.3.0"
+    prop-types "^15.6.1"
+    shallow-equal "^1.1.0"
+
 react-scripts@4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-4.0.1.tgz#34974c0f4cfdf1655906c95df6a04d80db8b88f0"
@@ -10288,6 +10322,11 @@ shallow-clone@^3.0.0:
   dependencies:
     kind-of "^6.0.2"
 
+shallow-equal@^1.1.0:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.2.1.tgz#4c16abfa56043aa20d050324efa68940b0da79da"
+  integrity sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==
+
 shallowequal@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"