Fela Maslen 5 лет назад
Родитель
Сommit
30bad0d2ff

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

@@ -8,6 +8,7 @@ export enum CmusUIActionType {
   ArtistSongsLoaded = '@@ui/cmus/ARTIST_SONGS_LOADED',
   ArtistSongsLoaded = '@@ui/cmus/ARTIST_SONGS_LOADED',
   CommandSet = '@@ui/cmus/COMMAND_SET',
   CommandSet = '@@ui/cmus/COMMAND_SET',
   ClientActivated = '@@ui/cmus/CLIENT_ACTIVATED',
   ClientActivated = '@@ui/cmus/CLIENT_ACTIVATED',
+  QueueInfoLoaded = '@@ui/cmus/QUEUE_INFO_LOADED',
 }
 }
 
 
 export type ArtistsSet = Action<CmusUIActionType.ArtistsSet, string[]>;
 export type ArtistsSet = Action<CmusUIActionType.ArtistsSet, string[]>;
@@ -57,10 +58,18 @@ export const clientActivated = (name: string | null): ClientActivated => ({
   payload: name,
   payload: name,
 });
 });
 
 
+export type QueueInfoLoaded = Action<CmusUIActionType.QueueInfoLoaded, Song[]>;
+
+export const queueInfoLoaded = (songs: Song[]): QueueInfoLoaded => ({
+  type: CmusUIActionType.QueueInfoLoaded,
+  payload: songs,
+});
+
 export type CmusUIAction =
 export type CmusUIAction =
   | ArtistsSet
   | ArtistsSet
   | ArtistAlbumsLoaded
   | ArtistAlbumsLoaded
   | ArtistSongsLoaded
   | ArtistSongsLoaded
   | CommandSet
   | CommandSet
   | ClientActivated
   | ClientActivated
+  | QueueInfoLoaded
   | ActionKeyPressed;
   | ActionKeyPressed;

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

@@ -1,3 +1,4 @@
+import { Song } from '../../../../types';
 import { CmusUIState, LibraryModeWindow, View } from '../types';
 import { CmusUIState, LibraryModeWindow, View } from '../types';
 import { initialCmusUIState } from './reducer';
 import { initialCmusUIState } from './reducer';
 
 
@@ -41,3 +42,12 @@ export const stateWithActiveSong: CmusUIState = {
     activeSongId: 1867,
     activeSongId: 1867,
   },
   },
 };
 };
+
+export const stateQueue: CmusUIState = {
+  ...initialCmusUIState,
+  view: View.Queue,
+  queue: {
+    info: [{ id: 887 } as Song, { id: 75 } as Song, { id: 189 } as Song],
+    active: null,
+  },
+};

+ 78 - 2
gmus-web/src/components/ui/cmus/reducer/keypress.spec.ts

@@ -1,10 +1,16 @@
-import { masterSet, playPaused, queuePushed, stateSet } from '../../../../actions';
+import { masterSet, playPaused, queueOrdered, queuePushed, stateSet } from '../../../../actions';
 import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../../hooks/vim';
 import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../../hooks/vim';
 import { Song } from '../../../../types';
 import { Song } from '../../../../types';
 
 
 import { CmusUIState, LibraryModeWindow, Overlay, View } from '../types';
 import { CmusUIState, LibraryModeWindow, Overlay, View } from '../types';
 
 
-import { stateDifferentView, stateFromMode, stateLibrary, stateWithActiveSong } from './fixtures';
+import {
+  stateDifferentView,
+  stateFromMode,
+  stateLibrary,
+  stateQueue,
+  stateWithActiveSong,
+} from './fixtures';
 import { cmusUIReducer, initialCmusUIState } from './reducer';
 import { cmusUIReducer, initialCmusUIState } from './reducer';
 
 
 describe(ActionTypeKeyPressed, () => {
 describe(ActionTypeKeyPressed, () => {
@@ -12,6 +18,7 @@ describe(ActionTypeKeyPressed, () => {
     key          | toView
     key          | toView
     ${Keys['1']} | ${View.Library}
     ${Keys['1']} | ${View.Library}
     ${Keys['2']} | ${View.ClientList}
     ${Keys['2']} | ${View.ClientList}
+    ${Keys['3']} | ${View.Queue}
   `('$key', ({ key, toView }) => {
   `('$key', ({ key, toView }) => {
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key };
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key };
 
 
@@ -186,6 +193,14 @@ describe(ActionTypeKeyPressed, () => {
       });
       });
     });
     });
 
 
+    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', () => {
     describe('when in a different view', () => {
       it('should set the scroll delta and increment the serial number', () => {
       it('should set the scroll delta and increment the serial number', () => {
         expect.assertions(1);
         expect.assertions(1);
@@ -303,6 +318,17 @@ describe(ActionTypeKeyPressed, () => {
       });
       });
     });
     });
 
 
+    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', () => {
     describe('when in a different view', () => {
       it('should set the scroll delta and increment the serial number', () => {
       it('should set the scroll delta and increment the serial number', () => {
         expect.assertions(1);
         expect.assertions(1);
@@ -312,6 +338,36 @@ describe(ActionTypeKeyPressed, () => {
     });
     });
   });
   });
 
 
+  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, () => {
   describe(Keys.Z, () => {
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.Z };
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.Z };
 
 
@@ -579,6 +635,26 @@ describe(ActionTypeKeyPressed, () => {
         expect(result.globalAction).toStrictEqual(masterSet('some-active-client'));
         expect(result.globalAction).toStrictEqual(masterSet('some-active-client'));
       });
       });
     });
     });
+
+    describe('when on the queue view', () => {
+      it('should set the globalAction to play the active song', () => {
+        expect.assertions(1);
+
+        const result = cmusUIReducer(
+          { ...stateQueue, queue: { ...stateQueue.queue, active: 75 } },
+          action,
+        );
+
+        expect(result.globalAction).toStrictEqual(
+          stateSet({
+            playing: true,
+            songId: 75,
+            currentTime: 0,
+            seekTime: 0,
+          }),
+        );
+      });
+    });
   });
   });
 
 
   describe(Keys.esc, () => {
   describe(Keys.esc, () => {

+ 26 - 9
gmus-web/src/components/ui/cmus/reducer/keypress.ts

@@ -1,6 +1,7 @@
 import { masterSet, playPaused, queuePushed, stateSet } from '../../../../actions';
 import { masterSet, playPaused, queuePushed, stateSet } from '../../../../actions';
 import { ActionKeyPressed, Keys } from '../../../../hooks/vim';
 import { ActionKeyPressed, Keys } from '../../../../hooks/vim';
 import { CmusUIState, LibraryModeWindow, Overlay, View } from '../types';
 import { CmusUIState, LibraryModeWindow, Overlay, View } from '../types';
+import { handleOrder } from './order';
 import { handleScroll } from './scroll';
 import { handleScroll } from './scroll';
 import { withGlobalAction } from './utils';
 import { withGlobalAction } from './utils';
 
 
@@ -33,6 +34,17 @@ function toggleExpandArtist(library: CmusUIState['library']): CmusUIState['libra
   return { ...library, expandedArtists: [...library.expandedArtists, library.activeArtist] };
   return { ...library, expandedArtists: [...library.expandedArtists, library.activeArtist] };
 }
 }
 
 
+const activateSong = (state: CmusUIState, songId: number): CmusUIState =>
+  withGlobalAction(
+    state,
+    stateSet({
+      playing: true,
+      songId,
+      currentTime: 0,
+      seekTime: 0,
+    }),
+  );
+
 function handleActivate(state: CmusUIState): CmusUIState {
 function handleActivate(state: CmusUIState): CmusUIState {
   switch (state.view) {
   switch (state.view) {
     case View.Library:
     case View.Library:
@@ -41,18 +53,16 @@ function handleActivate(state: CmusUIState): CmusUIState {
           return state;
           return state;
         }
         }
 
 
-        return withGlobalAction(
-          state,
-          stateSet({
-            playing: true,
-            songId: state.library.activeSongId,
-            currentTime: 0,
-            seekTime: 0,
-          }),
-        );
+        return activateSong(state, state.library.activeSongId);
       }
       }
       return state;
       return state;
 
 
+    case View.Queue:
+      if (!state.queue.active) {
+        return state;
+      }
+      return activateSong(state, state.queue.active);
+
     case View.ClientList:
     case View.ClientList:
       if (!state.clientList.active) {
       if (!state.clientList.active) {
         return state;
         return state;
@@ -73,6 +83,8 @@ export function handleKeyPress(state: CmusUIState, action: ActionKeyPressed): Cm
       return { ...state, view: View.Library };
       return { ...state, view: View.Library };
     case Keys['2']:
     case Keys['2']:
       return { ...state, view: View.ClientList };
       return { ...state, view: View.ClientList };
+    case Keys['3']:
+      return { ...state, view: View.Queue };
 
 
     case Keys.tab:
     case Keys.tab:
       if (state.view === View.Library) {
       if (state.view === View.Library) {
@@ -121,6 +133,11 @@ export function handleKeyPress(state: CmusUIState, action: ActionKeyPressed): Cm
     case Keys.K:
     case Keys.K:
       return handleScroll(state, -1);
       return handleScroll(state, -1);
 
 
+    case Keys.p:
+      return handleOrder(state, 1);
+    case Keys.P:
+      return handleOrder(state, -1);
+
     case Keys.pageDown:
     case Keys.pageDown:
       return handleScroll(state, 20);
       return handleScroll(state, 20);
     case Keys.pageUp:
     case Keys.pageUp:

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

@@ -0,0 +1,16 @@
+import { queueOrdered } from '../../../../actions';
+import { CmusUIState, View } from '../types';
+import { withGlobalAction } from './utils';
+
+export function handleOrder(state: CmusUIState, delta: -1 | 1): CmusUIState {
+  switch (state.view) {
+    case View.Queue:
+      if (!state.queue.active) {
+        return state;
+      }
+      return withGlobalAction(state, queueOrdered(state.queue.active, delta));
+
+    default:
+      return state;
+  }
+}

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

@@ -1,4 +1,5 @@
-import { clientActivated, CmusUIActionType } from '../actions';
+import { Song } from '../../../../types';
+import { clientActivated, CmusUIActionType, queueInfoLoaded } from '../actions';
 import { cmusUIReducer, initialCmusUIState } from './reducer';
 import { cmusUIReducer, initialCmusUIState } from './reducer';
 
 
 describe(CmusUIActionType.ClientActivated, () => {
 describe(CmusUIActionType.ClientActivated, () => {
@@ -10,3 +11,13 @@ describe(CmusUIActionType.ClientActivated, () => {
     expect(result.clientList.active).toBe('some-client');
     expect(result.clientList.active).toBe('some-client');
   });
   });
 });
 });
+
+describe(CmusUIActionType.QueueInfoLoaded, () => {
+  const action = queueInfoLoaded([{ id: 176 } as Song]);
+
+  it('should set the queue info', () => {
+    expect.assertions(1);
+    const result = cmusUIReducer(initialCmusUIState, action);
+    expect(result.queue.info).toStrictEqual([{ id: 176 }]);
+  });
+});

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

@@ -31,6 +31,10 @@ export const initialCmusUIState: CmusUIState = {
   clientList: {
   clientList: {
     active: null,
     active: null,
   },
   },
+  queue: {
+    info: [],
+    active: null,
+  },
 };
 };
 
 
 export const CmusUIStateContext = createContext<CmusUIState>(initialCmusUIState);
 export const CmusUIStateContext = createContext<CmusUIState>(initialCmusUIState);
@@ -54,6 +58,17 @@ export function cmusUIReducer(state: CmusUIState, action: CmusUIAction): CmusUIS
     case CmusUIActionType.ClientActivated:
     case CmusUIActionType.ClientActivated:
       return { ...state, clientList: { active: action.payload } };
       return { ...state, clientList: { active: action.payload } };
 
 
+    case CmusUIActionType.QueueInfoLoaded:
+      return {
+        ...state,
+        queue: {
+          info: action.payload,
+          active: action.payload.some(({ id }) => id === state.queue.active)
+            ? state.queue.active
+            : action.payload[0]?.id ?? null,
+        },
+      };
+
     default:
     default:
       return state;
       return state;
   }
   }

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

@@ -65,10 +65,25 @@ function handleScrollLibrary(state: CmusUIState, delta: number): CmusUIState {
   }
   }
 }
 }
 
 
+function handleScrollQueue(state: CmusUIState, delta: number): CmusUIState {
+  const scrolledItem =
+    scrollThroughItems(state.queue.info, (compare) => compare.id === state.queue.active, delta) ??
+    state.queue.info[0];
+  return {
+    ...state,
+    queue: {
+      ...state.queue,
+      active: scrolledItem?.id ?? null,
+    },
+  };
+}
+
 export function handleScroll(state: CmusUIState, delta: number): CmusUIState {
 export function handleScroll(state: CmusUIState, delta: number): CmusUIState {
   switch (state.view) {
   switch (state.view) {
     case View.Library:
     case View.Library:
       return handleScrollLibrary(state, delta);
       return handleScrollLibrary(state, delta);
+    case View.Queue:
+      return handleScrollQueue(state, delta);
     default:
     default:
       return {
       return {
         ...state,
         ...state,

+ 5 - 0
gmus-web/src/components/ui/cmus/types.ts

@@ -4,6 +4,7 @@ import { Song } from '../../../types/songs';
 export enum View {
 export enum View {
   Library = 'Library',
   Library = 'Library',
   ClientList = 'Client list',
   ClientList = 'Client list',
+  Queue = 'Queue',
 }
 }
 
 
 export enum Overlay {
 export enum Overlay {
@@ -43,4 +44,8 @@ export type CmusUIState = {
   clientList: {
   clientList: {
     active: string | null;
     active: string | null;
   };
   };
+  queue: {
+    info: Song[];
+    active: number | null;
+  };
 };
 };

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

@@ -16,6 +16,7 @@ const commandsGeneral: Command[] = [
   { command: '<PageUp>', description: 'select pervious page of list items' },
   { command: '<PageUp>', description: 'select pervious page of list items' },
   { command: '1', description: 'show library view' },
   { command: '1', description: 'show library view' },
   { command: '2', description: 'show client list' },
   { command: '2', description: 'show client list' },
+  { command: '3', description: 'show queue' },
   { command: ':q', description: 'log out' },
   { command: ':q', description: 'log out' },
   { command: '<Esc>', description: 'close this dialog' },
   { command: '<Esc>', description: 'close this dialog' },
 ];
 ];
@@ -30,6 +31,13 @@ const commandsClientList: Command[] = [
   { command: '<Enter>', description: 'set the selected client to master' },
   { command: '<Enter>', description: 'set the selected client to master' },
 ];
 ];
 
 
+const commandsQueue: Command[] = [
+  { command: 'd', description: 'remove the selected song from the queue' },
+  { command: 'P', description: 'move the selected song up the queue' },
+  { command: 'p', description: 'move the selected song down the queue' },
+  { command: '<Enter>', description: 'play the selected song' },
+];
+
 type CommandGroup = {
 type CommandGroup = {
   title: string;
   title: string;
   commands: Command[];
   commands: Command[];
@@ -39,6 +47,7 @@ const commandGroups: CommandGroup[] = [
   { title: 'General', commands: commandsGeneral },
   { title: 'General', commands: commandsGeneral },
   { title: 'Library view', commands: commandsLibrary },
   { title: 'Library view', commands: commandsLibrary },
   { title: 'Client list', commands: commandsClientList },
   { title: 'Client list', commands: commandsClientList },
+  { title: 'Queue', commands: commandsQueue },
 ];
 ];
 
 
 export const HelpDialog: React.FC = () => (
 export const HelpDialog: React.FC = () => (

+ 28 - 0
gmus-web/src/components/ui/cmus/views/queue.styles.ts

@@ -0,0 +1,28 @@
+import { rem } from 'polished';
+import styled from 'styled-components';
+
+import { ActiveHighlightRow, FlexColumn } from '../styled/layout';
+
+export const Container = styled(FlexColumn)`
+  width: 100%;
+`;
+
+export const QueueSong = styled(ActiveHighlightRow)`
+  width: 100%;
+`;
+
+export const Track = styled.span`
+  flex: 0 0 ${rem(64)};
+`;
+
+export const Title = styled.span`
+  flex: 1;
+`;
+
+export const Artist = styled.span`
+  flex: 1;
+`;
+
+export const Album = styled.span`
+  flex: 1;
+`;

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

@@ -0,0 +1,70 @@
+import { AxiosInstance, AxiosResponse } from 'axios';
+import React, { useCallback, useContext, useEffect, useMemo } from 'react';
+
+import { StateContext } from '../../../../context/state';
+import { useRequestCallback } from '../../../../hooks/request';
+import { Song } from '../../../../types';
+import { getApiUrl } from '../../../../utils/url';
+import { queueInfoLoaded } from '../actions';
+import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
+
+import * as Styled from './queue.styles';
+
+export type Props = {
+  currentSong: Song | null;
+};
+
+export const ViewQueue: React.FC<Props> = ({ currentSong }) => {
+  const {
+    player: { queue },
+  } = useContext(StateContext);
+
+  const dispatchUI = useContext(CmusUIDispatchContext);
+  const {
+    queue: { active },
+  } = useContext(CmusUIStateContext);
+
+  const sendRequest = useCallback(
+    (axios: AxiosInstance, query: number[]): Promise<AxiosResponse<Song[]>> =>
+      axios.get(`${getApiUrl()}/multi-song-info?${query.map((id) => `ids=${id}`).join('&')}`),
+    [],
+  );
+
+  const [fetchQueueInfo, queueInfo] = useRequestCallback<number[], Song[]>({
+    sendRequest,
+  });
+
+  useEffect(() => {
+    fetchQueueInfo(queue);
+  }, [fetchQueueInfo, queue]);
+
+  const orderedSongInfo = useMemo<Song[]>(
+    () =>
+      queue
+        .map((id) => queueInfo?.find((compare) => compare.id === id))
+        .filter((info: Song | undefined): info is Song => !!info),
+    [queueInfo, queue],
+  );
+
+  useEffect(() => {
+    dispatchUI(queueInfoLoaded(orderedSongInfo));
+  }, [dispatchUI, orderedSongInfo]);
+
+  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>
+      ))}
+    </Styled.Container>
+  );
+};

+ 2 - 0
gmus-web/src/components/ui/cmus/wrapper.tsx

@@ -19,6 +19,7 @@ import { ViewClientList } from './views/clients';
 import { CommandView } from './views/command';
 import { CommandView } from './views/command';
 import { HelpDialog } from './views/help';
 import { HelpDialog } from './views/help';
 import { ViewLibrary } from './views/library';
 import { ViewLibrary } from './views/library';
+import { ViewQueue } from './views/queue';
 import { PlayerStatus } from './views/status';
 import { PlayerStatus } from './views/status';
 
 
 import * as Styled from './wrapper.styles';
 import * as Styled from './wrapper.styles';
@@ -70,6 +71,7 @@ export const CmusUIProvider: UIProviderComponent = ({ currentSong, nextSong, pre
             {(stateUI.view === View.Library || stateUI.view === View.ClientList) && (
             {(stateUI.view === View.Library || stateUI.view === View.ClientList) && (
               <ViewLibrary currentArtist={currentSong?.artist ?? null} />
               <ViewLibrary currentArtist={currentSong?.artist ?? null} />
             )}
             )}
+            {stateUI.view === View.Queue && <ViewQueue currentSong={currentSong} />}
           </Styled.View>
           </Styled.View>
           {showOverlay && (
           {showOverlay && (
             <Styled.Overlay>
             <Styled.Overlay>

+ 2 - 0
gmus-web/src/hooks/master.spec.tsx

@@ -80,6 +80,7 @@ describe(useMaster.name, () => {
         currentTime: 17,
         currentTime: 17,
         seekTime: -1,
         seekTime: -1,
         master: 'master-client-a',
         master: 'master-client-a',
+        queue: [],
       },
       },
     };
     };
 
 
@@ -226,3 +227,4 @@ describe(useMaster.name, () => {
     });
     });
   });
   });
 });
 });
+

+ 3 - 0
gmus-web/src/hooks/vim.ts

@@ -12,11 +12,14 @@ export const Keys = {
   pageUp: 'PageUp',
   pageUp: 'PageUp',
   '1': '1',
   '1': '1',
   '2': '2',
   '2': '2',
+  '3': '3',
   B: 'b',
   B: 'b',
   C: 'c',
   C: 'c',
   E: 'e',
   E: 'e',
   J: 'j',
   J: 'j',
   K: 'k',
   K: 'k',
+  P: 'P',
+  p: 'p',
   Z: 'z',
   Z: 'z',
 };
 };