Procházet zdrojové kódy

fix: some ui bugs

    fix: select first song when searching if the songs have already been loaded
    fix: only scroll songs within selected album
    chore: moved scroll and order reducer specs into their own file
    fix: don't clear name when refreshing page
    fix: initialise to master after a delay in the case when no message is received from existing master
Fela Maslen před 5 roky
rodič
revize
fd52099c1a

+ 1 - 0
gmus-web/package.json

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

+ 1 - 431
gmus-web/src/components/ui/cmus/reducer/keypress.spec.ts

@@ -1,13 +1,5 @@
-import {
-  masterSet,
-  playPaused,
-  queueOrdered,
-  queuePushed,
-  queueRemoved,
-  stateSet,
-} from '../../../../actions';
+import { masterSet, playPaused, queuePushed, queueRemoved, stateSet } from '../../../../actions';
 import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../../hooks/vim';
-import { Song } from '../../../../types';
 
 import { CmusUIState, LibraryModeWindow, Overlay, View } from '../types';
 
@@ -93,288 +85,6 @@ describe(ActionTypeKeyPressed, () => {
     });
   });
 
-  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 queue view', () => {
-      it('should select the next item in the queue', () => {
-        expect.assertions(1);
-        const result = cmusUIReducer(stateQueue, action);
-        expect(result.queue.active).toBe(887);
-      });
-    });
-
-    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 queue view', () => {
-      it('should select the next item in the queue', () => {
-        expect.assertions(1);
-        const result = cmusUIReducer(
-          { ...stateQueue, queue: { ...stateQueue.queue, active: 189 } },
-          action,
-        );
-        expect(result.queue.active).toBe(75);
-      });
-    });
-
-    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.p, () => {
-    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.p };
-
-    describe('when on the queue view', () => {
-      it('should set a global action to move the song down the queue', () => {
-        expect.assertions(1);
-        const result = cmusUIReducer(
-          { ...stateQueue, queue: { ...stateQueue.queue, active: 75 } },
-          action,
-        );
-        expect(result.globalAction).toStrictEqual(queueOrdered(75, 1));
-      });
-    });
-  });
-
-  describe(Keys.P, () => {
-    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.P };
-
-    describe('when on the queue view', () => {
-      it('should set a global action to move the song up the queue', () => {
-        expect.assertions(1);
-        const result = cmusUIReducer(
-          { ...stateQueue, queue: { ...stateQueue.queue, active: 75 } },
-          action,
-        );
-        expect(result.globalAction).toStrictEqual(queueOrdered(75, -1));
-      });
-    });
-  });
-
   describe(Keys.Z, () => {
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.Z };
 
@@ -388,146 +98,6 @@ describe(ActionTypeKeyPressed, () => {
     });
   });
 
-  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 };
 

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

@@ -0,0 +1,36 @@
+import { queueOrdered } from '../../../../actions';
+import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../../hooks/vim';
+import { stateQueue } from './fixtures';
+import { cmusUIReducer } from './reducer';
+
+describe('Order actions', () => {
+  describe(Keys.p, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.p };
+
+    describe('when on the queue view', () => {
+      it('should set a global action to move the song down the queue', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(
+          { ...stateQueue, queue: { ...stateQueue.queue, active: 75 } },
+          action,
+        );
+        expect(result.globalAction).toStrictEqual(queueOrdered(75, 1));
+      });
+    });
+  });
+
+  describe(Keys.P, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.P };
+
+    describe('when on the queue view', () => {
+      it('should set a global action to move the song up the queue', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(
+          { ...stateQueue, queue: { ...stateQueue.queue, active: 75 } },
+          action,
+        );
+        expect(result.globalAction).toStrictEqual(queueOrdered(75, -1));
+      });
+    });
+  });
+});

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

@@ -0,0 +1,419 @@
+import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../../hooks/vim';
+import { Song } from '../../../../types';
+import { CmusUIState, LibraryModeWindow } from '../types';
+import { stateDifferentView, stateLibrary, stateQueue } from './fixtures';
+import { cmusUIReducer } from './reducer';
+
+describe('Scroll actions', () => {
+  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, album: 'Album 1' } as Song,
+              { id: 456, album: 'Album 1' } as Song,
+              { id: 789, album: 'Album 2' } 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 filtered by album', () => {
+          const stateFiltered: CmusUIState = {
+            ...stateSongsMode,
+            library: { ...stateSongsMode.library, activeAlbum: 'Album 1' },
+          };
+          it('should only scroll within the selected album', () => {
+            expect.assertions(2);
+
+            const firstResult = cmusUIReducer(stateFiltered, action);
+            const secondResult = cmusUIReducer(firstResult, action);
+
+            expect(firstResult.library.activeSongId).toBe(456);
+            expect(secondResult.library.activeSongId).toBe(456);
+          });
+        });
+      });
+    });
+
+    describe('when in queue view', () => {
+      it('should select the next item in the queue', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(stateQueue, action);
+        expect(result.queue.active).toBe(887);
+      });
+    });
+
+    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 queue view', () => {
+      it('should select the next item in the queue', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(
+          { ...stateQueue, queue: { ...stateQueue.queue, active: 189 } },
+          action,
+        );
+        expect(result.queue.active).toBe(75);
+      });
+    });
+
+    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);
+        });
+      });
+    });
+  });
+});

+ 2 - 1
gmus-web/src/components/ui/cmus/reducer/scroll.ts

@@ -1,5 +1,6 @@
 import { Song } from '../../../../types';
 import { scrollThroughItems } from '../../../../utils/delta';
+import { getFilteredSongs } from '../selectors';
 import { CmusUIState, LibraryModeWindow, View } from '../types';
 import { getNextActiveArtistAndAlbum } from '../utils/scroll';
 
@@ -11,7 +12,7 @@ const scrollSongs = (state: CmusUIState, delta: number): CmusUIState =>
         library: {
           ...state.library,
           activeSongId: scrollThroughItems(
-            state.artistSongs[state.library.activeArtist] ?? [],
+            getFilteredSongs(state),
             (compare) => compare.id === state.library.activeSongId,
             delta,
           ).id,

+ 16 - 1
gmus-web/src/components/ui/cmus/reducer/search.spec.ts

@@ -25,6 +25,7 @@ describe('Searching', () => {
         ...stateSearching.library,
         activeArtist: null,
         modeWindow: LibraryModeWindow.ArtistList,
+        activeSongId: 883,
       },
     };
 
@@ -48,9 +49,23 @@ describe('Searching', () => {
 
     describe('artists', () => {
       it('should select the first match', () => {
-        expect.assertions(1);
+        expect.assertions(2);
         const result = cmusUIReducer(stateSearchingArtists, searched('ant'));
         expect(result.library.activeArtist).toBe('Anticon');
+        expect(result.library.activeSongId).toBeNull();
+      });
+
+      describe('when the artist has songs loaded', () => {
+        const stateWithSongsLoaded: CmusUIState = {
+          ...stateSearchingArtists,
+          artistSongs: { Anticon: [{ id: 174 } as Song] },
+        };
+
+        it('should select the first song', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateWithSongsLoaded, searched('ant'));
+          expect(result.library.activeSongId).toBe(174);
+        });
       });
     });
 

+ 3 - 1
gmus-web/src/components/ui/cmus/reducer/search.ts

@@ -7,7 +7,9 @@ function searchForArtist(state: CmusUIState, term: string): CmusUIState {
     return state;
   }
 
-  return { ...state, library: { ...state.library, activeArtist: closestArtist } };
+  const activeSongId = state.artistSongs[closestArtist]?.[0]?.id ?? null;
+
+  return { ...state, library: { ...state.library, activeArtist: closestArtist, activeSongId } };
 }
 
 function searchForSong(state: CmusUIState, term: string): CmusUIState {

+ 20 - 0
gmus-web/src/components/ui/cmus/selectors.ts

@@ -0,0 +1,20 @@
+import { createSelector } from 'reselect';
+
+import { Song } from '../../../types';
+import { emptyArray } from '../../../utils/array';
+
+import { CmusUIState } from './types';
+
+export const getArtistSongs = (state: CmusUIState): Song[] =>
+  state.library.activeArtist === null
+    ? emptyArray
+    : state.artistSongs[state.library.activeArtist] ?? emptyArray;
+
+export const getActiveAlbum = (state: CmusUIState): string | null => state.library.activeAlbum;
+
+export const getFilteredSongs = createSelector(
+  getArtistSongs,
+  getActiveAlbum,
+  (songs, activeAlbum) =>
+    activeAlbum === null ? songs : songs.filter(({ album }) => album === activeAlbum),
+);

+ 7 - 20
gmus-web/src/components/ui/cmus/views/songs.tsx

@@ -6,6 +6,7 @@ import { StateContext } from '../../../../context/state';
 import { Song } from '../../../../types';
 import { namedMemo } from '../../../../utils/component';
 import { CmusUIStateContext } from '../reducer';
+import { getFilteredSongs } from '../selectors';
 import { NoWrapFill } from '../styled/layout';
 import { AsciiSpinner } from '../styled/spinner';
 import { getSongScrollIndex, lineHeight, useAutoJumpyScroll } from '../utils/scroll';
@@ -16,8 +17,6 @@ type Props = {
   active: boolean;
 };
 
-const emptyArray: Song[] = [];
-
 type SongData = {
   song: Song;
   active: boolean;
@@ -42,25 +41,13 @@ const Row = namedMemo<{ index: number; data: SongData[]; style: CSSProperties }>
 );
 
 export const Songs: React.FC<Props> = ({ active: parentActive }) => {
-  const {
-    artistSongs,
-    library: { activeArtist, activeAlbum, activeSongId },
-  } = useContext(CmusUIStateContext);
-
-  const {
-    player: { songId: playingSongId },
-  } = useContext(StateContext);
+  const globalState = useContext(StateContext);
+  const { songId: playingSongId } = globalState.player;
 
-  const activeArtistSongs =
-    activeArtist === null ? emptyArray : artistSongs[activeArtist] ?? emptyArray;
+  const state = useContext(CmusUIStateContext);
+  const { activeArtist, activeSongId } = state.library;
 
-  const filteredSongs = useMemo<Song[]>(
-    () =>
-      activeAlbum === null
-        ? activeArtistSongs
-        : activeArtistSongs.filter(({ album }) => album === activeAlbum),
-    [activeAlbum, activeArtistSongs],
-  );
+  const filteredSongs = getFilteredSongs(state);
 
   const itemData = useMemo<SongData[]>(
     () =>
@@ -78,7 +65,7 @@ export const Songs: React.FC<Props> = ({ active: parentActive }) => {
 
   useAutoJumpyScroll(windowRef, scrollIndex);
 
-  if (activeArtist && !(activeArtist in artistSongs)) {
+  if (activeArtist !== null && !(activeArtist in state.artistSongs)) {
     return (
       <Styled.Container>
         <AsciiSpinner />

+ 14 - 22
gmus-web/src/hooks/master.spec.tsx

@@ -29,7 +29,6 @@ describe(useMaster.name, () => {
   describe('when there is no master initially', () => {
     const stateNoMaster: GlobalState = {
       ...initialState,
-      initialised: true,
       myClientName: 'my-client-name',
       player: {
         ...nullPlayer,
@@ -39,35 +38,24 @@ describe(useMaster.name, () => {
 
     it('should take control of master', () => {
       expect.assertions(2);
+      jest.useFakeTimers();
       const { unmount } = setup(stateNoMaster);
 
+      act(() => {
+        jest.runOnlyPendingTimers();
+      });
+
       expect(dispatch).toHaveBeenCalledTimes(1);
       expect(dispatch).toHaveBeenCalledWith(stateSet({ master: 'my-client-name' }));
 
       unmount();
-    });
-
-    describe('when the state is not initialised', () => {
-      const stateNoMasterUninit: GlobalState = {
-        ...stateNoMaster,
-        initialised: false,
-      };
-
-      it('should not take control of master', () => {
-        expect.assertions(1);
-        const { unmount } = setup(stateNoMasterUninit);
-
-        expect(dispatch).not.toHaveBeenCalled();
-
-        unmount();
-      });
+      jest.useRealTimers();
     });
   });
 
   describe('when master goes away', () => {
     const stateWithMaster: GlobalState = {
       ...initialState,
-      initialised: true,
       myClientName: 'my-client-name',
       clientList: [
         { name: 'master-client-a', lastPing: 0 },
@@ -142,7 +130,9 @@ describe(useMaster.name, () => {
           });
         });
 
-        jest.runAllTimers();
+        act(() => {
+          jest.runAllTimers();
+        });
 
         expect(dispatch).not.toHaveBeenCalled();
 
@@ -155,7 +145,6 @@ describe(useMaster.name, () => {
   describe('when the client is master', () => {
     const stateMaster: GlobalState = {
       ...initialState,
-      initialised: true,
       myClientName: 'the-master-client',
       clientList: [{ name: 'the-master-client', lastPing: 0 }],
       player: {
@@ -168,6 +157,11 @@ describe(useMaster.name, () => {
       expect.assertions(6);
       const clock = jest.useFakeTimers();
       const { unmount } = setup(stateMaster);
+      act(() => {
+        clock.runOnlyPendingTimers();
+      });
+
+      dispatch.mockClear();
 
       act(() => {
         clock.runTimersToTime(masterStateUpdateTimeout - 1);
@@ -199,7 +193,6 @@ describe(useMaster.name, () => {
   describe('when the client is a slave', () => {
     const stateSlave: GlobalState = {
       ...initialState,
-      initialised: true,
       myClientName: 'a-slave-client',
       clientList: [
         { name: 'the-master-client', lastPing: 0 },
@@ -227,4 +220,3 @@ describe(useMaster.name, () => {
     });
   });
 });
-

+ 20 - 3
gmus-web/src/hooks/master.ts

@@ -1,4 +1,4 @@
-import { useContext, useEffect, useRef } from 'react';
+import { useContext, useEffect, useRef, useState } from 'react';
 
 import { masterSet, stateSet } from '../actions';
 import { masterStateUpdateTimeout } from '../constants/system';
@@ -23,7 +23,24 @@ export function useMaster(): void {
     };
   }, [dispatch, clientIsMaster]);
 
-  const shouldInitMaster = !state.player.master && state.initialised;
+  const clientList = state.clientList;
+  useEffect(() => {
+    if (clientIsMaster) {
+      dispatch(stateSet({ master: state.myClientName }));
+    }
+  }, [clientList, dispatch, clientIsMaster, state.myClientName]);
+
+  // wait one second for first update message from existing master,
+  // before assuming master doesn't exist
+  const [initialised, setInitialised] = useState<boolean>(false);
+  const initMasterTimer = useRef<number>(0);
+  useEffect(() => {
+    initMasterTimer.current = setTimeout(() => {
+      setInitialised(true);
+    }, 1000);
+  }, []);
+
+  const shouldInitMaster = initialised && !state.player.master;
   useEffect(() => {
     if (shouldInitMaster) {
       dispatch(stateSet({ master: state.myClientName }));
@@ -31,7 +48,7 @@ export function useMaster(): void {
   }, [dispatch, shouldInitMaster, state.myClientName]);
 
   const masterWentAway =
-    state.initialised && !state.clientList.some(({ name }) => name === state.player.master);
+    initialised && !state.clientList.some(({ name }) => name === state.player.master);
   const retakeControlTimer = useRef<number>(0);
 
   useEffect(() => {

+ 3 - 2
gmus-web/src/hooks/socket.ts

@@ -10,6 +10,7 @@ import { GlobalState } from '../reducer';
 import { getPubsubUrl } from '../utils/url';
 
 const getUniqueName = (name: string): string => (name.length ? `${name}-${nanoid(5)}` : '');
+const clientNameKey = 'client-name';
 
 export type OnMessage = (message: MessageEvent<unknown>) => void;
 
@@ -38,6 +39,7 @@ export function useDispatchWithEffects(
     (action: LocalAction): void => {
       if (action.type === ActionTypeLocal.LoggedOut) {
         socket?.close();
+        localStorage.removeItem(clientNameKey);
       } else {
         setLastAction(action);
         dispatch(action);
@@ -70,7 +72,7 @@ export function useSocket(
   connecting: boolean;
   connected: boolean;
 } {
-  const [storedName, saveName] = useStorageState<string>(localStorage, 'client-name', '');
+  const [storedName, saveName] = useStorageState<string>(localStorage, clientNameKey, '');
   const [uniqueName, setUniqueName] = useState<string>(getUniqueName(storedName));
   const [tempName, onIdentify] = useState<string>(storedName);
 
@@ -107,7 +109,6 @@ export function useSocket(
       ws.onclose = (): void => {
         setError(false);
         setSocket(null);
-        saveName('');
       };
     } else {
       setConnecting(false);

+ 0 - 18
gmus-web/src/reducer/reducer.spec.ts

@@ -18,24 +18,6 @@ import { GlobalState } from './types';
 
 describe(globalReducer.name, () => {
   describe(ActionTypeRemote.StateSet, () => {
-    it('should initialise the state', () => {
-      expect.assertions(1);
-      const state: GlobalState = { ...initialState, initialised: false };
-      const result = globalReducer(state, {
-        type: ActionTypeRemote.StateSet,
-        payload: {
-          songId: null,
-          playing: false,
-          currentTime: 0,
-          seekTime: -1,
-          master: 'some-master-client',
-          queue: [],
-        },
-      });
-
-      expect(result.initialised).toBe(true);
-    });
-
     describe('when the client is master', () => {
       const stateMaster: GlobalState = {
         ...initialState,

+ 1 - 2
gmus-web/src/reducer/reducer.ts

@@ -20,7 +20,6 @@ export const nullPlayer: MusicPlayer = {
 };
 
 export const initialState: GlobalState = {
-  initialised: false,
   songInfo: null,
   player: nullPlayer,
   clientList: [],
@@ -37,7 +36,7 @@ function onRemoteStateSet(state: GlobalState, action: ActionStateSetRemote): Glo
 
   const nextPlayerWithSeekTime: MusicPlayer = { ...nextPlayer, seekTime };
 
-  return { ...state, initialised: true, player: nextPlayerWithSeekTime };
+  return { ...state, player: nextPlayerWithSeekTime };
 }
 
 function onLocalStateSet(state: GlobalState, action: ActionStateSetLocal): GlobalState {

+ 0 - 1
gmus-web/src/reducer/types.ts

@@ -2,7 +2,6 @@ import { Song } from '../types';
 import { Member, MusicPlayer } from '../types/state';
 
 export type GlobalState = {
-  initialised: boolean;
   player: MusicPlayer;
   songInfo: Song | null;
   clientList: Member[];

+ 1 - 0
gmus-web/src/utils/array.ts

@@ -0,0 +1 @@
+export const emptyArray = [];

+ 5 - 0
gmus-web/yarn.lock

@@ -9835,6 +9835,11 @@ requires-port@^1.0.0:
   resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
   integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
 
+reselect@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7"
+  integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA==
+
 resolve-cwd@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a"