Jelajahi Sumber

feat: improvements to queue UI

    * feat: scroll queue
    * feat: help command for adding items to queue
    * feat: add entire artist or album to queue at once
Fela Maslen 5 tahun lalu
induk
melakukan
aad4008412

+ 3 - 3
gmus-web/src/actions/actions.ts

@@ -62,10 +62,10 @@ export const songInfoFetched = (song: Song | null, replace = false): ActionSongI
   payload: { song, replace },
 });
 
-export type ActionQueuePushed = ActionLocal<ActionTypeLocal.QueuePushed, number>;
-export const queuePushed = (songId: number): ActionQueuePushed => ({
+export type ActionQueuePushed = ActionLocal<ActionTypeLocal.QueuePushed, number[]>;
+export const queuePushed = (songIds: number[]): ActionQueuePushed => ({
   type: ActionTypeLocal.QueuePushed,
-  payload: songId,
+  payload: songIds,
 });
 
 export type ActionQueueShifted = ActionLocal<ActionTypeLocal.QueueShifted, void>;

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

@@ -28,6 +28,9 @@ export const stateFromMode = (fromModeWindow: LibraryModeWindow): CmusUIState =>
 
 export const stateWithActiveArtist: CmusUIState = {
   ...initialCmusUIState,
+  artistSongs: {
+    'My artist': [{ id: 184, album: 'Album 1' } as Song, { id: 37, album: 'Album 2' } as Song],
+  },
   library: {
     ...initialCmusUIState.library,
     activeArtist: 'My artist',

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

@@ -8,6 +8,7 @@ import {
   stateFromMode,
   stateLibrary,
   stateQueue,
+  stateWithActiveArtist,
   stateWithActiveSong,
 } from './fixtures';
 import { cmusUIReducer, initialCmusUIState } from './reducer';
@@ -77,11 +78,19 @@ describe(ActionTypeKeyPressed, () => {
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.E };
 
     describe('when in library view', () => {
+      describe('when in artist list mode', () => {
+        it("should add all the active artist's songs to the queue", () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateWithActiveArtist, action);
+          expect(result.globalAction).toStrictEqual(queuePushed([184, 37]));
+        });
+      });
+
       describe('when in songs list mode', () => {
         it('should set global action to add the selected song to the queue', () => {
           expect.assertions(1);
           const result = cmusUIReducer(stateWithActiveSong, action);
-          expect(result.globalAction).toStrictEqual(queuePushed(1867));
+          expect(result.globalAction).toStrictEqual(queuePushed([1867]));
         });
       });
     });

+ 19 - 8
gmus-web/src/components/ui/cmus/reducer/keypress.ts

@@ -1,5 +1,6 @@
 import { masterSet, playPaused, queuePushed, queueRemoved, stateSet } from '../../../../actions';
 import { ActionKeyPressed, Keys } from '../../../../hooks/vim';
+import { getFilteredSongs } from '../selectors';
 import { CmusUIState, LibraryModeWindow, Overlay, View } from '../types';
 import { handleOrder } from './order';
 import { handleScroll } from './scroll';
@@ -74,6 +75,23 @@ function handleActivate(state: CmusUIState): CmusUIState {
   }
 }
 
+function addSelectedToQueue(state: CmusUIState): CmusUIState {
+  if (state.view !== View.Library) {
+    return state;
+  }
+  switch (state.library.modeWindow) {
+    case LibraryModeWindow.ArtistList:
+      return withGlobalAction(state, queuePushed(getFilteredSongs(state).map(({ id }) => id)));
+    case LibraryModeWindow.SongList:
+      if (!state.library.activeSongId) {
+        return state;
+      }
+      return withGlobalAction(state, queuePushed([state.library.activeSongId]));
+    default:
+      return state;
+  }
+}
+
 export function handleKeyPress(state: CmusUIState, action: ActionKeyPressed): CmusUIState {
   switch (action.key) {
     case Keys.colon:
@@ -128,14 +146,7 @@ export function handleKeyPress(state: CmusUIState, action: ActionKeyPressed): Cm
       return state;
 
     case Keys.E:
-      if (
-        state.view === View.Library &&
-        state.library.modeWindow === LibraryModeWindow.SongList &&
-        state.library.activeSongId
-      ) {
-        return withGlobalAction(state, queuePushed(state.library.activeSongId));
-      }
-      return state;
+      return addSelectedToQueue(state);
 
     case Keys.J:
       return handleScroll(state, 1);

+ 1 - 0
gmus-web/src/components/ui/cmus/views/help.tsx

@@ -25,6 +25,7 @@ const commandsLibrary: Command[] = [
   { command: '<Tab>', description: 'switch between artists/albums and songs' },
   { command: '<Space>', description: 'toggle albums for selected artist' },
   { command: '<Enter>', description: 'play the selected song' },
+  { command: 'e', description: 'add selected item to queue' },
   { command: '/', description: 'search for an item' },
 ];
 

+ 4 - 2
gmus-web/src/components/ui/cmus/views/queue.styles.ts

@@ -1,13 +1,15 @@
 import { rem } from 'polished';
 import styled from 'styled-components';
 
-import { ActiveHighlightRow, FlexColumn } from '../styled/layout';
+import { ActiveHighlightRow, FlexList } from '../styled/layout';
 
-export const Container = styled(FlexColumn)`
+export const Container = styled(FlexList)`
+  height: 100%;
   width: 100%;
 `;
 
 export const QueueSong = styled(ActiveHighlightRow)`
+  overflow: hidden;
   width: 100%;
 `;
 

+ 70 - 28
gmus-web/src/components/ui/cmus/views/queue.tsx

@@ -1,28 +1,50 @@
 import { AxiosInstance, AxiosResponse } from 'axios';
-import React, { useCallback, useContext, useEffect, useMemo } from 'react';
+import React, { CSSProperties, useCallback, useContext, useEffect, useMemo, useRef } from 'react';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import { FixedSizeList as List } from 'react-window';
 
 import { StateContext } from '../../../../context/state';
 import { useRequestCallback } from '../../../../hooks/request';
 import { Song } from '../../../../types';
+import { namedMemo } from '../../../../utils/component';
 import { getApiUrl } from '../../../../utils/url';
 import { queueInfoLoaded } from '../actions';
 import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
+import { lineHeight, useAutoJumpyScroll } from '../utils/scroll';
 
 import * as Styled from './queue.styles';
+import { SongData } from './songs';
 
 export type Props = {
   currentSong: Song | null;
 };
 
+type QueueInfoData = Omit<SongData, 'parentActive' | 'queuePosition'>;
+
+const itemKey = (index: number, data: QueueInfoData[]): number => data[index].song.id;
+
+const Row = namedMemo<{ index: number; data: QueueInfoData[]; style: CSSProperties }>(
+  'QueueInfo',
+  ({ index, data, style }) => (
+    <Styled.QueueSong
+      active={data[index].active}
+      parentActive={true}
+      highlight={data[index].highlight}
+      style={style}
+    >
+      <Styled.Track>{data[index].song.track}</Styled.Track>
+      <Styled.Title>{data[index].song.title}</Styled.Title>
+      <Styled.Artist>{data[index].song.artist}</Styled.Artist>
+      <Styled.Album>{data[index].song.album}</Styled.Album>
+    </Styled.QueueSong>
+  ),
+);
+
 export const ViewQueue: React.FC<Props> = ({ currentSong }) => {
-  const {
-    player: { queue },
-  } = useContext(StateContext);
+  const globalState = useContext(StateContext);
 
   const dispatchUI = useContext(CmusUIDispatchContext);
-  const {
-    queue: { active },
-  } = useContext(CmusUIStateContext);
+  const state = useContext(CmusUIStateContext);
 
   const sendRequest = useCallback(
     (axios: AxiosInstance, query: number[]): Promise<AxiosResponse<Song[]>> =>
@@ -34,39 +56,59 @@ export const ViewQueue: React.FC<Props> = ({ currentSong }) => {
     sendRequest,
   });
 
-  useEffect(() => {
-    if (queue.length) {
-      fetchQueueInfo(queue);
-    }
-  }, [fetchQueueInfo, queue]);
-
   const orderedSongInfo = useMemo<Song[]>(
     () =>
-      queue
+      globalState.player.queue
         .map((id) => queueInfo?.find((compare) => compare.id === id))
         .filter((info: Song | undefined): info is Song => !!info),
-    [queueInfo, queue],
+    [globalState.player.queue, queueInfo],
+  );
+
+  const itemData = useMemo<QueueInfoData[]>(
+    () =>
+      orderedSongInfo.map<QueueInfoData>((info) => ({
+        song: info,
+        active: state.queue.active === info.id,
+        highlight: currentSong?.id === info.id,
+      })),
+    [orderedSongInfo, state.queue.active, currentSong?.id],
   );
 
+  useEffect(() => {
+    if (globalState.player.queue.length) {
+      fetchQueueInfo(globalState.player.queue);
+    }
+  }, [fetchQueueInfo, globalState.player.queue]);
+
   useEffect(() => {
     dispatchUI(queueInfoLoaded(orderedSongInfo));
   }, [dispatchUI, orderedSongInfo]);
 
+  const windowRef = useRef<HTMLDivElement>(null);
+  useAutoJumpyScroll(
+    windowRef,
+    state.queue.active === null
+      ? -1
+      : orderedSongInfo.findIndex(({ id }) => id === state.queue.active),
+  );
+
   return (
     <Styled.Container>
-      {orderedSongInfo.map((song) => (
-        <Styled.QueueSong
-          key={song.id}
-          active={active === song.id}
-          parentActive={true}
-          highlight={currentSong?.id === song.id}
-        >
-          <Styled.Track>{song.track}</Styled.Track>
-          <Styled.Title>{song.title}</Styled.Title>
-          <Styled.Artist>{song.artist}</Styled.Artist>
-          <Styled.Album>{song.album}</Styled.Album>
-        </Styled.QueueSong>
-      ))}
+      <AutoSizer>
+        {({ height, width }): React.ReactElement => (
+          <List
+            outerRef={windowRef}
+            height={height}
+            width={width}
+            itemCount={itemData.length}
+            itemSize={lineHeight}
+            itemKey={itemKey}
+            itemData={itemData}
+          >
+            {Row}
+          </List>
+        )}
+      </AutoSizer>
     </Styled.Container>
   );
 };

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

@@ -18,7 +18,7 @@ type Props = {
   active: boolean;
 };
 
-type SongData = {
+export type SongData = {
   song: Song;
   active: boolean;
   parentActive: boolean;

+ 4 - 4
gmus-web/src/effects/effects.spec.ts

@@ -248,7 +248,7 @@ describe(globalEffects.name, () => {
   });
 
   describe(ActionTypeLocal.QueuePushed, () => {
-    const action = queuePushed(184);
+    const action = queuePushed([184, 79]);
 
     it('should add to the end of the queue', () => {
       expect.assertions(1);
@@ -264,18 +264,18 @@ describe(globalEffects.name, () => {
         payload: {
           ...initialState.player,
           master: 'some-master',
-          queue: [23, 184],
+          queue: [23, 184, 79],
         },
       });
     });
 
-    describe('when the song is already in the queue', () => {
+    describe('when the songs are already in the queue', () => {
       it('should not modify the queue', () => {
         expect.assertions(1);
         const result = globalEffects(
           {
             ...initialState,
-            player: { ...initialState.player, queue: [184, 23] },
+            player: { ...initialState.player, queue: [184, 23, 79] },
           },
           action,
         );

+ 16 - 10
gmus-web/src/effects/effects.ts

@@ -1,5 +1,6 @@
 import {
   ActionQueueOrdered,
+  ActionQueuePushed,
   ActionTypeLocal,
   ActionTypeRemote,
   LocalAction,
@@ -24,6 +25,20 @@ function reorderQueue(queue: number[], action: ActionQueueOrdered): number[] {
   return reverseInArray(queue, reverseIndex);
 }
 
+function pushToQueue(state: GlobalState, action: ActionQueuePushed): RemoteAction | null {
+  const nextQueue = Array.from(new Set([...state.player.queue, ...action.payload]));
+  if (!state.player.master || nextQueue.length === state.player.queue.length) {
+    return null;
+  }
+  return {
+    type: ActionTypeRemote.StateSet,
+    payload: {
+      ...state.player,
+      queue: nextQueue,
+    },
+  };
+}
+
 export function globalEffects(prevState: GlobalState, action: LocalAction): RemoteAction | null {
   switch (action.type) {
     case ActionTypeLocal.StateSet:
@@ -94,16 +109,7 @@ export function globalEffects(prevState: GlobalState, action: LocalAction): Remo
       };
 
     case ActionTypeLocal.QueuePushed:
-      if (!prevState.player.master || prevState.player.queue.includes(action.payload)) {
-        return null;
-      }
-      return {
-        type: ActionTypeRemote.StateSet,
-        payload: {
-          ...prevState.player,
-          queue: [...prevState.player.queue, action.payload],
-        },
-      };
+      return pushToQueue(prevState, action);
     case ActionTypeLocal.QueueShifted:
       if (!prevState.player.master) {
         return null;