瀏覽代碼

feat(wip): scrolling through albums

Fela Maslen 5 年之前
父節點
當前提交
c825f6203d

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

@@ -175,6 +175,15 @@ describe(cmusUIReducer.name, () => {
           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',
@@ -214,6 +223,31 @@ describe(cmusUIReducer.name, () => {
               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', () => {
@@ -249,6 +283,15 @@ describe(cmusUIReducer.name, () => {
           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',
@@ -288,6 +331,32 @@ describe(cmusUIReducer.name, () => {
               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', () => {

+ 32 - 17
gmus/src/components/ui/cmus/reducer.ts

@@ -3,6 +3,7 @@ import { createContext, Dispatch } from 'react';
 import { LocalAction, loggedOut, 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,
@@ -12,6 +13,7 @@ import {
   CmusUIActionType,
 } from './actions';
 import { CmusUIState, LibraryModeWindow, Overlay, View } from './types';
+import { getNextActiveArtistAndAlbum } from './utils/scroll';
 
 export const initialCmusUIState: CmusUIState = {
   globalAction: null,
@@ -54,28 +56,41 @@ const switchLibraryMode = (state: CmusUIState): CmusUIState => ({
   },
 });
 
-const setActiveSongIdFromActiveArtist = (state: CmusUIState): CmusUIState => ({
-  ...state,
-  library: {
-    ...state.library,
-    activeSongId: state.library.activeArtist
-      ? state.artistSongs[state.library.activeArtist]?.[0]?.id ?? null
-      : null,
-  },
-});
+function getActiveSongIdFromActiveArtistAlbum(
+  activeArtist: string | null,
+  activeAlbum: string | null,
+  artistSongs: Record<string, Song[]>,
+): number | null {
+  if (!activeArtist) {
+    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 =>
-  setActiveSongIdFromActiveArtist({
+const scrollArtists = (state: CmusUIState, delta: 1 | -1): 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: scrollThroughItems(
-        state.artists,
-        (compare) => compare === state.library.activeArtist,
-        delta,
-      ),
+      activeArtist: artist,
+      activeAlbum: album,
+      activeSongId: getActiveSongIdFromActiveArtistAlbum(artist, album, state.artistSongs),
     },
-  });
+  };
+};
 
 const scrollSongs = (state: CmusUIState, delta: number): CmusUIState =>
   state.library.activeArtist

+ 28 - 0
gmus/src/components/ui/cmus/styled/spinner.tsx

@@ -0,0 +1,28 @@
+import { rem } from 'polished';
+import React, { useEffect, useRef, useState } from 'react';
+import styled from 'styled-components';
+
+const icons: string[] = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
+
+const SpinSpan = styled.div`
+  color: inherit;
+  font-family: Hack, monospace;
+  height: ${rem(8)};
+  width: ${rem(8)};
+`;
+
+export const AsciiSpinner: React.FC = () => {
+  const [icon, setIcon] = useState<number>(0);
+  const timer = useRef<number>(0);
+  useEffect(() => {
+    timer.current = window.setInterval(() => {
+      setIcon((last) => (last + 1) % icons.length);
+    }, 50);
+
+    return (): void => {
+      window.clearInterval(timer.current);
+    };
+  }, []);
+
+  return <SpinSpan>{icons[icon]}</SpinSpan>;
+};

+ 193 - 0
gmus/src/components/ui/cmus/utils/scroll.spec.ts

@@ -0,0 +1,193 @@
+import { getNextActiveArtistAndAlbum } from './scroll';
+
+describe(getNextActiveArtistAndAlbum.name, () => {
+  const artists: string[] = ['A', 'B', 'C'];
+
+  const artistAlbums: Record<string, string[]> = {
+    A: ['a1', 'a2', 'a3'],
+    C: ['c1', 'c2'],
+  };
+
+  describe('scrolling down', () => {
+    describe('when nothing is active', () => {
+      it('switch to the first artist', () => {
+        expect(getNextActiveArtistAndAlbum(artists, artistAlbums, null, null, [], 1)).toStrictEqual(
+          {
+            artist: 'A',
+            album: null,
+          },
+        );
+      });
+    });
+
+    describe('when the first artist is active', () => {
+      describe('when its albums are not expanded', () => {
+        it('should switch to the second artist', () => {
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, 'A', null, [], 1),
+          ).toStrictEqual({
+            artist: 'B',
+            album: null,
+          });
+        });
+      });
+
+      describe('when its albums are expanded', () => {
+        it('should switch to the first album', () => {
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, 'A', null, ['A'], 1),
+          ).toStrictEqual({
+            artist: 'A',
+            album: 'a1',
+          });
+        });
+      });
+    });
+
+    describe('when the last artist is active', () => {
+      describe('when its albums are expanded', () => {
+        it.each`
+          activeAlbum | nextAlbum
+          ${null}     | ${'c1'}
+          ${'c1'}     | ${'c2'}
+        `('should switch to the next album ($nextAlbum)', ({ activeAlbum, nextAlbum }) => {
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, 'C', activeAlbum, ['C'], 1),
+          ).toStrictEqual({
+            artist: 'C',
+            album: nextAlbum,
+          });
+        });
+
+        describe('when the last album is active', () => {
+          it('should keep the current selection', () => {
+            expect(
+              getNextActiveArtistAndAlbum(artists, artistAlbums, 'C', 'c2', ['C'], 1),
+            ).toStrictEqual({
+              artist: 'C',
+              album: 'c2',
+            });
+          });
+        });
+      });
+
+      describe('when its albums are not expanded', () => {
+        it('should keep the current selection', () => {
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, 'C', null, ['A'], 1),
+          ).toStrictEqual({
+            artist: 'C',
+            album: null,
+          });
+        });
+      });
+    });
+  });
+
+  describe('scrolling up', () => {
+    describe('when nothing is active', () => {
+      it('should switch to the last artist', () => {
+        expect(
+          getNextActiveArtistAndAlbum(artists, artistAlbums, null, null, [], -1),
+        ).toStrictEqual({
+          artist: 'C',
+          album: null,
+        });
+      });
+
+      describe('when the last albums are expanded', () => {
+        it('should switch to the last album', () => {
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, null, null, ['C'], -1),
+          ).toStrictEqual({
+            artist: 'C',
+            album: 'c2',
+          });
+        });
+      });
+    });
+
+    describe('when the first artist is active', () => {
+      describe('when its albums are not expanded', () => {
+        it('should keep the current selection', () => {
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, 'A', null, [], -1),
+          ).toStrictEqual({
+            artist: 'A',
+            album: null,
+          });
+        });
+      });
+
+      describe('when its albums are expanded', () => {
+        it('should switch to the previous album', () => {
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, 'A', 'a3', ['A'], -1),
+          ).toStrictEqual({
+            artist: 'A',
+            album: 'a2',
+          });
+        });
+
+        describe('when the first album is active', () => {
+          it('should switch to the artist', () => {
+            expect(
+              getNextActiveArtistAndAlbum(artists, artistAlbums, 'A', 'a1', ['A'], -1),
+            ).toStrictEqual({
+              artist: 'A',
+              album: null,
+            });
+          });
+        });
+
+        describe('when no album is active', () => {
+          it('should keep the current selection', () => {
+            expect(
+              getNextActiveArtistAndAlbum(artists, artistAlbums, 'A', null, ['A'], -1),
+            ).toStrictEqual({
+              artist: 'A',
+              album: null,
+            });
+          });
+        });
+      });
+    });
+
+    describe('when a middle or last artist is active', () => {
+      describe('when no album is active', () => {
+        describe('when the previous albums are expanded', () => {
+          it('should switch to the last album of the previous artist', () => {
+            expect(
+              getNextActiveArtistAndAlbum(artists, artistAlbums, 'B', null, ['A'], -1),
+            ).toStrictEqual({
+              artist: 'A',
+              album: 'a3',
+            });
+          });
+        });
+
+        describe('when the previous albums are not expanded', () => {
+          it('should switch to the previous artist', () => {
+            expect(
+              getNextActiveArtistAndAlbum(artists, artistAlbums, 'B', null, [], -1),
+            ).toStrictEqual({
+              artist: 'A',
+              album: null,
+            });
+          });
+        });
+      });
+
+      describe('when an album is active', () => {
+        it('should switch to the previous album', () => {
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, 'C', 'c2', ['C'], -1),
+          ).toStrictEqual({
+            artist: 'C',
+            album: 'c1',
+          });
+        });
+      });
+    });
+  });
+});

+ 63 - 0
gmus/src/components/ui/cmus/utils/scroll.ts

@@ -0,0 +1,63 @@
+import { scrollThroughItems } from '../../../../utils/delta';
+
+const getArtistAlbums = (
+  artist: string | null,
+  artistAlbums: Record<string, string[]>,
+  expandedArtists: string[],
+): string[] => (artist && expandedArtists.includes(artist) ? artistAlbums[artist] ?? [] : []);
+
+export function getNextActiveArtistAndAlbum(
+  artists: string[],
+  artistAlbums: Record<string, string[]>,
+  activeArtist: string | null,
+  activeAlbum: string | null,
+  expandedArtists: string[],
+  delta: -1 | 1,
+): { artist: string | null; album: string | null } {
+  if (activeArtist === null) {
+    if (delta === 1) {
+      return { artist: artists[0] ?? null, album: null };
+    }
+    const lastArtist = artists.length > 0 ? artists[artists.length - 1] : null;
+    if (!lastArtist) {
+      return { artist: null, album: null };
+    }
+    const lastArtistAlbums = getArtistAlbums(lastArtist, artistAlbums, expandedArtists);
+
+    const lastAlbum =
+      lastArtistAlbums.length > 0 ? lastArtistAlbums[lastArtistAlbums.length - 1] : null;
+
+    return { artist: lastArtist, album: lastAlbum };
+  }
+
+  const nextArtist = scrollThroughItems(artists, (compare) => compare === activeArtist, delta);
+  const atEnd = nextArtist === activeArtist;
+
+  const activeArtistAlbums = getArtistAlbums(activeArtist, artistAlbums, expandedArtists);
+  const nextArtistAlbums = getArtistAlbums(nextArtist, artistAlbums, expandedArtists);
+
+  if (activeAlbum === null) {
+    if (
+      (delta === 1 && !activeArtistAlbums.length) ||
+      (delta === -1 && (atEnd || !nextArtistAlbums.length))
+    ) {
+      return { artist: nextArtist, album: null };
+    }
+    if (delta === 1) {
+      return { artist: activeArtist, album: activeArtistAlbums[0] };
+    }
+    return { artist: nextArtist, album: nextArtistAlbums[nextArtistAlbums.length - 1] };
+  }
+
+  const nextAlbum = scrollThroughItems(
+    activeArtistAlbums,
+    (compare) => compare === activeAlbum,
+    delta,
+  );
+
+  if (delta === -1 && nextAlbum === activeAlbum) {
+    return { artist: activeArtist, album: null };
+  }
+
+  return { artist: activeArtist, album: nextAlbum };
+}