Sfoglia il codice sorgente

feat: determine the result of keybindings in the UI reducer and move logic out of components

Fela Maslen 5 anni fa
parent
commit
c5cb7fe332

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

@@ -13,7 +13,7 @@ export enum ActionTypeLocal {
   MasterRetaken = '@@local/MASTER_RETAKEN',
 }
 
-interface Action<T extends string = string, P = unknown> {
+export interface Action<T extends string = string, P = unknown> {
   type: T;
   payload: P;
 }

+ 2 - 1
gmus/src/components/root/index.tsx

@@ -2,7 +2,8 @@ import React, { Reducer, useCallback, useReducer } from 'react';
 import { AnyAction, nameSet } from '../../actions';
 
 import { useDispatchWithEffects, useOnMessage, useSocket } from '../../hooks/socket';
-import { globalReducer, GlobalState, init, initialState } from '../../reducer';
+import { globalReducer, GlobalState, initialState } from '../../reducer';
+import { init } from '../../utils/state';
 import { App } from '../app';
 import { Identify } from '../identify';
 

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

@@ -0,0 +1,58 @@
+import { Action } from '../../../actions';
+import { ActionKeyPressed } from '../../../hooks/vim';
+import { Song } from '../../../types';
+import { LibraryModeWindow } from './types';
+
+export enum CmusUIActionType {
+  ArtistsSet = '@@ui/cmus/ARTISTS_SET',
+  ArtistAlbumsLoaded = '@@ui/cmus/ARTIST_ALBUMS_LOADED',
+  ArtistSongsLoaded = '@@ui/cmus/ARTIST_SONGS_LOADED',
+  LibraryModeSet = '@@ui/cmus/library/MODE_SET',
+}
+
+export type ArtistsSet = Action<CmusUIActionType.ArtistsSet, string[]>;
+
+export const artistsSet = (artists: string[]): ArtistsSet => ({
+  type: CmusUIActionType.ArtistsSet,
+  payload: artists,
+});
+
+export type ArtistAlbumsLoaded = Action<
+  CmusUIActionType.ArtistAlbumsLoaded,
+  {
+    artist: string;
+    albums: string[];
+  }
+>;
+
+export const artistAlbumsLoaded = (artist: string, albums: string[]): ArtistAlbumsLoaded => ({
+  type: CmusUIActionType.ArtistAlbumsLoaded,
+  payload: { artist, albums },
+});
+
+export type ArtistSongsLoaded = Action<
+  CmusUIActionType.ArtistSongsLoaded,
+  {
+    artist: string;
+    songs: Song[];
+  }
+>;
+
+export const artistSongsLoaded = (artist: string, songs: Song[]): ArtistSongsLoaded => ({
+  type: CmusUIActionType.ArtistSongsLoaded,
+  payload: { artist, songs },
+});
+
+export type LibraryModeSet = Action<CmusUIActionType.LibraryModeSet, LibraryModeWindow>;
+
+export const libraryModeSet = (mode: LibraryModeWindow): LibraryModeSet => ({
+  type: CmusUIActionType.LibraryModeSet,
+  payload: mode,
+});
+
+export type CmusUIAction =
+  | ArtistsSet
+  | ArtistAlbumsLoaded
+  | ArtistSongsLoaded
+  | LibraryModeSet
+  | ActionKeyPressed;

+ 33 - 78
gmus/src/components/ui/cmus/artists/index.tsx

@@ -1,11 +1,10 @@
 import { useDebounce } from '@react-hook/debounce';
-import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import React, { useContext, useEffect } from 'react';
 
 import { useArtistsAlbumsAndSongs } from '../../../../hooks/fetch/artists';
-import { BoundAction, Key, useVerticalScrollBindings } from '../../../../hooks/vim';
 import { Song } from '../../../../types/songs';
-import { scrollThroughItems } from '../../../../utils/delta';
-import { LibraryDispatchContext, LibraryStateContext } from '../context';
+import { artistAlbumsLoaded, artistSongsLoaded } from '../actions';
+import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
 
 import * as Styled from './styles';
 
@@ -26,98 +25,55 @@ type PropsArtist = {
 const Artist: React.FC<PropsArtist> = ({
   artist,
   albums,
-  songs,
   active,
   parentActive,
   expanded,
   activeAlbum,
-}) => {
-  const dispatch = useContext(LibraryDispatchContext);
-
-  useEffect(() => {
-    if (active) {
-      dispatch((last) => ({
-        ...last,
-        visibleSongs: songs ?? [],
-        activeSong: songs?.[0]?.id ?? null,
-      }));
-    }
-  }, [dispatch, songs, active]);
-
-  return (
-    <Styled.ArtistRow key={artist}>
-      <Styled.ArtistTitle active={active} parentActive={parentActive}>
-        {artist || 'Unknown Artist'}
-      </Styled.ArtistTitle>
-      {expanded && (
-        <Styled.ArtistAlbums>
-          {albums?.map((album) => (
-            <Styled.AlbumTitle key={album} active={active && album === activeAlbum}>
-              {album || 'Unknown Album'}
-            </Styled.AlbumTitle>
-          ))}
-        </Styled.ArtistAlbums>
-      )}
-    </Styled.ArtistRow>
-  );
-};
+}) => (
+  <Styled.ArtistRow key={artist}>
+    <Styled.ArtistTitle active={active} parentActive={parentActive}>
+      {artist || 'Unknown Artist'}
+    </Styled.ArtistTitle>
+    {expanded && (
+      <Styled.ArtistAlbums>
+        {albums?.map((album) => (
+          <Styled.AlbumTitle key={album} active={active && album === activeAlbum}>
+            {album || 'Unknown Album'}
+          </Styled.AlbumTitle>
+        ))}
+      </Styled.ArtistAlbums>
+    )}
+  </Styled.ArtistRow>
+);
 
 export const Artists: React.FC<Props> = ({ active }) => {
-  const dispatch = useContext(LibraryDispatchContext);
-  const { artists, activeArtist, activeAlbum } = useContext(LibraryStateContext);
-
-  const [expandedArtists, setExpandedArtists] = useState<Record<string, boolean>>({});
-
-  const toggleActiveArtistExpanded = useCallback(
-    () =>
-      setExpandedArtists((last) =>
-        activeArtist ? { ...last, [activeArtist]: !last[activeArtist] } : last,
-      ),
-    [activeArtist],
-  );
+  const dispatchUI = useContext(CmusUIDispatchContext);
+  const {
+    artists,
+    artistAlbums,
+    library: { expandedArtists, activeArtist, activeAlbum },
+  } = useContext(CmusUIStateContext);
 
   const [debouncedActiveArtist, setDebouncedActiveArtist] = useDebounce(activeArtist, 100);
   useEffect(() => {
     setDebouncedActiveArtist(activeArtist);
   }, [activeArtist, setDebouncedActiveArtist]);
 
-  const [albumsCache, setAlbumsCache] = useState<Record<string, string[]>>({});
-  const [songsCache, setSongsCache] = useState<Record<string, Song[]>>({});
-
   const { albums, songs } = useArtistsAlbumsAndSongs(
     debouncedActiveArtist ?? '',
-    !(debouncedActiveArtist && expandedArtists[debouncedActiveArtist]),
+    !(debouncedActiveArtist && expandedArtists.includes(debouncedActiveArtist)),
     !debouncedActiveArtist,
   );
   useEffect(() => {
     if (albums) {
-      setAlbumsCache((last) => ({ ...last, [albums.artist]: albums.albums }));
+      dispatchUI(artistAlbumsLoaded(albums.artist, albums.albums));
     }
+  }, [dispatchUI, albums]);
+  useEffect(() => {
     if (songs) {
-      setSongsCache((last) => ({ ...last, [songs.artist]: songs.songs }));
+      dispatchUI(artistSongsLoaded(songs.artist, songs.songs));
     }
-  }, [albums, songs]);
-
-  const scrollDelta = useCallback(
-    (delta: number) =>
-      dispatch((last) => ({
-        ...last,
-        activeSong: null,
-        activeArtist: scrollThroughItems(
-          last.artists,
-          (compare) => compare === last.activeArtist,
-          delta,
-        ),
-      })),
-    [dispatch],
-  );
-
-  const boundActions = useMemo<BoundAction[]>(
-    () => [{ binding: Key.Toggle, action: toggleActiveArtistExpanded }],
-    [toggleActiveArtistExpanded],
-  );
-
-  useVerticalScrollBindings(active, scrollDelta, boundActions);
+  }, [dispatchUI, songs]);
 
   return (
     <Styled.Container>
@@ -125,11 +81,10 @@ export const Artists: React.FC<Props> = ({ active }) => {
         <Artist
           key={artist}
           artist={artist}
-          albums={albumsCache[artist]}
-          songs={songsCache[artist]}
+          albums={artistAlbums[artist]}
           active={artist === activeArtist}
           parentActive={active}
-          expanded={!!expandedArtists[artist]}
+          expanded={expandedArtists.includes(artist)}
           activeAlbum={activeAlbum}
         />
       ))}

+ 1 - 1
gmus/src/components/ui/cmus/artists/styles.ts

@@ -1,7 +1,7 @@
 import { rem } from 'polished';
 import styled from 'styled-components';
 
-import { ActiveHighlightRow, FlexColumn } from '../shared/layout';
+import { ActiveHighlightRow, FlexColumn } from '../styled/layout';
 
 export const Container = styled(FlexColumn)`
   flex: 1;

+ 0 - 18
gmus/src/components/ui/cmus/context.ts

@@ -1,18 +0,0 @@
-import { createContext, Dispatch, SetStateAction } from 'react';
-
-import { nullDispatch } from '../../../context/state';
-import { LibraryState, ModeWindow } from './types';
-
-export const initialLibraryState: LibraryState = {
-  artists: [],
-  activeArtist: null,
-  activeAlbum: null,
-  activeSong: null,
-  modeWindow: ModeWindow.ArtistList,
-  visibleSongs: [],
-};
-
-export const LibraryStateContext = createContext<LibraryState>(initialLibraryState);
-export const LibraryDispatchContext = createContext<Dispatch<SetStateAction<LibraryState>>>(
-  nullDispatch,
-);

+ 31 - 28
gmus/src/components/ui/cmus/index.tsx

@@ -1,42 +1,45 @@
-import React, { useCallback, useMemo, useState } from 'react';
-import {
-  BoundAction,
-  Key,
-  KeyPressedContext,
-  useMultiKeyBinding,
-  useVimBindings,
-} from '../../../hooks/vim';
+import React, { Reducer, useContext, useEffect, useReducer } from 'react';
+
+import { DispatchContext } from '../../../context/state';
+import { useVimBindings } from '../../../hooks/vim';
 
 import { UIProviderComponent } from '../types';
+import { CmusUIAction } from './actions';
+import {
+  CmusUIDispatchContext,
+  cmusUIReducer,
+  CmusUIStateContext,
+  initialCmusUIState,
+} from './reducer';
 import { PlayerStatus } from './status';
 
 import * as Styled from './styles';
+import { CmusUIState, View } from './types';
 import { ViewLibrary } from './views/library';
 
-enum View {
-  Library,
-}
-
 export const CmusUIProvider: UIProviderComponent = () => {
-  const lastKeyPress = useVimBindings();
-
-  const [view, setView] = useState<View>(View.Library);
-
-  const setLibrary = useCallback(() => setView(View.Library), []);
-
-  const boundActions = useMemo<BoundAction[]>(
-    () => [{ binding: Key.ModeLibrary, action: setLibrary }],
-    [setLibrary],
+  const dispatch = useContext(DispatchContext);
+  const [stateUI, dispatchUI] = useReducer<Reducer<CmusUIState, CmusUIAction>>(
+    cmusUIReducer,
+    initialCmusUIState,
   );
 
-  useMultiKeyBinding(true, boundActions);
+  useEffect(() => {
+    if (stateUI.globalAction) {
+      dispatch(stateUI.globalAction);
+    }
+  }, [dispatch, stateUI.globalAction, stateUI.globalActionSerialNumber]);
+
+  useVimBindings(dispatchUI);
 
   return (
-    <KeyPressedContext.Provider value={lastKeyPress}>
-      <Styled.Wrapper>
-        <Styled.View>{view === View.Library && <ViewLibrary />}</Styled.View>
-        <PlayerStatus />
-      </Styled.Wrapper>
-    </KeyPressedContext.Provider>
+    <CmusUIStateContext.Provider value={stateUI}>
+      <CmusUIDispatchContext.Provider value={dispatchUI}>
+        <Styled.Wrapper>
+          <Styled.View>{stateUI.view === View.Library && <ViewLibrary />}</Styled.View>
+          <PlayerStatus />
+        </Styled.Wrapper>
+      </CmusUIDispatchContext.Provider>
+    </CmusUIStateContext.Provider>
   );
 };

+ 374 - 0
gmus/src/components/ui/cmus/reducer.spec.ts

@@ -0,0 +1,374 @@
+import { stateSet } from '../../../actions';
+import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../hooks/vim';
+import { Song } from '../../../types';
+import {
+  artistAlbumsLoaded,
+  artistSongsLoaded,
+  artistsSet,
+  CmusUIActionType,
+  libraryModeSet,
+} from './actions';
+import { cmusUIReducer, initialCmusUIState } from './reducer';
+import { CmusUIState, LibraryModeWindow, View } from './types';
+
+describe(cmusUIReducer.name, () => {
+  const stateLibrary: CmusUIState = {
+    ...initialCmusUIState,
+    view: View.Library,
+  };
+
+  describe(CmusUIActionType.ArtistsSet, () => {
+    const action = artistsSet(['Artist A', 'Artist B']);
+
+    it('should set the artists array', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.artists).toStrictEqual(['Artist A', 'Artist B']);
+    });
+
+    it('should set the active artist to the first artist in the list', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.library.activeArtist).toBe('Artist A');
+    });
+  });
+
+  describe(CmusUIActionType.ArtistAlbumsLoaded, () => {
+    const action = artistAlbumsLoaded('My artist', ['Album A', 'Album B']);
+
+    it('should set the albums for the given artist', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.artistAlbums).toStrictEqual(
+        expect.objectContaining({
+          'My artist': ['Album A', 'Album B'],
+        }),
+      );
+    });
+  });
+
+  describe(CmusUIActionType.ArtistSongsLoaded, () => {
+    const action = artistSongsLoaded('My artist', [
+      { id: 12, track: 23, title: 'Title A', artist: 'My artist', album: 'Album A', time: 123 },
+      { id: 73, track: 17, title: 'Title B', artist: 'My artist', album: 'Album B', time: 456 },
+    ]);
+
+    it('should set the songs for the given artist', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.artistSongs).toStrictEqual(
+        expect.objectContaining({
+          'My artist': [
+            {
+              id: 12,
+              track: 23,
+              title: 'Title A',
+              artist: 'My artist',
+              album: 'Album A',
+              time: 123,
+            },
+            {
+              id: 73,
+              track: 17,
+              title: 'Title B',
+              artist: 'My artist',
+              album: 'Album B',
+              time: 456,
+            },
+          ],
+        }),
+      );
+    });
+
+    describe('when the artist is the active artist', () => {
+      const stateWithActiveArtist: CmusUIState = {
+        ...initialCmusUIState,
+        library: {
+          ...initialCmusUIState.library,
+          activeArtist: 'My artist',
+        },
+      };
+
+      it('should set the active song ID to the first song ID in the list', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(stateWithActiveArtist, action);
+        expect(result.library.activeSongId).toBe(12);
+      });
+    });
+  });
+
+  describe(CmusUIActionType.LibraryModeSet, () => {
+    const action = libraryModeSet(LibraryModeWindow.SongList);
+
+    it('should set the library mode window', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.library.modeWindow).toBe(LibraryModeWindow.SongList);
+    });
+  });
+
+  describe('Keypress actions', () => {
+    describe(Keys['1'], () => {
+      it('should set the view to Library', () => {
+        expect.assertions(1);
+        const state = ({ ...initialCmusUIState, view: undefined } as unknown) as CmusUIState;
+        const result = cmusUIReducer(state, { type: ActionTypeKeyPressed, key: Keys['1'] });
+
+        expect(result.view).toBe(View.Library);
+      });
+    });
+
+    describe(Keys.tab, () => {
+      describe('when in library view', () => {
+        describe.each`
+          fromModeWindow                  | toModeWindow
+          ${LibraryModeWindow.ArtistList} | ${LibraryModeWindow.SongList}
+          ${LibraryModeWindow.SongList}   | ${LibraryModeWindow.ArtistList}
+        `('when the mode window is $fromModeWindow', ({ fromModeWindow, toModeWindow }) => {
+          const stateFromMode: CmusUIState = {
+            ...stateLibrary,
+            library: {
+              ...stateLibrary.library,
+              modeWindow: fromModeWindow,
+            },
+          };
+
+          it(`should set the mode window to ${toModeWindow}`, () => {
+            expect.assertions(1);
+            const result = cmusUIReducer(stateFromMode, {
+              type: ActionTypeKeyPressed,
+              key: Keys.tab,
+            });
+
+            expect(result.library.modeWindow).toBe(toModeWindow);
+          });
+        });
+      });
+    });
+
+    describe(Keys.J, () => {
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.J };
+
+      describe('when in library view', () => {
+        describe('when in the artist list mode', () => {
+          const stateArtistMode: CmusUIState = {
+            ...stateLibrary,
+            artists: ['Artist A', 'Artist B'],
+            library: {
+              ...stateLibrary.library,
+              activeArtist: 'Artist A',
+              modeWindow: LibraryModeWindow.ArtistList,
+            },
+          };
+
+          it('should set the active artist to the next available artist', () => {
+            expect.assertions(1);
+            const result = cmusUIReducer(stateArtistMode, action);
+
+            expect(result.library.activeArtist).toBe('Artist B');
+          });
+
+          it('should set the active song ID to the first by the artist', () => {
+            expect.assertions(1);
+            const state: CmusUIState = {
+              ...stateArtistMode,
+              artistSongs: {
+                'Artist B': [{ id: 123 } as Song, { id: 456 } as Song],
+              },
+            };
+            const result = cmusUIReducer(state, action);
+
+            expect(result.library.activeSongId).toBe(123);
+          });
+
+          describe('when there are no songs loaded for the artist', () => {
+            it('should set the active song ID to null', () => {
+              expect.assertions(1);
+              const state: CmusUIState = {
+                ...stateArtistMode,
+                artistSongs: {},
+              };
+              const result = cmusUIReducer(state, action);
+
+              expect(result.library.activeSongId).toBeNull();
+            });
+          });
+        });
+
+        describe('when in the song list mode', () => {
+          const stateSongsMode: CmusUIState = {
+            ...stateLibrary,
+            artists: ['Artist A'],
+            artistSongs: {
+              'Artist A': [{ id: 123 } as Song, { id: 456 } as Song, { id: 789 } as Song],
+            },
+            library: {
+              ...stateLibrary.library,
+              activeArtist: 'Artist A',
+              activeSongId: 123,
+              modeWindow: LibraryModeWindow.SongList,
+            },
+          };
+
+          it('should set the active song ID to the next available song', () => {
+            expect.assertions(1);
+            const result = cmusUIReducer(stateSongsMode, action);
+
+            expect(result.library.activeSongId).toBe(456);
+          });
+        });
+      });
+    });
+
+    describe(Keys.K, () => {
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.K };
+
+      describe('when in library view', () => {
+        describe('when in the artist list mode', () => {
+          const stateArtistMode: CmusUIState = {
+            ...stateLibrary,
+            artists: ['Artist A', 'Artist B'],
+            library: {
+              ...stateLibrary.library,
+              activeArtist: 'Artist B',
+              modeWindow: LibraryModeWindow.ArtistList,
+            },
+          };
+
+          it('should set the active artist to the previous available artist', () => {
+            expect.assertions(1);
+            const result = cmusUIReducer(stateArtistMode, action);
+
+            expect(result.library.activeArtist).toBe('Artist A');
+          });
+
+          it('should set the active song ID to the first by the artist', () => {
+            expect.assertions(1);
+            const state: CmusUIState = {
+              ...stateArtistMode,
+              artistSongs: {
+                'Artist A': [{ id: 123 } as Song, { id: 456 } as Song],
+              },
+            };
+            const result = cmusUIReducer(state, action);
+
+            expect(result.library.activeSongId).toBe(123);
+          });
+
+          describe('when there are no songs loaded for the artist', () => {
+            it('should set the active song ID to null', () => {
+              expect.assertions(1);
+              const state: CmusUIState = {
+                ...stateArtistMode,
+                artistSongs: {},
+              };
+              const result = cmusUIReducer(state, action);
+
+              expect(result.library.activeSongId).toBeNull();
+            });
+          });
+        });
+
+        describe('when in the song list mode', () => {
+          const stateSongsMode: CmusUIState = {
+            ...stateLibrary,
+            artists: ['Artist A'],
+            artistSongs: {
+              'Artist A': [{ id: 123 } as Song, { id: 456 } as Song, { id: 789 } as Song],
+            },
+            library: {
+              ...stateLibrary.library,
+              activeArtist: 'Artist A',
+              activeSongId: 456,
+              modeWindow: LibraryModeWindow.SongList,
+            },
+          };
+
+          it('should set the active song ID to the previous available song', () => {
+            expect.assertions(1);
+            const result = cmusUIReducer(stateSongsMode, action);
+
+            expect(result.library.activeSongId).toBe(123);
+          });
+        });
+      });
+    });
+
+    describe(Keys.space, () => {
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.space };
+
+      describe('when in library view', () => {
+        describe('when in the artist list mode', () => {
+          it('should toggle the active artist', () => {
+            expect.assertions(2);
+
+            const state: CmusUIState = {
+              ...initialCmusUIState,
+              library: {
+                ...initialCmusUIState.library,
+                expandedArtists: [],
+                activeArtist: 'Some artist',
+              },
+            };
+
+            const firstResult = cmusUIReducer(state, action);
+            expect(firstResult.library.expandedArtists).toStrictEqual(['Some artist']);
+
+            const secondResult = cmusUIReducer(firstResult, action);
+            expect(secondResult.library.expandedArtists).toStrictEqual([]);
+          });
+
+          describe('when there is no active artist', () => {
+            it('should return the state', () => {
+              expect.assertions(1);
+              const stateNoActive: CmusUIState = {
+                ...initialCmusUIState,
+                library: {
+                  ...initialCmusUIState.library,
+                  activeArtist: null,
+                },
+              };
+
+              const result = cmusUIReducer(stateNoActive, action);
+              expect(result).toStrictEqual(stateNoActive);
+            });
+          });
+        });
+      });
+    });
+
+    describe(Keys.enter, () => {
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.enter };
+
+      describe('when in library view', () => {
+        describe('when in the songs list mode', () => {
+          const state: CmusUIState = {
+            ...initialCmusUIState,
+            globalActionSerialNumber: 1875,
+            library: {
+              ...initialCmusUIState.library,
+              modeWindow: LibraryModeWindow.SongList,
+              activeSongId: 713,
+            },
+          };
+
+          it('should set the globalAction to play the active song and increment the serial', () => {
+            expect.assertions(2);
+
+            const result = cmusUIReducer(state, action);
+
+            expect(result.globalAction).toStrictEqual(
+              stateSet({
+                playing: true,
+                songId: 713,
+                currentTime: 0,
+                seekTime: 0,
+              }),
+            );
+            expect(result.globalActionSerialNumber).toBe(1876);
+          });
+        });
+      });
+    });
+  });
+});

+ 219 - 0
gmus/src/components/ui/cmus/reducer.ts

@@ -0,0 +1,219 @@
+import { createContext, Dispatch } from 'react';
+
+import { stateSet } from '../../../actions';
+import { nullDispatch } from '../../../context/state';
+import { ActionTypeKeyPressed, Keys } from '../../../hooks/vim';
+import { scrollThroughItems } from '../../../utils/delta';
+import {
+  ArtistAlbumsLoaded,
+  ArtistSongsLoaded,
+  ArtistsSet,
+  CmusUIAction,
+  CmusUIActionType,
+} from './actions';
+import { CmusUIState, LibraryModeWindow, View } from './types';
+
+export const initialCmusUIState: CmusUIState = {
+  globalAction: null,
+  globalActionSerialNumber: 0,
+  view: View.Library,
+  artists: [],
+  artistAlbums: {},
+  artistSongs: {},
+  library: {
+    modeWindow: LibraryModeWindow.ArtistList,
+    expandedArtists: [],
+    activeArtist: null,
+    activeAlbum: null,
+    activeSongId: null,
+    visibleSongs: [],
+  },
+};
+
+export const CmusUIStateContext = createContext<CmusUIState>(initialCmusUIState);
+export const CmusUIDispatchContext = createContext<Dispatch<CmusUIAction>>(nullDispatch);
+
+const libraryModeWindows: LibraryModeWindow[] = Object.values(LibraryModeWindow);
+
+const switchLibraryMode = (state: CmusUIState): CmusUIState => ({
+  ...state,
+  library: {
+    ...state.library,
+    modeWindow:
+      libraryModeWindows[
+        (libraryModeWindows.indexOf(state.library.modeWindow) + 1) % libraryModeWindows.length
+      ],
+  },
+});
+
+const setActiveSongIdFromActiveArtist = (state: CmusUIState): CmusUIState => ({
+  ...state,
+  library: {
+    ...state.library,
+    activeSongId: state.library.activeArtist
+      ? state.artistSongs[state.library.activeArtist]?.[0]?.id ?? null
+      : null,
+  },
+});
+
+const scrollArtists = (state: CmusUIState, delta: number): CmusUIState =>
+  setActiveSongIdFromActiveArtist({
+    ...state,
+    library: {
+      ...state.library,
+      activeArtist: scrollThroughItems(
+        state.artists,
+        (compare) => compare === state.library.activeArtist,
+        delta,
+      ),
+    },
+  });
+
+const scrollSongs = (state: CmusUIState, delta: number): CmusUIState =>
+  state.library.activeArtist
+    ? {
+        ...state,
+        library: {
+          ...state.library,
+          activeSongId: scrollThroughItems(
+            state.artistSongs[state.library.activeArtist] ?? [],
+            (compare) => compare.id === state.library.activeSongId,
+            delta,
+          ).id,
+        },
+      }
+    : state;
+
+function toggleExpandArtist(library: CmusUIState['library']): CmusUIState['library'] {
+  if (!library.activeArtist) {
+    return library;
+  }
+  if (library.expandedArtists.includes(library.activeArtist)) {
+    return {
+      ...library,
+      expandedArtists: library.expandedArtists.filter(
+        (compare) => compare !== library.activeArtist,
+      ),
+    };
+  }
+  return { ...library, expandedArtists: [...library.expandedArtists, library.activeArtist] };
+}
+
+function handleScrollDown(state: CmusUIState): CmusUIState {
+  if (state.view === View.Library) {
+    if (state.library.modeWindow === LibraryModeWindow.ArtistList) {
+      return scrollArtists(state, 1);
+    }
+    if (state.library.modeWindow === LibraryModeWindow.SongList) {
+      return scrollSongs(state, 1);
+    }
+  }
+
+  return state;
+}
+
+function handleScrollUp(state: CmusUIState): CmusUIState {
+  if (state.view === View.Library) {
+    if (state.library.modeWindow === LibraryModeWindow.ArtistList) {
+      return scrollArtists(state, -1);
+    }
+    if (state.library.modeWindow === LibraryModeWindow.SongList) {
+      return scrollSongs(state, -1);
+    }
+  }
+
+  return state;
+}
+
+function handleKeyPress(state: CmusUIState, key: string): CmusUIState {
+  switch (key) {
+    case Keys['1']:
+      return { ...state, view: View.Library };
+    case Keys.tab:
+      if (state.view === View.Library) {
+        return switchLibraryMode(state);
+      }
+      return state;
+
+    case Keys.space:
+      if (state.view === View.Library) {
+        if (state.library.modeWindow === LibraryModeWindow.ArtistList) {
+          return { ...state, library: toggleExpandArtist(state.library) };
+        }
+      }
+      return state;
+
+    case Keys.enter:
+      if (state.view === View.Library) {
+        if (state.library.modeWindow === LibraryModeWindow.SongList) {
+          if (!state.library.activeSongId) {
+            return state;
+          }
+
+          return {
+            ...state,
+            globalAction: stateSet({
+              playing: true,
+              songId: state.library.activeSongId,
+              currentTime: 0,
+              seekTime: 0,
+            }),
+            globalActionSerialNumber: state.globalActionSerialNumber + 1,
+          };
+        }
+      }
+
+      return state;
+
+    case Keys.J:
+      return handleScrollDown(state);
+    case Keys.K:
+      return handleScrollUp(state);
+
+    default:
+      return state;
+  }
+}
+
+const setArtists = (state: CmusUIState, action: ArtistsSet): CmusUIState => ({
+  ...state,
+  artists: action.payload,
+  library: { ...state.library, activeArtist: action.payload[0] ?? null },
+});
+
+const setArtistAlbums = (state: CmusUIState, action: ArtistAlbumsLoaded): CmusUIState => ({
+  ...state,
+  artistAlbums: { ...state.artistAlbums, [action.payload.artist]: action.payload.albums },
+});
+
+const setArtistSongs = (state: CmusUIState, action: ArtistSongsLoaded): CmusUIState => ({
+  ...state,
+  artistSongs: { ...state.artistSongs, [action.payload.artist]: action.payload.songs },
+  library: {
+    ...state.library,
+    activeSongId:
+      state.library.activeArtist === action.payload.artist
+        ? action.payload.songs[0]?.id ?? null
+        : state.library.activeSongId,
+  },
+});
+
+export function cmusUIReducer(state: CmusUIState, action: CmusUIAction): CmusUIState {
+  switch (action.type) {
+    case ActionTypeKeyPressed:
+      return handleKeyPress(state, action.key);
+
+    case CmusUIActionType.ArtistsSet:
+      return setArtists(state, action);
+    case CmusUIActionType.ArtistAlbumsLoaded:
+      return setArtistAlbums(state, action);
+    case CmusUIActionType.ArtistSongsLoaded:
+      return setArtistSongs(state, action);
+
+    case CmusUIActionType.LibraryModeSet:
+      return { ...state, library: { ...state.library, modeWindow: action.payload } };
+
+    default:
+      return state;
+  }
+}

+ 19 - 37
gmus/src/components/ui/cmus/songs/index.tsx

@@ -1,10 +1,7 @@
-import React, { useCallback, useContext, useMemo } from 'react';
-import { stateSet } from '../../../../actions';
-import { DispatchContext } from '../../../../context/state';
-import { BoundAction, Key, useVerticalScrollBindings } from '../../../../hooks/vim';
-import { scrollThroughItems } from '../../../../utils/delta';
+import React, { useContext, useMemo } from 'react';
 
-import { LibraryDispatchContext, LibraryStateContext } from '../context';
+import { Song } from '../../../../types';
+import { CmusUIStateContext } from '../reducer';
 
 import * as Styled from './styles';
 
@@ -12,43 +9,28 @@ type Props = {
   active: boolean;
 };
 
-export const Songs: React.FC<Props> = ({ active }) => {
-  const dispatchGlobal = useContext(DispatchContext);
-
-  const dispatchLibrary = useContext(LibraryDispatchContext);
-  const { visibleSongs: songs, activeSong } = useContext(LibraryStateContext);
-
-  const playActiveSong = useCallback(() => {
-    dispatchGlobal(
-      stateSet({
-        playing: true,
-        songId: activeSong,
-      }),
-    );
-  }, [dispatchGlobal, activeSong]);
-
-  const scrollDelta = useCallback(
-    (delta: number) => {
-      dispatchLibrary((last) => ({
-        ...last,
-        activeSong: scrollThroughItems(songs, (compare) => compare.id === last.activeSong, delta)
-          .id,
-      }));
-    },
-    [dispatchLibrary, songs],
-  );
+const emptyArray: Song[] = [];
 
-  const boundActions = useMemo<BoundAction[]>(
-    () => [{ binding: Key.Activate, action: playActiveSong }],
-    [playActiveSong],
+export const Songs: React.FC<Props> = ({ active }) => {
+  const {
+    artistSongs,
+    library: { activeArtist, activeAlbum, activeSongId },
+  } = useContext(CmusUIStateContext);
+
+  const activeArtistSongs = activeArtist ? artistSongs[activeArtist] ?? emptyArray : emptyArray;
+
+  const songs = useMemo<Song[]>(
+    () =>
+      activeAlbum
+        ? activeArtistSongs.filter(({ album }) => album === activeAlbum)
+        : activeArtistSongs,
+    [activeArtistSongs, activeAlbum],
   );
 
-  useVerticalScrollBindings(active, scrollDelta, boundActions);
-
   return (
     <Styled.Container>
       {songs.map((song) => (
-        <Styled.Song key={song.id} active={song.id === activeSong} parentActive={active}>
+        <Styled.Song key={song.id} active={song.id === activeSongId} parentActive={active}>
           {song.track} - {song.title || 'Untitled Track'}
         </Styled.Song>
       ))}

+ 2 - 2
gmus/src/components/ui/cmus/songs/styles.ts

@@ -1,7 +1,7 @@
 import styled from 'styled-components';
 
-import { ActiveHighlightRow, FlexRow } from '../shared/layout';
-import { colors } from '../variables';
+import { ActiveHighlightRow, FlexRow } from '../styled/layout';
+import { colors } from '../styled/variables';
 
 export const Container = styled(FlexRow)`
   border-left: 1px solid ${colors.border};

+ 2 - 2
gmus/src/components/ui/cmus/status/styles.ts

@@ -1,8 +1,8 @@
 import { rem } from 'polished';
 import styled from 'styled-components';
 
-import { FlexColumn, FlexRow } from '../shared/layout';
-import { colors } from '../variables';
+import { FlexColumn, FlexRow } from '../styled/layout';
+import { colors } from '../styled/variables';
 
 export const StatusContainer = styled(FlexColumn)`
   flex: 0 0 ${rem(32)};

+ 1 - 1
gmus/src/components/ui/cmus/shared/layout.ts → gmus/src/components/ui/cmus/styled/layout.ts

@@ -1,7 +1,7 @@
 import { CSSProperties } from 'react';
 import styled from 'styled-components';
 
-import { colors } from '../variables';
+import { colors } from './variables';
 
 export const FlexRow = styled.div`
   display: flex;

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


+ 2 - 2
gmus/src/components/ui/cmus/styles.ts

@@ -1,8 +1,8 @@
 import { rem } from 'polished';
 import styled from 'styled-components';
 
-import { FlexColumn, FlexRow } from './shared/layout';
-import { colors } from './variables';
+import { FlexColumn, FlexRow } from './styled/layout';
+import { colors } from './styled/variables';
 
 export const Wrapper = styled(FlexColumn)`
   background: ${colors.background};

+ 20 - 7
gmus/src/components/ui/cmus/types.ts

@@ -1,15 +1,28 @@
+import { LocalAction } from '../../../actions';
 import { Song } from '../../../types/songs';
 
-export enum ModeWindow {
+export enum View {
+  Library,
+}
+
+export enum LibraryModeWindow {
   ArtistList = 'ArtistList',
   SongList = 'SongList',
 }
 
-export type LibraryState = {
+export type CmusUIState = {
+  globalAction: LocalAction | null;
+  globalActionSerialNumber: number;
+  view: View;
   artists: string[];
-  activeArtist: string | null;
-  activeAlbum: string | null;
-  activeSong: number | null;
-  modeWindow: ModeWindow;
-  visibleSongs: Song[];
+  artistAlbums: Record<string, string[]>;
+  artistSongs: Record<string, Song[]>;
+  library: {
+    expandedArtists: string[];
+    activeArtist: string | null;
+    activeAlbum: string | null;
+    activeSongId: number | null;
+    modeWindow: LibraryModeWindow;
+    visibleSongs: Song[];
+  };
 };

+ 12 - 24
gmus/src/components/ui/cmus/views/library.tsx

@@ -1,38 +1,26 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useContext, useEffect } from 'react';
 
 import { useArtists } from '../../../../hooks/fetch/artists';
-import { Key, useKeyBinding } from '../../../../hooks/vim';
+import { artistsSet } from '../actions';
 
 import { Artists } from '../artists';
-import { initialLibraryState, LibraryDispatchContext, LibraryStateContext } from '../context';
+import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
 import { Songs } from '../songs';
-import { LibraryState, ModeWindow } from '../types';
-
-const modeWindows: ModeWindow[] = Object.values(ModeWindow);
+import { LibraryModeWindow } from '../types';
 
 export const ViewLibrary: React.FC = () => {
-  const [state, dispatch] = useState<LibraryState>(initialLibraryState);
-
-  const switchModeWindow = useCallback(() => {
-    dispatch((last) => ({
-      ...last,
-      modeWindow: modeWindows[(modeWindows.indexOf(last.modeWindow) + 1) % modeWindows.length],
-    }));
-  }, []);
-
-  useKeyBinding(true, Key.Switch, switchModeWindow);
+  const dispatchUI = useContext(CmusUIDispatchContext);
+  const { library } = useContext(CmusUIStateContext);
 
   const { artists } = useArtists();
   useEffect(() => {
-    dispatch((last) => ({ ...last, artists, activeArtist: artists[0] }));
-  }, [artists]);
+    dispatchUI(artistsSet(artists));
+  }, [dispatchUI, artists]);
 
   return (
-    <LibraryStateContext.Provider value={state}>
-      <LibraryDispatchContext.Provider value={dispatch}>
-        <Artists active={state.modeWindow === ModeWindow.ArtistList} />
-        <Songs active={state.modeWindow === ModeWindow.SongList} />
-      </LibraryDispatchContext.Provider>
-    </LibraryStateContext.Provider>
+    <>
+      <Artists active={library.modeWindow === LibraryModeWindow.ArtistList} />
+      <Songs active={library.modeWindow === LibraryModeWindow.SongList} />
+    </>
   );
 };

+ 47 - 0
gmus/src/hooks/vim.spec.tsx

@@ -0,0 +1,47 @@
+import { act, fireEvent, render } from '@testing-library/react';
+import React, { Dispatch } from 'react';
+
+import { ActionKeyPressed, ActionTypeKeyPressed, useVimBindings } from './vim';
+
+describe(useVimBindings.name, () => {
+  const TestComponent: React.FC<{ dispatch: Dispatch<ActionKeyPressed> }> = ({ dispatch }) => {
+    useVimBindings(dispatch);
+    return null;
+  };
+
+  const dispatch = jest.fn();
+
+  describe('when a key is pressed', () => {
+    it('should dispatch a KeyPress action', () => {
+      expect.assertions(2);
+      render(<TestComponent dispatch={dispatch} />);
+
+      expect(dispatch).not.toHaveBeenCalled();
+
+      act(() => {
+        fireEvent.keyDown(window, {
+          key: 'Tab',
+        });
+      });
+
+      expect(dispatch).toHaveBeenCalledWith({ type: ActionTypeKeyPressed, key: 'Tab' });
+    });
+  });
+
+  describe('when the key is unhandled', () => {
+    it('should not dispatch anything', () => {
+      expect.assertions(2);
+      render(<TestComponent dispatch={dispatch} />);
+
+      expect(dispatch).not.toHaveBeenCalled();
+
+      act(() => {
+        fireEvent.keyDown(window, {
+          key: '@',
+        });
+      });
+
+      expect(dispatch).not.toHaveBeenCalled();
+    });
+  });
+});

+ 27 - 59
gmus/src/hooks/vim.ts

@@ -1,71 +1,39 @@
-import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import { Dispatch, useEffect } from 'react';
+
+export const Keys = {
+  tab: 'Tab',
+  enter: 'Enter',
+  space: ' ',
+  '1': '1',
+  J: 'j',
+  K: 'k',
+};
 
-export const KeyPressedContext = createContext<string | null>(null);
+const availableKeys = Object.values(Keys);
 
-export enum Key {
-  Down = 'j',
-  Activate = 'Enter',
-  ModeLibrary = '1',
-  Switch = 'Tab',
-  Toggle = ' ',
-  Up = 'k',
-}
+export const ActionTypeKeyPressed = '@@vim/KEY_PRESSED';
 
-export function useVimBindings(): string | null {
-  const [state, dispatch] = useState<string | null>(null);
+export type ActionKeyPressed = {
+  type: typeof ActionTypeKeyPressed;
+  key: string;
+};
+
+export function useVimBindings(dispatch: Dispatch<ActionKeyPressed>): void {
   useEffect(() => {
     const listener = (event: KeyboardEvent): void => {
+      if (!availableKeys.includes(event.key)) {
+        return;
+      }
+
       event.preventDefault();
-      dispatch(event.key);
-      dispatch(null);
+
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: event.key };
+      dispatch(action);
     };
+
     window.addEventListener('keydown', listener);
     return (): void => {
       window.removeEventListener('keydown', listener);
     };
-  }, []);
-
-  return state;
-}
-
-export function useKeyBinding(active: boolean, binding: Key, action: () => void): void {
-  const lastKeyPress = useContext(KeyPressedContext);
-  useEffect(() => {
-    if (active && lastKeyPress === binding) {
-      action();
-    }
-  }, [active, binding, action, lastKeyPress]);
-}
-
-export type BoundAction = {
-  binding: Key;
-  action: () => void;
-};
-
-export function useMultiKeyBinding(active: boolean, boundActions: BoundAction[]): void {
-  const lastKeyPress = useContext(KeyPressedContext);
-  useEffect(() => {
-    if (active && lastKeyPress) {
-      boundActions.find(({ binding }) => binding === lastKeyPress)?.action();
-    }
-  }, [active, boundActions, lastKeyPress]);
-}
-
-export function useVerticalScrollBindings(
-  active: boolean,
-  scrollDelta: (delta: number) => unknown,
-  additionalBindings: BoundAction[] = [],
-): void {
-  const scrollDown = useCallback(() => scrollDelta(1), [scrollDelta]);
-  const scrollUp = useCallback(() => scrollDelta(-1), [scrollDelta]);
-
-  const boundActions = useMemo<BoundAction[]>(
-    () => [
-      { binding: Key.Down, action: scrollDown },
-      { binding: Key.Up, action: scrollUp },
-      ...additionalBindings,
-    ],
-    [scrollDown, scrollUp, additionalBindings],
-  );
-  useMultiKeyBinding(active, boundActions);
+  }, [dispatch]);
 }

+ 0 - 6
gmus/src/reducer/index.ts

@@ -1,8 +1,2 @@
-import { GlobalState } from './types';
-
 export * from './reducer';
 export * from './types';
-
-export function init(state: GlobalState): GlobalState {
-  return state;
-}

+ 3 - 0
gmus/src/utils/state.ts

@@ -0,0 +1,3 @@
+export function init<S>(state: S): S {
+  return state;
+}