Ver código fonte

feat: interactive client list and master/slave status

    * fix: seek to current time when assigning self to master
    * feat: show client list as an overlay
    * feat: current client state in status bar
    * feat: assign remote clients to master from client list
    * fix: handle edge cases when artist is an empty string
    * fix: hook to fetch info separate from artists view
    * feat: integrate UI reducers with dev tools
    * feat: basic client list with status indicators
    * feat: hide scrollbars
    * feat: dummy client list view
    * feat: added client list commands to help dialog
    * fix: updated test
Fela Maslen 5 anos atrás
pai
commit
9e494b1c9e
33 arquivos alterados com 545 adições e 146 exclusões
  1. 1 0
      gmus/package.json
  2. 5 5
      gmus/src/actions/actions.ts
  3. 1 1
      gmus/src/actions/types.ts
  4. 6 3
      gmus/src/components/app.tsx
  5. 3 4
      gmus/src/components/identify.spec.tsx
  6. 9 0
      gmus/src/components/ui/cmus/actions.ts
  7. 73 8
      gmus/src/components/ui/cmus/reducer.spec.ts
  8. 71 34
      gmus/src/components/ui/cmus/reducer.ts
  9. 12 2
      gmus/src/components/ui/cmus/styled/layout.ts
  10. 11 0
      gmus/src/components/ui/cmus/styled/typography.ts
  11. 3 0
      gmus/src/components/ui/cmus/styled/variables.ts
  12. 8 0
      gmus/src/components/ui/cmus/types.ts
  13. 38 0
      gmus/src/components/ui/cmus/utils/library.ts
  14. 3 2
      gmus/src/components/ui/cmus/utils/scroll.ts
  15. 8 34
      gmus/src/components/ui/cmus/views/artists.tsx
  16. 30 0
      gmus/src/components/ui/cmus/views/clients.styles.ts
  17. 92 0
      gmus/src/components/ui/cmus/views/clients.tsx
  18. 6 6
      gmus/src/components/ui/cmus/views/help.styles.ts
  19. 15 6
      gmus/src/components/ui/cmus/views/help.tsx
  20. 2 11
      gmus/src/components/ui/cmus/views/library.tsx
  21. 2 1
      gmus/src/components/ui/cmus/views/songs.tsx
  22. 7 0
      gmus/src/components/ui/cmus/views/status.styles.ts
  23. 10 4
      gmus/src/components/ui/cmus/views/status.tsx
  24. 12 0
      gmus/src/components/ui/cmus/wrapper.styles.ts
  25. 27 13
      gmus/src/components/ui/cmus/wrapper.tsx
  26. 21 3
      gmus/src/effects/effects.spec.ts
  27. 12 1
      gmus/src/effects/effects.ts
  28. 2 2
      gmus/src/hooks/master.spec.tsx
  29. 2 2
      gmus/src/hooks/master.ts
  30. 1 0
      gmus/src/hooks/vim.ts
  31. 20 3
      gmus/src/reducer/reducer.spec.ts
  32. 12 1
      gmus/src/reducer/reducer.ts
  33. 20 0
      gmus/yarn.lock

+ 1 - 0
gmus/package.json

@@ -87,6 +87,7 @@
     "react-storage-hooks": "^4.0.1",
     "react-virtualized-auto-sizer": "^1.0.2",
     "react-window": "^1.8.6",
+    "reinspect": "^1.1.0",
     "styled-components": "^5.2.1",
     "styled-reset": "^4.3.3",
     "typescript": "^4.0.3",

+ 5 - 5
gmus/src/actions/actions.ts

@@ -37,11 +37,11 @@ export const seeked = (time: number): ActionSeeked => ({
   payload: time,
 });
 
-export type ActionMasterRetaken = ActionLocal<ActionTypeLocal.MasterRetaken, void>;
+export type ActionMasterSet = ActionLocal<ActionTypeLocal.MasterSet, string | undefined>;
 
-export const masterRetaken = (): ActionMasterRetaken => ({
-  type: ActionTypeLocal.MasterRetaken,
-  payload: undefined,
+export const masterSet = (name?: string): ActionMasterSet => ({
+  type: ActionTypeLocal.MasterSet,
+  payload: name,
 });
 
 export type ActionPlayPaused = ActionLocal<ActionTypeLocal.PlayPaused, void>;
@@ -58,6 +58,6 @@ export type LocalAction =
   | ActionStateSetLocal
   | ActionSeeked
   | ActionPlayPaused
-  | ActionMasterRetaken;
+  | ActionMasterSet;
 
 export type AnyAction = LocalAction | RemoteAction;

+ 1 - 1
gmus/src/actions/types.ts

@@ -11,7 +11,7 @@ export enum ActionTypeLocal {
   NameSet = '@@local/NAME_SET',
   StateSet = '@@local/STATE_SET',
   Seeked = '@@local/SEEKED',
-  MasterRetaken = '@@local/MASTER_RETAKEN',
+  MasterSet = '@@local/MASTER_SET',
   PlayPaused = '@@local/PLAY_PAUSED',
 }
 

+ 6 - 3
gmus/src/components/app.tsx

@@ -1,4 +1,5 @@
 import React, { Dispatch, Suspense, useCallback } from 'react';
+import { StateInspector } from 'reinspect';
 
 import { LocalAction, stateSet } from '../actions';
 import { DispatchContext, StateContext } from '../context/state';
@@ -47,9 +48,11 @@ export const App: React.FC<Props> = ({ socket, state, dispatch }) => {
       )}
       <StateContext.Provider value={state}>
         <DispatchContext.Provider value={dispatch}>
-          <Suspense fallback={<LoadingWrapper />}>
-            <UI isMaster={isMaster(state)} currentSong={currentSong} />
-          </Suspense>
+          <StateInspector name="ui">
+            <Suspense fallback={<LoadingWrapper />}>
+              <UI isMaster={isMaster(state)} currentSong={currentSong} />
+            </Suspense>
+          </StateInspector>
         </DispatchContext.Provider>
       </StateContext.Provider>
     </>

+ 3 - 4
gmus/src/components/identify.spec.tsx

@@ -43,12 +43,11 @@ describe(Identify.name, () => {
   describe('when connecting', () => {
     const propsConnecting: Props = { ...props, connecting: true };
 
-    it('should disable the connect button', () => {
+    it('should not render the connect button', () => {
       expect.assertions(1);
 
-      const { getByText } = render(<Identify {...propsConnecting} />);
-      const button = getByText('Connect') as HTMLButtonElement;
-      expect(button.disabled).toBe(true);
+      const { queryByText } = render(<Identify {...propsConnecting} />);
+      expect(queryByText('Connect')).not.toBeInTheDocument();
     });
   });
 });

+ 9 - 0
gmus/src/components/ui/cmus/actions.ts

@@ -7,6 +7,7 @@ export enum CmusUIActionType {
   ArtistAlbumsLoaded = '@@ui/cmus/ARTIST_ALBUMS_LOADED',
   ArtistSongsLoaded = '@@ui/cmus/ARTIST_SONGS_LOADED',
   CommandSet = '@@ui/cmus/COMMAND_SET',
+  ClientActivated = '@@ui/cmus/CLIENT_ACTIVATED',
 }
 
 export type ArtistsSet = Action<CmusUIActionType.ArtistsSet, string[]>;
@@ -49,9 +50,17 @@ export const commandSet = (command: string | null): CommandSet => ({
   payload: command,
 });
 
+export type ClientActivated = Action<CmusUIActionType.ClientActivated, string | null>;
+
+export const clientActivated = (name: string | null): ClientActivated => ({
+  type: CmusUIActionType.ClientActivated,
+  payload: name,
+});
+
 export type CmusUIAction =
   | ArtistsSet
   | ArtistAlbumsLoaded
   | ArtistSongsLoaded
   | CommandSet
+  | ClientActivated
   | ActionKeyPressed;

+ 73 - 8
gmus/src/components/ui/cmus/reducer.spec.ts

@@ -1,10 +1,11 @@
-import { loggedOut, playPaused, stateSet } from '../../../actions';
+import { loggedOut, masterSet, playPaused, stateSet } from '../../../actions';
 import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../hooks/vim';
 import { Song } from '../../../types';
 import {
   artistAlbumsLoaded,
   artistSongsLoaded,
   artistsSet,
+  clientActivated,
   CmusUIActionType,
   commandSet,
 } from './actions';
@@ -22,6 +23,12 @@ describe(cmusUIReducer.name, () => {
     commandMode: true,
   };
 
+  const stateDifferentView: CmusUIState = {
+    ...initialCmusUIState,
+    view: (undefined as unknown) as View,
+    scroll: { delta: 0, serialNumber: 8813 },
+  };
+
   describe(CmusUIActionType.ArtistsSet, () => {
     const action = artistsSet(['Artist A', 'Artist B']);
 
@@ -32,9 +39,20 @@ describe(cmusUIReducer.name, () => {
     });
 
     it('should set the active artist to the first artist in the list', () => {
-      expect.assertions(1);
-      const result = cmusUIReducer(initialCmusUIState, action);
+      expect.assertions(2);
+      const result = cmusUIReducer(
+        {
+          ...initialCmusUIState,
+          library: {
+            ...initialCmusUIState.library,
+            activeArtist: 'Artist Z',
+            activeAlbum: 'Some album',
+          },
+        },
+        action,
+      );
       expect(result.library.activeArtist).toBe('Artist A');
+      expect(result.library.activeAlbum).toBeNull();
     });
   });
 
@@ -115,16 +133,30 @@ describe(cmusUIReducer.name, () => {
     });
   });
 
-  describe('Keypress actions', () => {
-    describe(Keys['1'], () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys['1'] };
+  describe(CmusUIActionType.ClientActivated, () => {
+    const action = clientActivated('some-client');
 
-      it('should set the view to Library', () => {
+    it('should set the active client', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.clientList.active).toBe('some-client');
+    });
+  });
+
+  describe('Keypress actions', () => {
+    describe.each`
+      key          | toView
+      ${Keys['1']} | ${View.Library}
+      ${Keys['2']} | ${View.ClientList}
+    `('$key', ({ key, toView }) => {
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key };
+
+      it(`should set the view to ${toView}`, () => {
         expect.assertions(1);
         const state = ({ ...initialCmusUIState, view: undefined } as unknown) as CmusUIState;
         const result = cmusUIReducer(state, action);
 
-        expect(result.view).toBe(View.Library);
+        expect(result.view).toBe(toView);
       });
     });
 
@@ -273,6 +305,14 @@ describe(cmusUIReducer.name, () => {
           });
         });
       });
+
+      describe('when in a different view', () => {
+        it('should set the scroll delta and increment the serial number', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateDifferentView, action);
+          expect(result.scroll).toStrictEqual({ delta: 1, serialNumber: 8814 });
+        });
+      });
     });
 
     describe(Keys.K, () => {
@@ -382,6 +422,14 @@ describe(cmusUIReducer.name, () => {
           });
         });
       });
+
+      describe('when in a different view', () => {
+        it('should set the scroll delta and increment the serial number', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateDifferentView, action);
+          expect(result.scroll).toStrictEqual({ delta: -1, serialNumber: 8814 });
+        });
+      });
     });
 
     describe(Keys.pageDown, () => {
@@ -621,6 +669,23 @@ describe(cmusUIReducer.name, () => {
           });
         });
       });
+
+      describe('when in client list view', () => {
+        const state: CmusUIState = {
+          ...initialCmusUIState,
+          globalActionSerialNumber: 123,
+          view: View.ClientList,
+          clientList: {
+            active: 'some-active-client',
+          },
+        };
+
+        it('should set the globalAction to set the given client to master', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(state, action);
+          expect(result.globalAction).toStrictEqual(masterSet('some-active-client'));
+        });
+      });
     });
 
     describe(Keys.esc, () => {

+ 71 - 34
gmus/src/components/ui/cmus/reducer.ts

@@ -1,6 +1,6 @@
 import { createContext, Dispatch } from 'react';
 
-import { LocalAction, loggedOut, playPaused, stateSet } from '../../../actions';
+import { LocalAction, loggedOut, masterSet, playPaused, stateSet } from '../../../actions';
 import { nullDispatch } from '../../../context/state';
 import { ActionTypeKeyPressed, Keys } from '../../../hooks/vim';
 import { Song } from '../../../types';
@@ -18,6 +18,7 @@ import { getNextActiveArtistAndAlbum } from './utils/scroll';
 export const initialCmusUIState: CmusUIState = {
   globalAction: null,
   globalActionSerialNumber: 0,
+  scroll: { delta: 0, serialNumber: 0 },
   view: View.Library,
   commandMode: false,
   overlay: null,
@@ -32,6 +33,9 @@ export const initialCmusUIState: CmusUIState = {
     activeSongId: null,
     visibleSongs: [],
   },
+  clientList: {
+    active: null,
+  },
 };
 
 export const CmusUIStateContext = createContext<CmusUIState>(initialCmusUIState);
@@ -61,7 +65,7 @@ function getActiveSongIdFromActiveArtistAlbum(
   activeAlbum: string | null,
   artistSongs: Record<string, Song[]>,
 ): number | null {
-  if (!activeArtist) {
+  if (activeArtist === null) {
     return null;
   }
   const songs = artistSongs[activeArtist] ?? [];
@@ -93,8 +97,9 @@ const scrollArtists = (state: CmusUIState, delta: number): CmusUIState => {
 };
 
 const scrollSongs = (state: CmusUIState, delta: number): CmusUIState =>
-  state.library.activeArtist
-    ? {
+  state.library.activeArtist === null
+    ? state
+    : {
         ...state,
         library: {
           ...state.library,
@@ -104,11 +109,10 @@ const scrollSongs = (state: CmusUIState, delta: number): CmusUIState =>
             delta,
           ).id,
         },
-      }
-    : state;
+      };
 
 function toggleExpandArtist(library: CmusUIState['library']): CmusUIState['library'] {
-  if (!library.activeArtist) {
+  if (library.activeArtist === null) {
     return library;
   }
   if (library.expandedArtists.includes(library.activeArtist)) {
@@ -123,17 +127,58 @@ function toggleExpandArtist(library: CmusUIState['library']): CmusUIState['libra
   return { ...library, expandedArtists: [...library.expandedArtists, library.activeArtist] };
 }
 
-function handleScroll(state: CmusUIState, delta: number): CmusUIState {
-  if (state.view === View.Library) {
-    if (state.library.modeWindow === LibraryModeWindow.ArtistList) {
+function handleScrollLibrary(state: CmusUIState, delta: number): CmusUIState {
+  switch (state.library.modeWindow) {
+    case LibraryModeWindow.ArtistList:
       return scrollArtists(state, delta);
-    }
-    if (state.library.modeWindow === LibraryModeWindow.SongList) {
+    case LibraryModeWindow.SongList:
       return scrollSongs(state, delta);
-    }
+    default:
+      return state;
   }
+}
 
-  return state;
+function handleScroll(state: CmusUIState, delta: number): CmusUIState {
+  switch (state.view) {
+    case View.Library:
+      return handleScrollLibrary(state, delta);
+    default:
+      return {
+        ...state,
+        scroll: { delta, serialNumber: state.scroll.serialNumber + 1 },
+      };
+  }
+}
+
+function handleActivate(state: CmusUIState): CmusUIState {
+  switch (state.view) {
+    case View.Library:
+      if (state.library.modeWindow === LibraryModeWindow.SongList) {
+        if (!state.library.activeSongId) {
+          return state;
+        }
+
+        return withGlobalAction(
+          state,
+          stateSet({
+            playing: true,
+            songId: state.library.activeSongId,
+            currentTime: 0,
+            seekTime: 0,
+          }),
+        );
+      }
+      return state;
+
+    case View.ClientList:
+      if (!state.clientList.active) {
+        return state;
+      }
+      return withGlobalAction(state, masterSet(state.clientList.active));
+
+    default:
+      return state;
+  }
 }
 
 function handleKeyPress(state: CmusUIState, key: string): CmusUIState {
@@ -143,6 +188,9 @@ function handleKeyPress(state: CmusUIState, key: string): CmusUIState {
 
     case Keys['1']:
       return { ...state, view: View.Library };
+    case Keys['2']:
+      return { ...state, view: View.ClientList };
+
     case Keys.tab:
       if (state.view === View.Library) {
         return switchLibraryMode(state);
@@ -158,25 +206,7 @@ function handleKeyPress(state: CmusUIState, key: string): CmusUIState {
       return state;
 
     case Keys.enter:
-      if (state.view === View.Library) {
-        if (state.library.modeWindow === LibraryModeWindow.SongList) {
-          if (!state.library.activeSongId) {
-            return state;
-          }
-
-          return withGlobalAction(
-            state,
-            stateSet({
-              playing: true,
-              songId: state.library.activeSongId,
-              currentTime: 0,
-              seekTime: 0,
-            }),
-          );
-        }
-      }
-
-      return state;
+      return handleActivate(state);
 
     case Keys.esc:
       return { ...state, overlay: null };
@@ -205,7 +235,11 @@ function handleKeyPress(state: CmusUIState, key: string): CmusUIState {
 const setArtists = (state: CmusUIState, action: ArtistsSet): CmusUIState => ({
   ...state,
   artists: action.payload,
-  library: { ...state.library, activeArtist: action.payload[0] ?? null },
+  library: {
+    ...state.library,
+    activeArtist: action.payload[0] ?? null,
+    activeAlbum: null,
+  },
 });
 
 const setArtistAlbums = (state: CmusUIState, action: ArtistAlbumsLoaded): CmusUIState => ({
@@ -251,6 +285,9 @@ export function cmusUIReducer(state: CmusUIState, action: CmusUIAction): CmusUIS
     case CmusUIActionType.CommandSet:
       return onCommand(state, action.payload);
 
+    case CmusUIActionType.ClientActivated:
+      return { ...state, clientList: { active: action.payload } };
+
     default:
       return state;
   }

+ 12 - 2
gmus/src/components/ui/cmus/styled/layout.ts

@@ -21,11 +21,13 @@ export const NoWrap = styled.div`
   white-space: nowrap;
 `;
 
-export const ActiveHighlightRow = styled(FlexRow)<{
+export type ActiveHighlightRowProps = {
   active?: boolean;
   highlight?: boolean;
   parentActive?: boolean;
-}>`
+};
+
+export const ActiveHighlightRow = styled(FlexRow)<ActiveHighlightRowProps>`
   background: ${({ active, parentActive }): string => {
     if (active) {
       if (parentActive) {
@@ -58,4 +60,12 @@ export const ActiveHighlightRow = styled(FlexRow)<{
 
 export const FlexList = styled(FlexColumn)`
   min-width: 0;
+
+  * {
+    &::-webkit-scrollbar {
+      display: none;
+    }
+    scrollbar-width: none;
+    -ms-overflow-style: none;
+  }
 `;

+ 11 - 0
gmus/src/components/ui/cmus/styled/typography.ts

@@ -0,0 +1,11 @@
+import { rem } from 'polished';
+import styled from 'styled-components';
+
+export const H3 = styled.h3`
+  font-weight: bold;
+  margin-bottom: ${rem(8)};
+`;
+
+export const H5 = styled.h5`
+  margin-bottom: ${rem(8)};
+`;

+ 3 - 0
gmus/src/components/ui/cmus/styled/variables.ts

@@ -4,6 +4,9 @@ export const colors = {
   background: rgb(0, 0, 0),
   border: rgb(0, 0, 180),
   foreground: rgb(255, 255, 255),
+  title: {
+    background: rgb(255, 210, 0),
+  },
   selected: {
     background: rgb(0, 0, 180),
     inactive: rgb(210, 210, 210),

+ 8 - 0
gmus/src/components/ui/cmus/types.ts

@@ -3,6 +3,7 @@ import { Song } from '../../../types/songs';
 
 export enum View {
   Library = 'Library',
+  ClientList = 'Client list',
 }
 
 export enum Overlay {
@@ -17,6 +18,10 @@ export enum LibraryModeWindow {
 export type CmusUIState = {
   globalAction: LocalAction | null;
   globalActionSerialNumber: number;
+  scroll: {
+    delta: number;
+    serialNumber: number;
+  };
   view: View;
   commandMode: boolean;
   overlay: Overlay | null;
@@ -31,4 +36,7 @@ export type CmusUIState = {
     modeWindow: LibraryModeWindow;
     visibleSongs: Song[];
   };
+  clientList: {
+    active: string | null;
+  };
 };

+ 38 - 0
gmus/src/components/ui/cmus/utils/library.ts

@@ -0,0 +1,38 @@
+import { useDebounce } from '@react-hook/debounce';
+import { Dispatch, useEffect } from 'react';
+
+import { useArtists, useArtistsAlbumsAndSongs } from '../../../../hooks/fetch/artists';
+
+import { artistAlbumsLoaded, artistSongsLoaded, artistsSet, CmusUIAction } from '../actions';
+import { CmusUIState } from '../types';
+
+export function useLibrary(
+  { library: { activeArtist, expandedArtists } }: CmusUIState,
+  dispatchUI: Dispatch<CmusUIAction>,
+): void {
+  const { artists } = useArtists();
+  useEffect(() => {
+    dispatchUI(artistsSet(artists));
+  }, [dispatchUI, artists]);
+
+  const [debouncedActiveArtist, setDebouncedActiveArtist] = useDebounce(activeArtist, 200);
+  useEffect(() => {
+    setDebouncedActiveArtist(activeArtist);
+  }, [activeArtist, setDebouncedActiveArtist]);
+
+  const { albums, songs } = useArtistsAlbumsAndSongs(
+    debouncedActiveArtist ?? '',
+    debouncedActiveArtist === null || !expandedArtists.includes(debouncedActiveArtist),
+    debouncedActiveArtist === null,
+  );
+  useEffect(() => {
+    if (albums) {
+      dispatchUI(artistAlbumsLoaded(albums.artist, albums.albums));
+    }
+  }, [dispatchUI, albums]);
+  useEffect(() => {
+    if (songs) {
+      dispatchUI(artistSongsLoaded(songs.artist, songs.songs));
+    }
+  }, [dispatchUI, songs]);
+}

+ 3 - 2
gmus/src/components/ui/cmus/utils/scroll.ts

@@ -5,7 +5,8 @@ const getArtistAlbums = (
   artist: string | null,
   artistAlbums: Record<string, string[]>,
   expandedArtists: string[],
-): string[] => (artist && expandedArtists.includes(artist) ? artistAlbums[artist] ?? [] : []);
+): string[] =>
+  artist !== null && expandedArtists.includes(artist) ? artistAlbums[artist] ?? [] : [];
 
 export function getNextActiveArtistAndAlbum(
   artists: string[],
@@ -22,7 +23,7 @@ export function getNextActiveArtistAndAlbum(
       return { artist: artists[0] ?? null, album: null };
     }
     const lastArtist = artists.length > 0 ? artists[artists.length - 1] : null;
-    if (!lastArtist) {
+    if (lastArtist === null) {
       return { artist: null, album: null };
     }
     const lastArtistAlbums = getArtistAlbums(lastArtist, artistAlbums, expandedArtists);

+ 8 - 34
gmus/src/components/ui/cmus/views/artists.tsx

@@ -1,12 +1,9 @@
-import { useDebounce } from '@react-hook/debounce';
-import React, { CSSProperties, useContext, useEffect, useMemo, useRef } from 'react';
+import React, { CSSProperties, useContext, useMemo, useRef } from 'react';
 import AutoSizer from 'react-virtualized-auto-sizer';
 import { FixedSizeList as List } from 'react-window';
 
-import { useArtistsAlbumsAndSongs } from '../../../../hooks/fetch/artists';
 import { namedMemo } from '../../../../utils/component';
-import { artistAlbumsLoaded, artistSongsLoaded } from '../actions';
-import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
+import { CmusUIStateContext } from '../reducer';
 import { NoWrapFill } from '../styled/layout';
 import { AsciiSpinner } from '../styled/spinner';
 import { getArtistAlbumScrollIndex, lineHeight, useAutoJumpyScroll } from '../utils/scroll';
@@ -76,34 +73,11 @@ const Row = namedMemo<{ index: number; data: RowData[]; style: CSSProperties }>(
 );
 
 export const Artists: React.FC<Props> = ({ active: parentActive, currentArtist }) => {
-  const dispatchUI = useContext(CmusUIDispatchContext);
-  const state = useContext(CmusUIStateContext);
   const {
     artists,
     artistAlbums,
     library: { activeArtist, activeAlbum, expandedArtists },
-  } = state;
-
-  const [debouncedActiveArtist, setDebouncedActiveArtist] = useDebounce(activeArtist, 200);
-  useEffect(() => {
-    setDebouncedActiveArtist(activeArtist);
-  }, [activeArtist, setDebouncedActiveArtist]);
-
-  const { albums, songs } = useArtistsAlbumsAndSongs(
-    debouncedActiveArtist ?? '',
-    !(debouncedActiveArtist && expandedArtists.includes(debouncedActiveArtist)),
-    !debouncedActiveArtist,
-  );
-  useEffect(() => {
-    if (albums) {
-      dispatchUI(artistAlbumsLoaded(albums.artist, albums.albums));
-    }
-  }, [dispatchUI, albums]);
-  useEffect(() => {
-    if (songs) {
-      dispatchUI(artistSongsLoaded(songs.artist, songs.songs));
-    }
-  }, [dispatchUI, songs]);
+  } = useContext(CmusUIStateContext);
 
   const itemData = useMemo<RowData[]>(
     () =>
@@ -146,11 +120,11 @@ export const Artists: React.FC<Props> = ({ active: parentActive, currentArtist }
 
   const windowRef = useRef<HTMLDivElement>(null);
   const scrollIndex = getArtistAlbumScrollIndex(
-    state.artists,
-    state.artistAlbums,
-    state.library.activeArtist,
-    state.library.activeAlbum,
-    state.library.expandedArtists,
+    artists,
+    artistAlbums,
+    activeArtist,
+    activeAlbum,
+    expandedArtists,
   );
 
   useAutoJumpyScroll(windowRef, scrollIndex);

+ 30 - 0
gmus/src/components/ui/cmus/views/clients.styles.ts

@@ -0,0 +1,30 @@
+import { rem } from 'polished';
+import { CSSProperties } from 'react';
+import styled from 'styled-components';
+import { ActiveHighlightRow, ActiveHighlightRowProps, FlexColumn } from '../styled/layout';
+import { colors } from '../styled/variables';
+
+export const Container = styled.div`
+  background: ${colors.background};
+  border: 1px solid ${colors.border};
+  overflow-y: hidden;
+  padding: ${rem(8)} ${rem(16)} ${rem(16)} ${rem(16)};
+  z-index: 11;
+`;
+
+export const List = styled(FlexColumn)`
+  min-width: ${rem(360)};
+  overflow-y: auto;
+`;
+
+export const Client = styled(ActiveHighlightRow)<ActiveHighlightRowProps>`
+  display: flex;
+  font-weight: ${({ highlight }): CSSProperties['fontWeight'] => (highlight ? 'bold' : 'normal')};
+  justify-content: space-between;
+  width: 100%;
+`;
+
+export const ClientName = styled.span`
+  flex: 1;
+  margin-right: ${rem(64)};
+`;

+ 92 - 0
gmus/src/components/ui/cmus/views/clients.tsx

@@ -0,0 +1,92 @@
+import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
+
+import { StateContext } from '../../../../context/state';
+import { Member } from '../../../../types';
+import { scrollThroughItems } from '../../../../utils/delta';
+import { clientActivated } from '../actions';
+import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
+import { H3 } from '../styled/typography';
+import { lineHeight } from '../utils/scroll';
+
+import * as Styled from './clients.styles';
+
+type ClientRowProps = {
+  name: string;
+  active: boolean;
+  isMe: boolean;
+  isMaster: boolean;
+  isPaused: boolean;
+};
+
+const ClientRow: React.FC<ClientRowProps> = ({ name, active, isMe, isMaster, isPaused }) => (
+  <Styled.Client highlight={isMaster} active={active} parentActive={true}>
+    <Styled.ClientName>{name}</Styled.ClientName>
+    {isMaster && (isPaused ? '🔈' : '🔊')}
+    {isMe ? '🏠' : '📶'}
+  </Styled.Client>
+);
+
+export const ViewClientList: React.FC = () => {
+  const {
+    clientList,
+    myClientName,
+    player: { master, playing },
+  } = useContext(StateContext);
+
+  const dispatchUI = useContext(CmusUIDispatchContext);
+  const {
+    scroll,
+    clientList: { active: activeClient },
+  } = useContext(CmusUIStateContext);
+
+  const ref = useRef<HTMLDivElement>(null);
+
+  const sortedClientList = useMemo<Member[]>(
+    () => clientList.slice().sort((a, b) => (a.name < b.name ? -1 : 1)),
+    [clientList],
+  );
+
+  const setActiveClient = useCallback((name: string) => dispatchUI(clientActivated(name)), [
+    dispatchUI,
+  ]);
+
+  const onScroll = useCallback(
+    (delta: -1 | 1): void => {
+      setActiveClient(
+        scrollThroughItems(sortedClientList, (compare) => compare.name === activeClient, delta)
+          .name,
+      );
+
+      if (ref.current) {
+        ref.current.scrollTop += delta * lineHeight;
+      }
+    },
+    [sortedClientList, activeClient, setActiveClient],
+  );
+
+  const lastScrollSerial = useRef<number>(0);
+  useEffect(() => {
+    if (scroll.delta !== 0 && scroll.serialNumber !== lastScrollSerial.current) {
+      lastScrollSerial.current = scroll.serialNumber;
+      onScroll(scroll.delta > 0 ? 1 : -1);
+    }
+  }, [scroll, onScroll]);
+
+  return (
+    <Styled.Container>
+      <H3>Client list</H3>
+      <Styled.List ref={ref}>
+        {sortedClientList.map(({ name }) => (
+          <ClientRow
+            key={name}
+            name={name}
+            active={name === activeClient}
+            isMe={name === myClientName}
+            isMaster={name === master}
+            isPaused={!playing}
+          />
+        ))}
+      </Styled.List>
+    </Styled.Container>
+  );
+};

+ 6 - 6
gmus/src/components/ui/cmus/views/help.styles.ts

@@ -2,16 +2,13 @@ import { rem } from 'polished';
 import styled from 'styled-components';
 
 import { FlexColumn } from '../styled/layout';
+import { H5 } from '../styled/typography';
 import { colors } from '../styled/variables';
 
 export const HelpDialogContainer = styled(FlexColumn)`
   background: ${colors.background};
   border: 1px solid ${colors.active.color};
   padding: ${rem(8)} ${rem(16)};
-
-  h3 {
-    font-weight: bold;
-  }
 `;
 
 export const Commands = styled(FlexColumn)`
@@ -25,8 +22,11 @@ export const Descriptions = styled(FlexColumn)`
   white-space: nowrap;
 `;
 
-export const CommandGroupTitle = styled.h5`
+export const CommandGroup = styled(FlexColumn)`
+  margin-bottom: ${rem(8)};
+`;
+
+export const CommandGroupTitle = styled(H5)`
   color: ${colors.selected.inactive};
   font-style: italic;
-  margin: ${rem(8)} 0;
 `;

+ 15 - 6
gmus/src/components/ui/cmus/views/help.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
-import { FlexColumn, FlexRow, NoWrap } from '../styled/layout';
+import { FlexRow, NoWrap } from '../styled/layout';
+import { H3 } from '../styled/typography';
 
 import * as Styled from './help.styles';
 
@@ -11,13 +12,20 @@ const commandsGeneral: Command[] = [
   { command: 'k', description: 'select previous list item' },
   { command: '<PageDown>', description: 'select next page of list items' },
   { command: '<PageUp>', description: 'select pervious page of list items' },
+  { command: '1', description: 'show library view' },
+  { command: '2', description: 'show client list' },
   { command: ':q', description: 'log out' },
   { command: '<Esc>', description: 'close this dialog' },
 ];
 
 const commandsLibrary: Command[] = [
-  { command: '<Tab>', description: 'switch between artists and albums' },
+  { command: '<Tab>', description: 'switch between artists/albums and songs' },
   { command: '<Space>', description: 'toggle albums for selected artist' },
+  { command: '<Enter>', description: 'play the selected song' },
+];
+
+const commandsClientList: Command[] = [
+  { command: '<Enter>', description: 'set the selected client to master' },
 ];
 
 type CommandGroup = {
@@ -26,15 +34,16 @@ type CommandGroup = {
 };
 
 const commandGroups: CommandGroup[] = [
-  { title: 'Library view', commands: commandsLibrary },
   { title: 'General', commands: commandsGeneral },
+  { title: 'Library view', commands: commandsLibrary },
+  { title: 'Client list', commands: commandsClientList },
 ];
 
 export const HelpDialog: React.FC = () => (
   <Styled.HelpDialogContainer>
-    <h3>Commands available</h3>
+    <H3>Available commands</H3>
     {commandGroups.map(({ title, commands }) => (
-      <FlexColumn key={title}>
+      <Styled.CommandGroup key={title}>
         <Styled.CommandGroupTitle>{title}</Styled.CommandGroupTitle>
         <FlexRow>
           <Styled.Commands>
@@ -48,7 +57,7 @@ export const HelpDialog: React.FC = () => (
             ))}
           </Styled.Commands>
         </FlexRow>
-      </FlexColumn>
+      </Styled.CommandGroup>
     ))}
   </Styled.HelpDialogContainer>
 );

+ 2 - 11
gmus/src/components/ui/cmus/views/library.tsx

@@ -1,9 +1,6 @@
-import React, { useContext, useEffect } from 'react';
+import React, { useContext } from 'react';
 
-import { useArtists } from '../../../../hooks/fetch/artists';
-import { artistsSet } from '../actions';
-
-import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
+import { CmusUIStateContext } from '../reducer';
 import { LibraryModeWindow } from '../types';
 
 import { Artists, Props as PropsArtists } from './artists';
@@ -12,14 +9,8 @@ import { Songs } from './songs';
 export type Props = Pick<PropsArtists, 'currentArtist'>;
 
 export const ViewLibrary: React.FC<Props> = ({ currentArtist }) => {
-  const dispatchUI = useContext(CmusUIDispatchContext);
   const { library } = useContext(CmusUIStateContext);
 
-  const { artists } = useArtists();
-  useEffect(() => {
-    dispatchUI(artistsSet(artists));
-  }, [dispatchUI, artists]);
-
   return (
     <>
       <Artists

+ 2 - 1
gmus/src/components/ui/cmus/views/songs.tsx

@@ -51,7 +51,8 @@ export const Songs: React.FC<Props> = ({ active: parentActive }) => {
     player: { songId: playingSongId },
   } = useContext(StateContext);
 
-  const activeArtistSongs = activeArtist ? artistSongs[activeArtist] ?? emptyArray : emptyArray;
+  const activeArtistSongs =
+    activeArtist === null ? emptyArray : artistSongs[activeArtist] ?? emptyArray;
 
   const filteredSongs = useMemo<Song[]>(
     () =>

+ 7 - 0
gmus/src/components/ui/cmus/views/status.styles.ts

@@ -21,5 +21,12 @@ export const PlayStatus = styled(FlexRow)`
   background: ${colors.selected.inactive};
   color: ${colors.background};
   flex: 1;
+  justify-content: space-between;
   width: 100%;
 `;
+
+export const Time = styled.div`
+  flex: 1;
+`;
+
+export const ClientName = styled.span``;

+ 10 - 4
gmus/src/components/ui/cmus/views/status.tsx

@@ -1,5 +1,6 @@
 import React, { useContext } from 'react';
 import { StateContext } from '../../../../context/state';
+import { isMaster } from '../../../../selectors';
 
 import { MusicPlayer, Song } from '../../../../types';
 import { formatTime } from '../../../../utils/time';
@@ -33,10 +34,15 @@ export const PlayerStatus: React.FC<Props> = ({ song }) => {
     <Styled.StatusContainer>
       <Styled.TrackMetadata>{getTrackMetadata(song)}</Styled.TrackMetadata>
       <Styled.PlayStatus>
-        <span>{getPlayPauseIcon(state.player)}</span>
-        <span>
-          {formatTime(state.player.currentTime)} / {formatTime(song?.time ?? null)}
-        </span>
+        <Styled.Time>
+          <span>{getPlayPauseIcon(state.player)}</span>
+          <span>
+            {formatTime(state.player.currentTime)} / {formatTime(song?.time ?? null)}
+          </span>
+        </Styled.Time>
+        <Styled.ClientName>
+          {state.myClientName} [{isMaster(state) ? 'Master' : 'Slave'}]
+        </Styled.ClientName>
       </Styled.PlayStatus>
     </Styled.StatusContainer>
   );

+ 12 - 0
gmus/src/components/ui/cmus/wrapper.styles.ts

@@ -1,4 +1,5 @@
 import { rem } from 'polished';
+import { CSSProperties } from 'react';
 import styled from 'styled-components';
 
 import { FlexColumn, FlexRow } from './styled/layout';
@@ -18,6 +19,17 @@ export const Wrapper = styled(FlexColumn)`
   user-select: none;
 `;
 
+export const ViewTitle = styled(FlexRow)`
+  background: ${colors.title.background};
+  color: ${colors.background};
+  font-weight: bold;
+`;
+
+export const ViewTitleItem = styled.span<{ active: boolean }>`
+  font-weight: ${({ active }): CSSProperties['fontWeight'] => (active ? 'bold' : 'normal')};
+  margin: 0 ${rem(16)} 0 ${rem(8)};
+`;
+
 export const View = styled(FlexRow)`
   flex: 1;
   overflow: hidden;

+ 27 - 13
gmus/src/components/ui/cmus/wrapper.tsx

@@ -1,10 +1,11 @@
-import React, { Reducer, useContext, useEffect, useReducer } from 'react';
+import React, { useContext, useEffect } from 'react';
+import { useReducer } from 'reinspect';
 
 import { DispatchContext } from '../../../context/state';
 import { useVimBindings } from '../../../hooks/vim';
+import { init } from '../../../utils/state';
 
 import { UIProviderComponent } from '../types';
-import { CmusUIAction } from './actions';
 
 import {
   CmusUIDispatchContext,
@@ -12,8 +13,9 @@ import {
   CmusUIStateContext,
   initialCmusUIState,
 } from './reducer';
-import { CmusUIState, Overlay, View } from './types';
-
+import { Overlay, View } from './types';
+import { useLibrary } from './utils/library';
+import { ViewClientList } from './views/clients';
 import { CommandView } from './views/command';
 import { HelpDialog } from './views/help';
 import { ViewLibrary } from './views/library';
@@ -21,12 +23,11 @@ import { PlayerStatus } from './views/status';
 
 import * as Styled from './wrapper.styles';
 
+const viewTitles = Object.values(View);
+
 export const CmusUIProvider: UIProviderComponent = ({ currentSong }) => {
   const dispatch = useContext(DispatchContext);
-  const [stateUI, dispatchUI] = useReducer<Reducer<CmusUIState, CmusUIAction>>(
-    cmusUIReducer,
-    initialCmusUIState,
-  );
+  const [stateUI, dispatchUI] = useReducer(cmusUIReducer, initialCmusUIState, init, 'ui');
 
   useEffect(() => {
     if (stateUI.globalAction) {
@@ -36,19 +37,32 @@ export const CmusUIProvider: UIProviderComponent = ({ currentSong }) => {
 
   useVimBindings(dispatchUI, stateUI.commandMode);
 
+  useLibrary(stateUI, dispatchUI);
+
+  const showOverlay = !!stateUI.overlay || stateUI.view === View.ClientList;
+
   return (
     <CmusUIStateContext.Provider value={stateUI}>
       <CmusUIDispatchContext.Provider value={dispatchUI}>
         <Styled.Wrapper>
+          <Styled.ViewTitle>
+            gmus -{' '}
+            {viewTitles.map((view, index) => (
+              <Styled.ViewTitleItem key={view} active={view === stateUI.view}>
+                ({index + 1}) {view}
+              </Styled.ViewTitleItem>
+            ))}
+          </Styled.ViewTitle>
           <Styled.View>
-            {stateUI.view === View.Library && (
+            {(stateUI.view === View.Library || stateUI.view === View.ClientList) && (
               <ViewLibrary currentArtist={currentSong?.artist ?? null} />
             )}
           </Styled.View>
-          {!!stateUI.overlay && (
-            <>
-              <Styled.Overlay>{stateUI.overlay === Overlay.Help && <HelpDialog />}</Styled.Overlay>
-            </>
+          {showOverlay && (
+            <Styled.Overlay>
+              {!stateUI.overlay && stateUI.view === View.ClientList && <ViewClientList />}
+              {stateUI.overlay === Overlay.Help && <HelpDialog />}
+            </Styled.Overlay>
           )}
           <PlayerStatus song={currentSong} />
           <CommandView />

+ 21 - 3
gmus/src/effects/effects.spec.ts

@@ -2,7 +2,7 @@ import {
   ActionStateSetRemote,
   ActionTypeLocal,
   ActionTypeRemote,
-  masterRetaken,
+  masterSet,
   playPaused,
   seeked,
   stateSet,
@@ -75,7 +75,7 @@ describe(globalEffects.name, () => {
     });
   });
 
-  describe(ActionTypeLocal.MasterRetaken, () => {
+  describe(ActionTypeLocal.MasterSet, () => {
     const stateMasterWentAway: GlobalState = {
       ...initialState,
       clientList: [{ name: 'my-client-name', lastPing: 0 }],
@@ -91,7 +91,7 @@ describe(globalEffects.name, () => {
 
     it('should return a StateSet action informing other clients that we are the new master', () => {
       expect.assertions(1);
-      const result = globalEffects(stateMasterWentAway, masterRetaken());
+      const result = globalEffects(stateMasterWentAway, masterSet());
 
       expect(result).toStrictEqual<ActionStateSetRemote>({
         type: ActionTypeRemote.StateSet,
@@ -104,6 +104,24 @@ describe(globalEffects.name, () => {
         },
       });
     });
+
+    describe('when the action specified a particular client', () => {
+      it('should return a StateSet action informing the new client to resume playback', () => {
+        expect.assertions(1);
+        const result = globalEffects(stateMasterWentAway, masterSet('other-client'));
+
+        expect(result).toStrictEqual<ActionStateSetRemote>({
+          type: ActionTypeRemote.StateSet,
+          payload: {
+            songId: 123,
+            playing: true,
+            currentTime: 83,
+            seekTime: 83,
+            master: 'other-client',
+          },
+        });
+      });
+    });
   });
 
   describe(ActionTypeLocal.PlayPaused, () => {

+ 12 - 1
gmus/src/effects/effects.ts

@@ -16,7 +16,18 @@ export function globalEffects(prevState: GlobalState, action: LocalAction): Remo
         payload: { ...prevState.player, seekTime: action.payload },
       };
 
-    case ActionTypeLocal.MasterRetaken:
+    case ActionTypeLocal.MasterSet:
+      if (action.payload) {
+        return {
+          type: ActionTypeRemote.StateSet,
+          payload: {
+            ...prevState.player,
+            seekTime: prevState.player.currentTime,
+            master: action.payload,
+          },
+        };
+      }
+
       return {
         type: ActionTypeRemote.StateSet,
         payload: {

+ 2 - 2
gmus/src/hooks/master.spec.tsx

@@ -1,7 +1,7 @@
 import { act, render } from '@testing-library/react';
 import React from 'react';
 
-import { masterRetaken, stateSet } from '../actions';
+import { masterSet, stateSet } from '../actions';
 import { masterStateUpdateTimeout } from '../constants/system';
 import { GlobalState, initialState, nullPlayer } from '../reducer';
 
@@ -96,7 +96,7 @@ describe(useMaster.name, () => {
         jest.runAllTimers();
       });
 
-      expect(dispatch).toHaveBeenCalledWith(masterRetaken());
+      expect(dispatch).toHaveBeenCalledWith(masterSet());
 
       unmount();
       jest.useRealTimers();

+ 2 - 2
gmus/src/hooks/master.ts

@@ -1,6 +1,6 @@
 import { Dispatch, useEffect, useRef } from 'react';
 
-import { LocalAction, masterRetaken, stateSet } from '../actions';
+import { LocalAction, masterSet, stateSet } from '../actions';
 import { masterStateUpdateTimeout } from '../constants/system';
 import { GlobalState } from '../reducer';
 import { isMaster } from '../selectors';
@@ -35,7 +35,7 @@ export function useMaster(state: GlobalState, dispatch: Dispatch<LocalAction>):
   useEffect(() => {
     if (masterWentAway) {
       retakeControlTimer.current = window.setTimeout(() => {
-        dispatch(masterRetaken());
+        dispatch(masterSet());
       }, Math.floor(Math.random() * 1000));
     }
 

+ 1 - 0
gmus/src/hooks/vim.ts

@@ -11,6 +11,7 @@ export const Keys = {
   pageDown: 'PageDown',
   pageUp: 'PageUp',
   '1': '1',
+  '2': '2',
   C: 'c',
   J: 'j',
   K: 'k',

+ 20 - 3
gmus/src/reducer/reducer.spec.ts

@@ -4,7 +4,7 @@ import {
   ActionStateSetRemote,
   ActionTypeLocal,
   ActionTypeRemote,
-  masterRetaken,
+  masterSet,
   nameSet,
   playPaused,
   seeked,
@@ -367,8 +367,8 @@ describe(globalReducer.name, () => {
     });
   });
 
-  describe(ActionTypeLocal.MasterRetaken, () => {
-    const action = masterRetaken();
+  describe(ActionTypeLocal.MasterSet, () => {
+    const action = masterSet();
 
     const stateBefore: GlobalState = {
       ...initialState,
@@ -399,6 +399,23 @@ describe(globalReducer.name, () => {
       const result = globalReducer(stateBefore, action);
       expect(result.player.playing).toBe(false);
     });
+
+    describe('when a particular client is given in the action', () => {
+      const actionToOtherClient = masterSet('other-client');
+
+      it('should set the master player to the given client', () => {
+        expect.assertions(2);
+        const result = globalReducer(stateBefore, actionToOtherClient);
+        expect(result.player.master).toBe('other-client');
+        expect(result.player.seekTime).toBe(stateBefore.player.currentTime);
+      });
+
+      it('should not pause the client', () => {
+        expect.assertions(1);
+        const result = globalReducer(stateBefore, actionToOtherClient);
+        expect(result.player.playing).toBe(true);
+      });
+    });
   });
 
   describe(ActionTypeLocal.PlayPaused, () => {

+ 12 - 1
gmus/src/reducer/reducer.ts

@@ -77,7 +77,18 @@ export function globalReducer(state: GlobalState, action: AnyAction): GlobalStat
       }
       return { ...state, player: { ...state.player, seekTime: action.payload } };
 
-    case ActionTypeLocal.MasterRetaken:
+    case ActionTypeLocal.MasterSet:
+      if (action.payload) {
+        return {
+          ...state,
+          player: {
+            ...state.player,
+            master: action.payload,
+            seekTime: state.player.currentTime,
+          },
+        };
+      }
+
       return {
         ...state,
         player: {

+ 20 - 0
gmus/yarn.lock

@@ -9672,6 +9672,14 @@ redent@^3.0.0:
     indent-string "^4.0.0"
     strip-indent "^3.0.0"
 
+redux@^4.0.5:
+  version "4.0.5"
+  resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f"
+  integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==
+  dependencies:
+    loose-envify "^1.4.0"
+    symbol-observable "^1.2.0"
+
 regenerate-unicode-properties@^8.2.0:
   version "8.2.0"
   resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec"
@@ -9751,6 +9759,13 @@ regjsparser@^0.6.4:
   dependencies:
     jsesc "~0.5.0"
 
+reinspect@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/reinspect/-/reinspect-1.1.0.tgz#e2cabeaf7d79a1e0295b483db1ebf88032aa4c1a"
+  integrity sha512-sYDAENbErxleBaDYuODWY8imD/ILvPCau8HV9XoGKWquYTmRMP9HRr32u9EqqjFu3no1MULYByRy6yABj61/+w==
+  dependencies:
+    redux "^4.0.5"
+
 relateurl@^0.2.7:
   version "0.2.7"
   resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
@@ -10895,6 +10910,11 @@ svgo@^1.0.0, svgo@^1.2.2:
     unquote "~1.1.1"
     util.promisify "~1.0.0"
 
+symbol-observable@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
+  integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
+
 symbol-tree@^3.2.4:
   version "3.2.4"
   resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"