Browse Source

refactor: split up cmus reducer

Fela Maslen 5 năm trước cách đây
mục cha
commit
9ead54dc68

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

@@ -1,731 +0,0 @@
-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';
-import { cmusUIReducer, initialCmusUIState } from './reducer';
-import { CmusUIState, LibraryModeWindow, Overlay, View } from './types';
-
-describe(cmusUIReducer.name, () => {
-  const stateLibrary: CmusUIState = {
-    ...initialCmusUIState,
-    view: View.Library,
-  };
-
-  const stateCommandMode: CmusUIState = {
-    ...stateLibrary,
-    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']);
-
-    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(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();
-    });
-  });
-
-  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.CommandSet, () => {
-    describe('q', () => {
-      const action = commandSet('q');
-
-      it('should set a log out global action', () => {
-        expect.assertions(2);
-        const result = cmusUIReducer(stateCommandMode, action);
-        expect(result.commandMode).toBe(false);
-        expect(result.globalAction).toStrictEqual(loggedOut());
-      });
-    });
-  });
-
-  describe(CmusUIActionType.ClientActivated, () => {
-    const action = clientActivated('some-client');
-
-    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(toView);
-      });
-    });
-
-    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.C, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.C };
-
-      it('should set the globalAction to play/pause', () => {
-        expect.assertions(2);
-        const result = cmusUIReducer(stateLibrary, action);
-        expect(result.globalAction).toStrictEqual(playPaused());
-        expect(result.globalActionSerialNumber).toBe(stateLibrary.globalActionSerialNumber + 1);
-      });
-    });
-
-    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'],
-            artistAlbums: {
-              'Artist A': ['Album 1', 'Album 2'],
-            },
-            artistSongs: {
-              'Artist A': [
-                { id: 456, album: 'Album 2' } as Song,
-                { id: 123, album: 'Album 1' } as Song,
-              ],
-            },
-            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 the current artist is expanded', () => {
-            const stateArtistModeWithAlbums: CmusUIState = {
-              ...stateArtistMode,
-              library: {
-                ...stateArtistMode.library,
-                expandedArtists: ['Artist A'],
-              },
-            };
-
-            it('should select the next album', () => {
-              expect.assertions(2);
-              const result = cmusUIReducer(stateArtistModeWithAlbums, action);
-
-              expect(result.library.activeArtist).toBe('Artist A');
-              expect(result.library.activeAlbum).toBe('Album 1');
-            });
-
-            it('should set the active song ID to the first matching the album', () => {
-              expect.assertions(1);
-              const result = cmusUIReducer(stateArtistModeWithAlbums, action);
-
-              expect(result.library.activeSongId).toBe(123);
-            });
-          });
-        });
-
-        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('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, () => {
-      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'],
-            artistAlbums: {
-              'Artist B': ['Album 1', 'Album 2'],
-            },
-            artistSongs: {
-              'Artist B': [
-                { id: 456, album: 'Album 2' } as Song,
-                { id: 123, album: 'Album 1' } as Song,
-              ],
-            },
-            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 the current artist is expanded', () => {
-            const stateArtistModeWithAlbums: CmusUIState = {
-              ...stateArtistMode,
-              library: {
-                ...stateArtistMode.library,
-                expandedArtists: ['Artist B'],
-                activeAlbum: 'Album 2',
-              },
-            };
-
-            it('should select the previous album', () => {
-              expect.assertions(2);
-              const result = cmusUIReducer(stateArtistModeWithAlbums, action);
-
-              expect(result.library.activeArtist).toBe('Artist B');
-              expect(result.library.activeAlbum).toBe('Album 1');
-            });
-
-            it('should set the active song ID to the first matching the album', () => {
-              expect.assertions(1);
-              const result = cmusUIReducer(stateArtistModeWithAlbums, action);
-
-              expect(result.library.activeSongId).toBe(123);
-            });
-          });
-        });
-
-        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('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, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.pageDown };
-
-      describe('when in library view', () => {
-        describe('when in the artist list mode', () => {
-          const stateArtistMode: CmusUIState = {
-            ...stateLibrary,
-            artists: Array(26)
-              .fill(0)
-              .map((_, index) => `Artist ${index + 1}`),
-            artistAlbums: {
-              'Artist 3': ['Album 1', 'Album 2'],
-              'Artist 4': ['Album Z'],
-              'Artist 18': ['Album 3'],
-            },
-            artistSongs: {
-              'Artist 18': [{ id: 123, album: 'Album 3' } as Song],
-            },
-            library: {
-              ...stateLibrary.library,
-              activeArtist: 'Artist 1',
-              activeAlbum: null,
-              expandedArtists: ['Artist 3', 'Artist 18'],
-              modeWindow: LibraryModeWindow.ArtistList,
-            },
-          };
-
-          it('should page the active artist and album by 20 rows down', () => {
-            expect.assertions(2);
-            const result = cmusUIReducer(stateArtistMode, action);
-
-            expect(result.library.activeArtist).toBe('Artist 18');
-            expect(result.library.activeAlbum).toBe('Album 3');
-          });
-
-          it('should set the active song ID to the first by the artist', () => {
-            expect.assertions(1);
-            const result = cmusUIReducer(stateArtistMode, action);
-
-            expect(result.library.activeSongId).toBe(123);
-          });
-        });
-
-        describe('when in the song list mode', () => {
-          const stateSongsMode: CmusUIState = {
-            ...stateLibrary,
-            artists: ['Artist A'],
-            artistSongs: {
-              'Artist A': Array(30)
-                .fill(0)
-                .map((_, index) => ({ id: index + 100 } as Song)),
-            },
-            library: {
-              ...stateLibrary.library,
-              activeArtist: 'Artist A',
-              activeSongId: 101,
-              modeWindow: LibraryModeWindow.SongList,
-            },
-          };
-
-          it('should set the active song ID to the one 20th after current', () => {
-            expect.assertions(1);
-            const result = cmusUIReducer(stateSongsMode, action);
-
-            expect(result.library.activeSongId).toBe(121);
-          });
-        });
-      });
-    });
-
-    describe(Keys.pageUp, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.pageUp };
-
-      describe('when in library view', () => {
-        describe('when in the artist list mode', () => {
-          const stateArtistMode: CmusUIState = {
-            ...stateLibrary,
-            artists: Array(26)
-              .fill(0)
-              .map((_, index) => `Artist ${index + 1}`),
-            artistAlbums: {
-              'Artist 3': ['Album 1', 'Album 2'],
-              'Artist 4': ['Album X', 'Album Y', 'Album Z'],
-              'Artist 18': ['Album 3'],
-            },
-            artistSongs: {
-              'Artist 3': [{ id: 123, album: 'Album 1' } as Song],
-            },
-            library: {
-              ...stateLibrary.library,
-              activeArtist: 'Artist 18',
-              activeAlbum: 'Album 3',
-              expandedArtists: ['Artist 3', 'Artist 4', 'Artist 18'],
-              modeWindow: LibraryModeWindow.ArtistList,
-            },
-          };
-
-          it('should page the active artist and album by 20 rows down', () => {
-            expect.assertions(2);
-            const result = cmusUIReducer(stateArtistMode, action);
-
-            expect(result.library.activeArtist).toBe('Artist 3');
-            expect(result.library.activeAlbum).toBe('Album 1');
-          });
-
-          it('should set the active song ID to the first by the artist', () => {
-            expect.assertions(1);
-            const result = cmusUIReducer(stateArtistMode, action);
-
-            expect(result.library.activeSongId).toBe(123);
-          });
-        });
-
-        describe('when in the song list mode', () => {
-          const stateSongsMode: CmusUIState = {
-            ...stateLibrary,
-            artists: ['Artist A'],
-            artistSongs: {
-              'Artist A': Array(30)
-                .fill(0)
-                .map((_, index) => ({ id: index + 100 } as Song)),
-            },
-            library: {
-              ...stateLibrary.library,
-              activeArtist: 'Artist A',
-              activeSongId: 128,
-              modeWindow: LibraryModeWindow.SongList,
-            },
-          };
-
-          it('should set the active song ID to the one 20th prior to current', () => {
-            expect.assertions(1);
-            const result = cmusUIReducer(stateSongsMode, action);
-
-            expect(result.library.activeSongId).toBe(108);
-          });
-        });
-      });
-    });
-
-    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('when the active album will disappear', () => {
-            const stateWithActiveAlbum: CmusUIState = {
-              ...initialCmusUIState,
-              artistAlbums: {
-                'Artist A': ['Album A', 'Album B', 'Album C'],
-              },
-              library: {
-                ...initialCmusUIState.library,
-                activeArtist: 'Artist A',
-                expandedArtists: ['Artist A'],
-                activeAlbum: 'Album B',
-              },
-            };
-
-            it('should set the active album to null', () => {
-              expect.assertions(2);
-              const result = cmusUIReducer(stateWithActiveAlbum, action);
-              expect(result.library.activeArtist).toBe('Artist A');
-              expect(result.library.activeAlbum).toBeNull();
-            });
-          });
-        });
-      });
-    });
-
-    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);
-          });
-        });
-      });
-
-      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, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.esc };
-
-      describe.each`
-        overlay
-        ${Overlay.Help}
-      `('when the overlay is set to $overlay', ({ overlay }) => {
-        const stateWithOverlay: CmusUIState = {
-          ...initialCmusUIState,
-          overlay,
-        };
-
-        it('should reset the overlay', () => {
-          expect.assertions(1);
-          const result = cmusUIReducer(stateWithOverlay, action);
-          expect(result.overlay).toBeNull();
-        });
-      });
-    });
-
-    describe(Keys.colon, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.colon };
-
-      it('should enter command mode', () => {
-        expect.assertions(1);
-        const result = cmusUIReducer(stateLibrary, action);
-        expect(result.commandMode).toBe(true);
-      });
-    });
-
-    describe(Keys.question, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.question };
-
-      it('should set the overlay to help mode', () => {
-        expect.assertions(1);
-        const result = cmusUIReducer(initialCmusUIState, action);
-        expect(result.overlay).toBe(Overlay.Help);
-      });
-    });
-  });
-});

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

@@ -1,294 +0,0 @@
-import { createContext, Dispatch } from 'react';
-
-import { LocalAction, loggedOut, masterSet, playPaused, stateSet } from '../../../actions';
-import { nullDispatch } from '../../../context/state';
-import { ActionTypeKeyPressed, Keys } from '../../../hooks/vim';
-import { Song } from '../../../types';
-import { scrollThroughItems } from '../../../utils/delta';
-import {
-  ArtistAlbumsLoaded,
-  ArtistSongsLoaded,
-  ArtistsSet,
-  CmusUIAction,
-  CmusUIActionType,
-} from './actions';
-import { CmusUIState, LibraryModeWindow, Overlay, View } from './types';
-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,
-  artists: [],
-  artistAlbums: {},
-  artistSongs: {},
-  library: {
-    modeWindow: LibraryModeWindow.ArtistList,
-    expandedArtists: [],
-    activeArtist: null,
-    activeAlbum: null,
-    activeSongId: null,
-    visibleSongs: [],
-  },
-  clientList: {
-    active: null,
-  },
-};
-
-export const CmusUIStateContext = createContext<CmusUIState>(initialCmusUIState);
-export const CmusUIDispatchContext = createContext<Dispatch<CmusUIAction>>(nullDispatch);
-
-const libraryModeWindows: LibraryModeWindow[] = Object.values(LibraryModeWindow);
-
-const withGlobalAction = (state: CmusUIState, action: LocalAction): CmusUIState => ({
-  ...state,
-  globalAction: action,
-  globalActionSerialNumber: state.globalActionSerialNumber + 1,
-});
-
-const switchLibraryMode = (state: CmusUIState): CmusUIState => ({
-  ...state,
-  library: {
-    ...state.library,
-    modeWindow:
-      libraryModeWindows[
-        (libraryModeWindows.indexOf(state.library.modeWindow) + 1) % libraryModeWindows.length
-      ],
-  },
-});
-
-function getActiveSongIdFromActiveArtistAlbum(
-  activeArtist: string | null,
-  activeAlbum: string | null,
-  artistSongs: Record<string, Song[]>,
-): number | null {
-  if (activeArtist === null) {
-    return null;
-  }
-  const songs = artistSongs[activeArtist] ?? [];
-  if (!activeAlbum) {
-    return songs[0]?.id ?? null;
-  }
-  return songs.find((compare) => compare.album === activeAlbum)?.id ?? null;
-}
-
-const scrollArtists = (state: CmusUIState, delta: number): CmusUIState => {
-  const { artist, album } = getNextActiveArtistAndAlbum(
-    state.artists,
-    state.artistAlbums,
-    state.library.activeArtist,
-    state.library.activeAlbum,
-    state.library.expandedArtists,
-    delta,
-  );
-
-  return {
-    ...state,
-    library: {
-      ...state.library,
-      activeArtist: artist,
-      activeAlbum: album,
-      activeSongId: getActiveSongIdFromActiveArtistAlbum(artist, album, state.artistSongs),
-    },
-  };
-};
-
-const scrollSongs = (state: CmusUIState, delta: number): CmusUIState =>
-  state.library.activeArtist === null
-    ? state
-    : {
-        ...state,
-        library: {
-          ...state.library,
-          activeSongId: scrollThroughItems(
-            state.artistSongs[state.library.activeArtist] ?? [],
-            (compare) => compare.id === state.library.activeSongId,
-            delta,
-          ).id,
-        },
-      };
-
-function toggleExpandArtist(library: CmusUIState['library']): CmusUIState['library'] {
-  if (library.activeArtist === null) {
-    return library;
-  }
-  if (library.expandedArtists.includes(library.activeArtist)) {
-    return {
-      ...library,
-      expandedArtists: library.expandedArtists.filter(
-        (compare) => compare !== library.activeArtist,
-      ),
-      activeAlbum: null,
-    };
-  }
-  return { ...library, expandedArtists: [...library.expandedArtists, library.activeArtist] };
-}
-
-function handleScrollLibrary(state: CmusUIState, delta: number): CmusUIState {
-  switch (state.library.modeWindow) {
-    case LibraryModeWindow.ArtistList:
-      return scrollArtists(state, delta);
-    case LibraryModeWindow.SongList:
-      return scrollSongs(state, delta);
-    default:
-      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 {
-  switch (key) {
-    case Keys.colon:
-      return { ...state, commandMode: true };
-
-    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);
-      }
-      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:
-      return handleActivate(state);
-
-    case Keys.esc:
-      return { ...state, overlay: null };
-
-    case Keys.question:
-      return { ...state, overlay: Overlay.Help };
-
-    case Keys.C:
-      return withGlobalAction(state, playPaused());
-
-    case Keys.J:
-      return handleScroll(state, 1);
-    case Keys.K:
-      return handleScroll(state, -1);
-
-    case Keys.pageDown:
-      return handleScroll(state, 20);
-    case Keys.pageUp:
-      return handleScroll(state, -20);
-
-    default:
-      return state;
-  }
-}
-
-const setArtists = (state: CmusUIState, action: ArtistsSet): CmusUIState => ({
-  ...state,
-  artists: action.payload,
-  library: {
-    ...state.library,
-    activeArtist: action.payload[0] ?? null,
-    activeAlbum: 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,
-  },
-});
-
-function onCommand(state: CmusUIState, command: string | null): CmusUIState {
-  const nextState: CmusUIState = { ...state, commandMode: false };
-
-  switch (command) {
-    case 'q':
-      return withGlobalAction(nextState, loggedOut());
-    default:
-      return nextState;
-  }
-}
-
-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.CommandSet:
-      return onCommand(state, action.payload);
-
-    case CmusUIActionType.ClientActivated:
-      return { ...state, clientList: { active: action.payload } };
-
-    default:
-      return state;
-  }
-}

+ 87 - 0
gmus-web/src/components/ui/cmus/reducer/artists.spec.ts

@@ -0,0 +1,87 @@
+import { artistAlbumsLoaded, artistSongsLoaded, artistsSet, CmusUIActionType } from '../actions';
+
+import { stateWithActiveArtist } from './fixtures';
+import { cmusUIReducer, initialCmusUIState } from './reducer';
+
+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(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();
+  });
+});
+
+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', () => {
+    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);
+    });
+  });
+});

+ 29 - 0
gmus-web/src/components/ui/cmus/reducer/artists.ts

@@ -0,0 +1,29 @@
+import { ArtistAlbumsLoaded, ArtistSongsLoaded, ArtistsSet } from '../actions';
+import { CmusUIState } from '../types';
+
+export const setArtists = (state: CmusUIState, action: ArtistsSet): CmusUIState => ({
+  ...state,
+  artists: action.payload,
+  library: {
+    ...state.library,
+    activeArtist: action.payload[0] ?? null,
+    activeAlbum: null,
+  },
+});
+
+export const setArtistAlbums = (state: CmusUIState, action: ArtistAlbumsLoaded): CmusUIState => ({
+  ...state,
+  artistAlbums: { ...state.artistAlbums, [action.payload.artist]: action.payload.albums },
+});
+
+export 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,
+  },
+});

+ 18 - 0
gmus-web/src/components/ui/cmus/reducer/command.spec.ts

@@ -0,0 +1,18 @@
+import { loggedOut } from '../../../../actions';
+import { CmusUIActionType, commandSet } from '../actions';
+
+import { stateCommandMode } from './fixtures';
+import { cmusUIReducer } from './reducer';
+
+describe(CmusUIActionType.CommandSet, () => {
+  describe('q', () => {
+    const action = commandSet('q');
+
+    it('should set a log out global action', () => {
+      expect.assertions(2);
+      const result = cmusUIReducer(stateCommandMode, action);
+      expect(result.commandMode).toBe(false);
+      expect(result.globalAction).toStrictEqual(loggedOut());
+    });
+  });
+});

+ 15 - 0
gmus-web/src/components/ui/cmus/reducer/command.ts

@@ -0,0 +1,15 @@
+import { loggedOut } from '../../../../actions';
+import { CommandSet } from '../actions';
+import { CmusUIState } from '../types';
+import { withGlobalAction } from './utils';
+
+export function onCommand(state: CmusUIState, action: CommandSet): CmusUIState {
+  const nextState: CmusUIState = { ...state, commandMode: false };
+
+  switch (action.payload) {
+    case 'q':
+      return withGlobalAction(nextState, loggedOut());
+    default:
+      return nextState;
+  }
+}

+ 34 - 0
gmus-web/src/components/ui/cmus/reducer/fixtures.ts

@@ -0,0 +1,34 @@
+import { CmusUIState, LibraryModeWindow, View } from '../types';
+import { initialCmusUIState } from './reducer';
+
+export const stateLibrary: CmusUIState = {
+  ...initialCmusUIState,
+  view: View.Library,
+};
+
+export const stateCommandMode: CmusUIState = {
+  ...stateLibrary,
+  commandMode: true,
+};
+
+export const stateDifferentView: CmusUIState = {
+  ...initialCmusUIState,
+  view: (undefined as unknown) as View,
+  scroll: { delta: 0, serialNumber: 8813 },
+};
+
+export const stateFromMode = (fromModeWindow: LibraryModeWindow): CmusUIState => ({
+  ...stateLibrary,
+  library: {
+    ...stateLibrary.library,
+    modeWindow: fromModeWindow,
+  },
+});
+
+export const stateWithActiveArtist: CmusUIState = {
+  ...initialCmusUIState,
+  library: {
+    ...initialCmusUIState.library,
+    activeArtist: 'My artist',
+  },
+};

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

@@ -0,0 +1 @@
+export * from './reducer';

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

@@ -0,0 +1,585 @@
+import { masterSet, playPaused, stateSet } from '../../../../actions';
+import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../../hooks/vim';
+import { Song } from '../../../../types';
+
+import { CmusUIState, LibraryModeWindow, Overlay, View } from '../types';
+
+import { stateDifferentView, stateFromMode, stateLibrary } from './fixtures';
+import { cmusUIReducer, initialCmusUIState } from './reducer';
+
+describe(ActionTypeKeyPressed, () => {
+  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 result = cmusUIReducer(stateDifferentView, action);
+
+      expect(result.view).toBe(toView);
+    });
+  });
+
+  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 }) => {
+        it(`should set the mode window to ${toModeWindow}`, () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateFromMode(fromModeWindow), {
+            type: ActionTypeKeyPressed,
+            key: Keys.tab,
+          });
+
+          expect(result.library.modeWindow).toBe(toModeWindow);
+        });
+      });
+    });
+  });
+
+  describe(Keys.C, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.C };
+
+    it('should set the globalAction to play/pause', () => {
+      expect.assertions(2);
+      const result = cmusUIReducer(stateLibrary, action);
+      expect(result.globalAction).toStrictEqual(playPaused());
+      expect(result.globalActionSerialNumber).toBe(stateLibrary.globalActionSerialNumber + 1);
+    });
+  });
+
+  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'],
+          artistAlbums: {
+            'Artist A': ['Album 1', 'Album 2'],
+          },
+          artistSongs: {
+            'Artist A': [
+              { id: 456, album: 'Album 2' } as Song,
+              { id: 123, album: 'Album 1' } as Song,
+            ],
+          },
+          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 the current artist is expanded', () => {
+          const stateArtistModeWithAlbums: CmusUIState = {
+            ...stateArtistMode,
+            library: {
+              ...stateArtistMode.library,
+              expandedArtists: ['Artist A'],
+            },
+          };
+
+          it('should select the next album', () => {
+            expect.assertions(2);
+            const result = cmusUIReducer(stateArtistModeWithAlbums, action);
+
+            expect(result.library.activeArtist).toBe('Artist A');
+            expect(result.library.activeAlbum).toBe('Album 1');
+          });
+
+          it('should set the active song ID to the first matching the album', () => {
+            expect.assertions(1);
+            const result = cmusUIReducer(stateArtistModeWithAlbums, action);
+
+            expect(result.library.activeSongId).toBe(123);
+          });
+        });
+      });
+
+      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('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, () => {
+    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'],
+          artistAlbums: {
+            'Artist B': ['Album 1', 'Album 2'],
+          },
+          artistSongs: {
+            'Artist B': [
+              { id: 456, album: 'Album 2' } as Song,
+              { id: 123, album: 'Album 1' } as Song,
+            ],
+          },
+          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 the current artist is expanded', () => {
+          const stateArtistModeWithAlbums: CmusUIState = {
+            ...stateArtistMode,
+            library: {
+              ...stateArtistMode.library,
+              expandedArtists: ['Artist B'],
+              activeAlbum: 'Album 2',
+            },
+          };
+
+          it('should select the previous album', () => {
+            expect.assertions(2);
+            const result = cmusUIReducer(stateArtistModeWithAlbums, action);
+
+            expect(result.library.activeArtist).toBe('Artist B');
+            expect(result.library.activeAlbum).toBe('Album 1');
+          });
+
+          it('should set the active song ID to the first matching the album', () => {
+            expect.assertions(1);
+            const result = cmusUIReducer(stateArtistModeWithAlbums, action);
+
+            expect(result.library.activeSongId).toBe(123);
+          });
+        });
+      });
+
+      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('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, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.pageDown };
+
+    describe('when in library view', () => {
+      describe('when in the artist list mode', () => {
+        const stateArtistMode: CmusUIState = {
+          ...stateLibrary,
+          artists: Array(26)
+            .fill(0)
+            .map((_, index) => `Artist ${index + 1}`),
+          artistAlbums: {
+            'Artist 3': ['Album 1', 'Album 2'],
+            'Artist 4': ['Album Z'],
+            'Artist 18': ['Album 3'],
+          },
+          artistSongs: {
+            'Artist 18': [{ id: 123, album: 'Album 3' } as Song],
+          },
+          library: {
+            ...stateLibrary.library,
+            activeArtist: 'Artist 1',
+            activeAlbum: null,
+            expandedArtists: ['Artist 3', 'Artist 18'],
+            modeWindow: LibraryModeWindow.ArtistList,
+          },
+        };
+
+        it('should page the active artist and album by 20 rows down', () => {
+          expect.assertions(2);
+          const result = cmusUIReducer(stateArtistMode, action);
+
+          expect(result.library.activeArtist).toBe('Artist 18');
+          expect(result.library.activeAlbum).toBe('Album 3');
+        });
+
+        it('should set the active song ID to the first by the artist', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateArtistMode, action);
+
+          expect(result.library.activeSongId).toBe(123);
+        });
+      });
+
+      describe('when in the song list mode', () => {
+        const stateSongsMode: CmusUIState = {
+          ...stateLibrary,
+          artists: ['Artist A'],
+          artistSongs: {
+            'Artist A': Array(30)
+              .fill(0)
+              .map((_, index) => ({ id: index + 100 } as Song)),
+          },
+          library: {
+            ...stateLibrary.library,
+            activeArtist: 'Artist A',
+            activeSongId: 101,
+            modeWindow: LibraryModeWindow.SongList,
+          },
+        };
+
+        it('should set the active song ID to the one 20th after current', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateSongsMode, action);
+
+          expect(result.library.activeSongId).toBe(121);
+        });
+      });
+    });
+  });
+
+  describe(Keys.pageUp, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.pageUp };
+
+    describe('when in library view', () => {
+      describe('when in the artist list mode', () => {
+        const stateArtistMode: CmusUIState = {
+          ...stateLibrary,
+          artists: Array(26)
+            .fill(0)
+            .map((_, index) => `Artist ${index + 1}`),
+          artistAlbums: {
+            'Artist 3': ['Album 1', 'Album 2'],
+            'Artist 4': ['Album X', 'Album Y', 'Album Z'],
+            'Artist 18': ['Album 3'],
+          },
+          artistSongs: {
+            'Artist 3': [{ id: 123, album: 'Album 1' } as Song],
+          },
+          library: {
+            ...stateLibrary.library,
+            activeArtist: 'Artist 18',
+            activeAlbum: 'Album 3',
+            expandedArtists: ['Artist 3', 'Artist 4', 'Artist 18'],
+            modeWindow: LibraryModeWindow.ArtistList,
+          },
+        };
+
+        it('should page the active artist and album by 20 rows down', () => {
+          expect.assertions(2);
+          const result = cmusUIReducer(stateArtistMode, action);
+
+          expect(result.library.activeArtist).toBe('Artist 3');
+          expect(result.library.activeAlbum).toBe('Album 1');
+        });
+
+        it('should set the active song ID to the first by the artist', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateArtistMode, action);
+
+          expect(result.library.activeSongId).toBe(123);
+        });
+      });
+
+      describe('when in the song list mode', () => {
+        const stateSongsMode: CmusUIState = {
+          ...stateLibrary,
+          artists: ['Artist A'],
+          artistSongs: {
+            'Artist A': Array(30)
+              .fill(0)
+              .map((_, index) => ({ id: index + 100 } as Song)),
+          },
+          library: {
+            ...stateLibrary.library,
+            activeArtist: 'Artist A',
+            activeSongId: 128,
+            modeWindow: LibraryModeWindow.SongList,
+          },
+        };
+
+        it('should set the active song ID to the one 20th prior to current', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateSongsMode, action);
+
+          expect(result.library.activeSongId).toBe(108);
+        });
+      });
+    });
+  });
+
+  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('when the active album will disappear', () => {
+          const stateWithActiveAlbum: CmusUIState = {
+            ...initialCmusUIState,
+            artistAlbums: {
+              'Artist A': ['Album A', 'Album B', 'Album C'],
+            },
+            library: {
+              ...initialCmusUIState.library,
+              activeArtist: 'Artist A',
+              expandedArtists: ['Artist A'],
+              activeAlbum: 'Album B',
+            },
+          };
+
+          it('should set the active album to null', () => {
+            expect.assertions(2);
+            const result = cmusUIReducer(stateWithActiveAlbum, action);
+            expect(result.library.activeArtist).toBe('Artist A');
+            expect(result.library.activeAlbum).toBeNull();
+          });
+        });
+      });
+    });
+  });
+
+  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);
+        });
+      });
+    });
+
+    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, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.esc };
+
+    describe.each`
+      overlay
+      ${Overlay.Help}
+    `('when the overlay is set to $overlay', ({ overlay }) => {
+      const stateWithOverlay: CmusUIState = {
+        ...initialCmusUIState,
+        overlay,
+      };
+
+      it('should reset the overlay', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(stateWithOverlay, action);
+        expect(result.overlay).toBeNull();
+      });
+    });
+  });
+
+  describe(Keys.colon, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.colon };
+
+    it('should enter command mode', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(stateLibrary, action);
+      expect(result.commandMode).toBe(true);
+    });
+  });
+
+  describe(Keys.question, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.question };
+
+    it('should set the overlay to help mode', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.overlay).toBe(Overlay.Help);
+    });
+  });
+});

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

@@ -0,0 +1,116 @@
+import { masterSet, playPaused, stateSet } from '../../../../actions';
+import { ActionKeyPressed, Keys } from '../../../../hooks/vim';
+import { CmusUIState, LibraryModeWindow, Overlay, View } from '../types';
+import { handleScroll } from './scroll';
+import { withGlobalAction } from './utils';
+
+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
+      ],
+  },
+});
+
+function toggleExpandArtist(library: CmusUIState['library']): CmusUIState['library'] {
+  if (library.activeArtist === null) {
+    return library;
+  }
+  if (library.expandedArtists.includes(library.activeArtist)) {
+    return {
+      ...library,
+      expandedArtists: library.expandedArtists.filter(
+        (compare) => compare !== library.activeArtist,
+      ),
+      activeAlbum: null,
+    };
+  }
+  return { ...library, expandedArtists: [...library.expandedArtists, library.activeArtist] };
+}
+
+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;
+  }
+}
+
+export function handleKeyPress(state: CmusUIState, action: ActionKeyPressed): CmusUIState {
+  switch (action.key) {
+    case Keys.colon:
+      return { ...state, commandMode: true };
+
+    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);
+      }
+      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:
+      return handleActivate(state);
+
+    case Keys.esc:
+      return { ...state, overlay: null };
+
+    case Keys.question:
+      return { ...state, overlay: Overlay.Help };
+
+    case Keys.C:
+      return withGlobalAction(state, playPaused());
+
+    case Keys.J:
+      return handleScroll(state, 1);
+    case Keys.K:
+      return handleScroll(state, -1);
+
+    case Keys.pageDown:
+      return handleScroll(state, 20);
+    case Keys.pageUp:
+      return handleScroll(state, -20);
+
+    default:
+      return state;
+  }
+}

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

@@ -0,0 +1,12 @@
+import { clientActivated, CmusUIActionType } from '../actions';
+import { cmusUIReducer, initialCmusUIState } from './reducer';
+
+describe(CmusUIActionType.ClientActivated, () => {
+  const action = clientActivated('some-client');
+
+  it('should set the active client', () => {
+    expect.assertions(1);
+    const result = cmusUIReducer(initialCmusUIState, action);
+    expect(result.clientList.active).toBe('some-client');
+  });
+});

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

@@ -0,0 +1,59 @@
+import { createContext, Dispatch } from 'react';
+
+import { nullDispatch } from '../../../../context/state';
+import { ActionTypeKeyPressed } from '../../../../hooks/vim';
+import { CmusUIAction, CmusUIActionType } from '../actions';
+import { CmusUIState, LibraryModeWindow, View } from '../types';
+
+import { setArtistAlbums, setArtists, setArtistSongs } from './artists';
+import { onCommand } from './command';
+import { handleKeyPress } from './keypress';
+
+export const initialCmusUIState: CmusUIState = {
+  globalAction: null,
+  globalActionSerialNumber: 0,
+  scroll: { delta: 0, serialNumber: 0 },
+  view: View.Library,
+  commandMode: false,
+  overlay: null,
+  artists: [],
+  artistAlbums: {},
+  artistSongs: {},
+  library: {
+    modeWindow: LibraryModeWindow.ArtistList,
+    expandedArtists: [],
+    activeArtist: null,
+    activeAlbum: null,
+    activeSongId: null,
+    visibleSongs: [],
+  },
+  clientList: {
+    active: null,
+  },
+};
+
+export const CmusUIStateContext = createContext<CmusUIState>(initialCmusUIState);
+export const CmusUIDispatchContext = createContext<Dispatch<CmusUIAction>>(nullDispatch);
+
+export function cmusUIReducer(state: CmusUIState, action: CmusUIAction): CmusUIState {
+  switch (action.type) {
+    case ActionTypeKeyPressed:
+      return handleKeyPress(state, action);
+
+    case CmusUIActionType.ArtistsSet:
+      return setArtists(state, action);
+    case CmusUIActionType.ArtistAlbumsLoaded:
+      return setArtistAlbums(state, action);
+    case CmusUIActionType.ArtistSongsLoaded:
+      return setArtistSongs(state, action);
+
+    case CmusUIActionType.CommandSet:
+      return onCommand(state, action);
+
+    case CmusUIActionType.ClientActivated:
+      return { ...state, clientList: { active: action.payload } };
+
+    default:
+      return state;
+  }
+}

+ 78 - 0
gmus-web/src/components/ui/cmus/reducer/scroll.ts

@@ -0,0 +1,78 @@
+import { Song } from '../../../../types';
+import { scrollThroughItems } from '../../../../utils/delta';
+import { CmusUIState, LibraryModeWindow, View } from '../types';
+import { getNextActiveArtistAndAlbum } from '../utils/scroll';
+
+const scrollSongs = (state: CmusUIState, delta: number): CmusUIState =>
+  state.library.activeArtist === null
+    ? state
+    : {
+        ...state,
+        library: {
+          ...state.library,
+          activeSongId: scrollThroughItems(
+            state.artistSongs[state.library.activeArtist] ?? [],
+            (compare) => compare.id === state.library.activeSongId,
+            delta,
+          ).id,
+        },
+      };
+
+function getActiveSongIdFromActiveArtistAlbum(
+  activeArtist: string | null,
+  activeAlbum: string | null,
+  artistSongs: Record<string, Song[]>,
+): number | null {
+  if (activeArtist === null) {
+    return null;
+  }
+  const songs = artistSongs[activeArtist] ?? [];
+  if (!activeAlbum) {
+    return songs[0]?.id ?? null;
+  }
+  return songs.find((compare) => compare.album === activeAlbum)?.id ?? null;
+}
+
+function scrollArtists(state: CmusUIState, delta: number): CmusUIState {
+  const { artist, album } = getNextActiveArtistAndAlbum(
+    state.artists,
+    state.artistAlbums,
+    state.library.activeArtist,
+    state.library.activeAlbum,
+    state.library.expandedArtists,
+    delta,
+  );
+
+  return {
+    ...state,
+    library: {
+      ...state.library,
+      activeArtist: artist,
+      activeAlbum: album,
+      activeSongId: getActiveSongIdFromActiveArtistAlbum(artist, album, state.artistSongs),
+    },
+  };
+}
+
+function handleScrollLibrary(state: CmusUIState, delta: number): CmusUIState {
+  switch (state.library.modeWindow) {
+    case LibraryModeWindow.ArtistList:
+      return scrollArtists(state, delta);
+    case LibraryModeWindow.SongList:
+      return scrollSongs(state, delta);
+    default:
+      return state;
+  }
+}
+
+export 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 },
+      };
+  }
+}

+ 8 - 0
gmus-web/src/components/ui/cmus/reducer/utils.ts

@@ -0,0 +1,8 @@
+import { LocalAction } from '../../../../actions';
+import { CmusUIState } from '../types';
+
+export const withGlobalAction = (state: CmusUIState, action: LocalAction): CmusUIState => ({
+  ...state,
+  globalAction: action,
+  globalActionSerialNumber: state.globalActionSerialNumber + 1,
+});