Procházet zdrojové kódy

feat: state and actions to handle play queue

Fela Maslen před 5 roky
rodič
revize
217c26122c

+ 1 - 0
gmus-backend/pkg/server/types.go

@@ -39,4 +39,5 @@ type MusicPlayer struct {
 	CurrentTime float32 `json:"currentTime" validate:"gte=0"`
 	SeekTime    float32 `json:"seekTime" validate:"min=-1"`
 	Master      string  `json:"master" validate:"required"`
+	Queue       *[]int  `json:"queue" validate:"required"`
 }

+ 32 - 1
gmus-web/src/actions/actions.ts

@@ -62,6 +62,33 @@ 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 => ({
+  type: ActionTypeLocal.QueuePushed,
+  payload: songId,
+});
+
+export type ActionQueueShifted = ActionLocal<ActionTypeLocal.QueueShifted, void>;
+export const queueShifted = (): ActionQueueShifted => ({
+  type: ActionTypeLocal.QueueShifted,
+  payload: undefined,
+});
+
+export type ActionQueueRemoved = ActionLocal<ActionTypeLocal.QueueRemoved, number>;
+export const queueRemoved = (songId: number): ActionQueueRemoved => ({
+  type: ActionTypeLocal.QueueRemoved,
+  payload: songId,
+});
+
+export type ActionQueueOrdered = ActionLocal<
+  ActionTypeLocal.QueueOrdered,
+  { songId: number; delta: -1 | 1 }
+>;
+export const queueOrdered = (songId: number, delta: -1 | 1): ActionQueueOrdered => ({
+  type: ActionTypeLocal.QueueOrdered,
+  payload: { songId, delta },
+});
+
 export type LocalAction =
   | LoggedOut
   | ActionErrorOccurred
@@ -70,6 +97,10 @@ export type LocalAction =
   | ActionSeeked
   | ActionPlayPaused
   | ActionMasterSet
-  | ActionSongInfoFetched;
+  | ActionSongInfoFetched
+  | ActionQueuePushed
+  | ActionQueueShifted
+  | ActionQueueRemoved
+  | ActionQueueOrdered;
 
 export type AnyAction = LocalAction | RemoteAction;

+ 4 - 0
gmus-web/src/actions/types.ts

@@ -14,6 +14,10 @@ export enum ActionTypeLocal {
   MasterSet = '@@local/MASTER_SET',
   PlayPaused = '@@local/PLAY_PAUSED',
   SongInfoFetched = '@@local/SONG_INFO_FETCHED',
+  QueuePushed = '@@local/QUEUE_PUSHED',
+  QueueShifted = '@@local/QUEUE_SHIFTED',
+  QueueRemoved = '@@local/QUEUE_REMOVED',
+  QueueOrdered = '@@local/QUEUE_ORDERED',
 }
 
 export interface Action<T extends string = string, P = unknown> {

+ 4 - 23
gmus-web/src/components/app.tsx

@@ -38,26 +38,7 @@ export const App: React.FC<Props> = ({ socket, interacted, setInteracted }) => {
     [dispatch],
   );
 
-  const { onNext, onPrev, loading: loadingQueue } = usePlayQueue();
-  const onEnded = useCallback(() => {
-    if (state.player.songId) {
-      onNext(state.player.songId);
-    }
-  }, [onNext, state.player.songId]);
-
-  const nextSong = useCallback(() => {
-    if (loadingQueue || !state.player.songId) {
-      return;
-    }
-    onNext(state.player.songId);
-  }, [loadingQueue, onNext, state.player.songId]);
-
-  const prevSong = useCallback(() => {
-    if (loadingQueue || !state.player.songId) {
-      return;
-    }
-    onPrev(state.player.songId);
-  }, [loadingQueue, onPrev, state.player.songId]);
+  const { onNext, onPrev } = usePlayQueue();
 
   return (
     <>
@@ -68,7 +49,7 @@ export const App: React.FC<Props> = ({ socket, interacted, setInteracted }) => {
           seekTime={state.player.seekTime}
           onTimeUpdate={onTimeUpdate}
           timeUpdateFPS={1}
-          onEnded={onEnded}
+          onEnded={onNext}
         />
       )}
       <StateInspector name="ui">
@@ -78,8 +59,8 @@ export const App: React.FC<Props> = ({ socket, interacted, setInteracted }) => {
             <UI
               isMaster={isMaster(state)}
               currentSong={state.songInfo}
-              nextSong={nextSong}
-              prevSong={prevSong}
+              nextSong={onNext}
+              prevSong={onPrev}
             />
           </Suspense>
         )}

+ 84 - 10
gmus-web/src/effects/effects.spec.ts

@@ -4,11 +4,16 @@ import {
   ActionTypeRemote,
   masterSet,
   playPaused,
+  queueOrdered,
+  queuePushed,
+  queueRemoved,
+  queueShifted,
+  RemoteAction,
   seeked,
   songInfoFetched,
   stateSet,
 } from '../actions';
-import { GlobalState, initialState } from '../reducer';
+import { globalReducer, GlobalState, initialState } from '../reducer';
 import { Song } from '../types';
 import { MusicPlayer } from '../types/state';
 import { globalEffects } from './effects';
@@ -24,6 +29,7 @@ describe(globalEffects.name, () => {
         currentTime: 83,
         seekTime: 87,
         master: 'my-client',
+        queue: [],
       };
 
       const prevState: GlobalState = {
@@ -31,7 +37,10 @@ describe(globalEffects.name, () => {
         myClientName: 'my-client-name',
       };
 
-      const result = globalEffects(prevState, stateSet(localPlayer));
+      const action = stateSet(localPlayer);
+      const nextState = globalReducer(prevState, action);
+
+      const result = globalEffects(prevState, action, nextState);
 
       expect(result).toStrictEqual<ActionStateSetRemote>({
         type: ActionTypeRemote.StateSet,
@@ -49,6 +58,7 @@ describe(globalEffects.name, () => {
         currentTime: 83,
         seekTime: 87,
         master: 'my-client-name',
+        queue: [],
       },
       myClientName: 'my-client-name',
     };
@@ -59,6 +69,8 @@ describe(globalEffects.name, () => {
       myClientName: 'some-slave-client',
     };
 
+    const action = seeked(776);
+
     describe.each`
       clientType   | state
       ${'master'}  | ${stateMaster}
@@ -67,7 +79,7 @@ describe(globalEffects.name, () => {
       it('should create a remote state set action', () => {
         expect.assertions(1);
 
-        const result = globalEffects(state, seeked(776));
+        const result = globalEffects(state, action, globalReducer(state, action));
 
         expect(result).toStrictEqual<ActionStateSetRemote>({
           type: ActionTypeRemote.StateSet,
@@ -87,13 +99,20 @@ describe(globalEffects.name, () => {
         currentTime: 83,
         seekTime: 5,
         master: 'some-master-went-away',
+        queue: [],
       },
       myClientName: 'my-client-name',
     };
 
+    const action = masterSet();
+
     it('should return a StateSet action informing other clients that we are the new master', () => {
       expect.assertions(1);
-      const result = globalEffects(stateMasterWentAway, masterSet());
+      const result = globalEffects(
+        stateMasterWentAway,
+        action,
+        globalReducer(stateMasterWentAway, action),
+      );
 
       expect(result).toStrictEqual<ActionStateSetRemote>({
         type: ActionTypeRemote.StateSet,
@@ -103,6 +122,7 @@ describe(globalEffects.name, () => {
           currentTime: 83,
           seekTime: -1,
           master: 'my-client-name',
+          queue: [],
         },
       });
     });
@@ -110,7 +130,11 @@ describe(globalEffects.name, () => {
     describe('when the action specified a particular client', () => {
       it('should return a StateSet action informing the new client to resume playback', () => {
         expect.assertions(1);
-        const result = globalEffects(stateMasterWentAway, masterSet('other-client'));
+        const result = globalEffects(
+          stateMasterWentAway,
+          masterSet('other-client'),
+          globalReducer(stateMasterWentAway, masterSet('other-client')),
+        );
 
         expect(result).toStrictEqual<ActionStateSetRemote>({
           type: ActionTypeRemote.StateSet,
@@ -120,6 +144,7 @@ describe(globalEffects.name, () => {
             currentTime: 83,
             seekTime: 83,
             master: 'other-client',
+            queue: [],
           },
         });
       });
@@ -135,14 +160,19 @@ describe(globalEffects.name, () => {
         currentTime: 83,
         seekTime: 5,
         master: 'some-master-client',
+        queue: [],
       },
       myClientName: 'some-master-client',
     };
 
+    const action = playPaused();
+
     describe('when the client is master', () => {
       it('should return null', () => {
         expect.assertions(1);
-        expect(globalEffects(statePriorMaster, playPaused())).toBeNull();
+        expect(
+          globalEffects(statePriorMaster, action, globalReducer(statePriorMaster, action)),
+        ).toBeNull();
       });
     });
 
@@ -154,7 +184,7 @@ describe(globalEffects.name, () => {
 
       it('should return a StateSet action informing other clients of the updated playing state', () => {
         expect.assertions(1);
-        const result = globalEffects(stateSlave, playPaused());
+        const result = globalEffects(stateSlave, action, globalReducer(stateSlave, action));
 
         expect(result).toStrictEqual<ActionStateSetRemote>({
           type: ActionTypeRemote.StateSet,
@@ -164,6 +194,7 @@ describe(globalEffects.name, () => {
             currentTime: 83,
             seekTime: 5,
             master: 'some-master-client',
+            queue: [],
           },
         });
       });
@@ -179,6 +210,7 @@ describe(globalEffects.name, () => {
         currentTime: 83,
         seekTime: 5,
         master: 'some-master-client',
+        queue: [],
       },
       myClientName: 'some-master-client',
     };
@@ -188,7 +220,9 @@ describe(globalEffects.name, () => {
     describe('when the client is master', () => {
       it('should return null', () => {
         expect.assertions(1);
-        expect(globalEffects(statePriorMaster, playPaused())).toBeNull();
+        expect(
+          globalEffects(statePriorMaster, action, globalReducer(statePriorMaster, action)),
+        ).toBeNull();
       });
     });
 
@@ -200,7 +234,7 @@ describe(globalEffects.name, () => {
 
       it('should return a StateSet action informing other clients of the changed song', () => {
         expect.assertions(1);
-        const result = globalEffects(stateSlave, action);
+        const result = globalEffects(stateSlave, action, globalReducer(stateSlave, action));
 
         expect(result).toStrictEqual<ActionStateSetRemote>({
           type: ActionTypeRemote.StateSet,
@@ -210,6 +244,7 @@ describe(globalEffects.name, () => {
             currentTime: 0,
             seekTime: 0,
             master: 'some-master-client',
+            queue: [],
           },
         });
       });
@@ -219,10 +254,49 @@ describe(globalEffects.name, () => {
 
         it('should return null', () => {
           expect.assertions(1);
-          const result = globalEffects(stateSlave, actionNoReplace);
+          const result = globalEffects(
+            stateSlave,
+            actionNoReplace,
+            globalReducer(stateSlave, actionNoReplace),
+          );
           expect(result).toBeNull();
         });
       });
     });
   });
+
+  describe.each`
+    description                     | action
+    ${ActionTypeLocal.QueuePushed}  | ${queuePushed(123)}
+    ${ActionTypeLocal.QueueShifted} | ${queueShifted()}
+    ${ActionTypeLocal.QueueRemoved} | ${queueRemoved(84)}
+    ${ActionTypeLocal.QueueOrdered} | ${queueOrdered(17, -1)}
+  `('$description', ({ action }) => {
+    const statePrior: GlobalState = {
+      ...initialState,
+      player: {
+        songId: 123,
+        playing: true,
+        currentTime: 83,
+        seekTime: 5,
+        master: 'some-master-client',
+        queue: [13, 84, 17],
+      },
+      myClientName: 'some-other-client',
+    };
+
+    const nextState = globalReducer(statePrior, action);
+
+    it('should issue a state set action to update the queue', () => {
+      expect.assertions(1);
+      const result = globalEffects(statePrior, action, nextState);
+      expect(result).toStrictEqual<RemoteAction>({
+        type: ActionTypeRemote.StateSet,
+        payload: {
+          ...statePrior.player,
+          queue: nextState.player.queue,
+        },
+      });
+    });
+  });
 });

+ 21 - 2
gmus-web/src/effects/effects.ts

@@ -2,15 +2,25 @@ import { ActionTypeLocal, ActionTypeRemote, LocalAction, RemoteAction } from '..
 import { GlobalState } from '../reducer/types';
 import { isMaster } from '../selectors';
 
-export function globalEffects(prevState: GlobalState, action: LocalAction): RemoteAction | null {
+export function globalEffects(
+  prevState: GlobalState,
+  action: LocalAction,
+  nextState: GlobalState,
+): RemoteAction | null {
   switch (action.type) {
     case ActionTypeLocal.StateSet:
+      if (!prevState.player.master && !action.payload.master) {
+        return null;
+      }
       return {
         type: ActionTypeRemote.StateSet,
         payload: { ...prevState.player, ...action.payload },
       };
 
     case ActionTypeLocal.Seeked:
+      if (!prevState.player.master) {
+        return null;
+      }
       return {
         type: ActionTypeRemote.StateSet,
         payload: { ...prevState.player, seekTime: action.payload },
@@ -51,7 +61,7 @@ export function globalEffects(prevState: GlobalState, action: LocalAction): Remo
       };
 
     case ActionTypeLocal.SongInfoFetched:
-      if (isMaster(prevState) || !action.payload.replace) {
+      if (isMaster(prevState) || !action.payload.replace || !prevState.player.master) {
         return null;
       }
       return {
@@ -65,6 +75,15 @@ export function globalEffects(prevState: GlobalState, action: LocalAction): Remo
         },
       };
 
+    case ActionTypeLocal.QueuePushed:
+    case ActionTypeLocal.QueueRemoved:
+    case ActionTypeLocal.QueueShifted:
+    case ActionTypeLocal.QueueOrdered:
+      return {
+        type: ActionTypeRemote.StateSet,
+        payload: { ...prevState.player, queue: nextState.player.queue },
+      };
+
     default:
       return null;
   }

+ 26 - 5
gmus-web/src/hooks/queue.ts

@@ -1,8 +1,8 @@
 import { useThrottleCallback } from '@react-hook/throttle';
 import { AxiosInstance, AxiosResponse } from 'axios';
 import { Dispatch, useCallback, useContext, useEffect } from 'react';
-import { LocalAction, songInfoFetched, stateSet } from '../actions';
-import { DispatchContext } from '../context/state';
+import { LocalAction, queueShifted, songInfoFetched, stateSet } from '../actions';
+import { DispatchContext, StateContext } from '../context/state';
 import { NullSong, Song, songExists } from '../types';
 import { getApiUrl } from '../utils/url';
 
@@ -38,14 +38,35 @@ function useNextOrPrevSong(
 }
 
 export function usePlayQueue(): {
-  onNext: (songId: number) => void;
-  onPrev: (songId: number) => void;
+  onNext: () => void;
+  onPrev: () => void;
   loading: boolean;
 } {
   const dispatch = useContext(DispatchContext);
+  const {
+    player: { queue, songId },
+  } = useContext(StateContext);
 
   const [onRequestNext, loadingNext] = useNextOrPrevSong('next', dispatch);
   const [onRequestPrev, loadingPrev] = useNextOrPrevSong('prev', dispatch);
 
-  return { onNext: onRequestNext, onPrev: onRequestPrev, loading: loadingNext || loadingPrev };
+  const loading = loadingNext || loadingPrev;
+
+  const firstQueuedSongId = queue[0];
+  const onNext = useCallback(() => {
+    if (firstQueuedSongId) {
+      dispatch(stateSet({ songId: firstQueuedSongId }));
+      dispatch(queueShifted());
+    } else if (!loading && songId) {
+      onRequestNext(songId);
+    }
+  }, [dispatch, firstQueuedSongId, songId, loading, onRequestNext]);
+
+  const onPrev = useCallback(() => {
+    if (!loading && songId) {
+      onRequestPrev(songId);
+    }
+  }, [songId, loading, onRequestPrev]);
+
+  return { onNext, onPrev, loading };
 }

+ 1 - 1
gmus-web/src/hooks/socket.spec.tsx

@@ -137,7 +137,7 @@ describe(useDispatchWithEffects.name, () => {
         });
 
         expect(globalEffectsSpy).toHaveBeenCalledTimes(1);
-        expect(globalEffectsSpy).toHaveBeenCalledWith(state, someAction);
+        expect(globalEffectsSpy).toHaveBeenCalledWith(state, someAction, state);
 
         expect(socket.send).toHaveBeenCalledTimes(1);
         expect(socket.send).toHaveBeenCalledWith(JSON.stringify(someEffect));

+ 8 - 4
gmus-web/src/hooks/socket.ts

@@ -33,28 +33,32 @@ export function useDispatchWithEffects(
   socket: WebSocket | null,
 ): Dispatch<LocalAction> {
   const [lastAction, setLastAction] = useState<LocalAction | null>(null);
+  const [lastState, setLastState] = useState<GlobalState>(state);
 
   const dispatchWithEffects = useCallback(
     (action: LocalAction): void => {
       if (action.type === ActionTypeLocal.LoggedOut) {
         socket?.close();
       } else {
-        setLastAction(action);
         dispatch(action);
+        setLastAction(action);
       }
     },
     [dispatch, socket],
   );
 
+  const actionSerial = useRef<LocalAction | null>(null);
   useEffect(() => {
-    if (lastAction) {
-      const effect = globalEffects(state, lastAction);
+    if (lastAction && lastAction !== actionSerial.current) {
+      actionSerial.current = lastAction;
+      const effect = globalEffects(lastState, lastAction, state);
       setLastAction(null);
       if (effect && socket && socket.readyState === socket.OPEN) {
         socket.send(JSON.stringify(effect));
       }
     }
-  }, [state, lastAction, socket]);
+    setLastState(state);
+  }, [state, lastState, lastAction, socket]);
 
   return dispatchWithEffects;
 }

+ 1 - 1
gmus-web/src/hooks/status.spec.tsx

@@ -105,8 +105,8 @@ describe(useCurrentlyPlayingSongInfo.name, () => {
           .reply(200, testSong, { 'Access-Control-Allow-Origin': '*' });
       });
 
+      // eslint-disable-next-line jest/prefer-expect-assertions
       it('should fetch the info for the updated song ID, and update the state', async () => {
-        expect.assertions(3);
         setup(stateStale);
         await waitFor(() => {
           expect(dispatch).toHaveBeenCalledTimes(1);

+ 92 - 3
gmus-web/src/reducer/reducer.spec.ts

@@ -7,6 +7,10 @@ import {
   masterSet,
   nameSet,
   playPaused,
+  queuePushed,
+  queueShifted,
+  queueRemoved,
+  queueOrdered,
   seeked,
   songInfoFetched,
   stateSet,
@@ -29,6 +33,7 @@ describe(globalReducer.name, () => {
           currentTime: 0,
           seekTime: -1,
           master: 'some-master-client',
+          queue: [],
         },
       });
 
@@ -57,6 +62,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'some-master-client',
+            queue: [],
           },
         };
 
@@ -70,6 +76,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'some-master-client',
+            queue: [],
           });
         });
       });
@@ -84,6 +91,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'some-master-client',
+            queue: [],
           },
         };
 
@@ -97,6 +105,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: -1,
             master: 'some-master-client',
+            queue: [],
           });
         });
       });
@@ -111,6 +120,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'other-master-client',
+            queue: [],
           },
         };
 
@@ -124,6 +134,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: -1,
             master: 'other-master-client',
+            queue: [],
           });
         });
       });
@@ -153,6 +164,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'some-master-client',
+            queue: [],
           },
         };
 
@@ -160,12 +172,13 @@ describe(globalReducer.name, () => {
           expect.assertions(1);
           const result = globalReducer(stateSlave, action);
 
-          expect(result.player).toStrictEqual({
+          expect(result.player).toStrictEqual<MusicPlayer>({
             songId: 123,
             playing: true,
             currentTime: 75,
             seekTime: -1,
             master: 'some-master-client',
+            queue: [],
           });
         });
       });
@@ -180,6 +193,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'my-client',
+            queue: [],
           },
         };
 
@@ -187,12 +201,13 @@ describe(globalReducer.name, () => {
           expect.assertions(1);
           const result = globalReducer(stateSlave, actionToMaster);
 
-          expect(result.player).toStrictEqual({
+          expect(result.player).toStrictEqual<MusicPlayer>({
             songId: 123,
             playing: true,
             currentTime: 75,
             seekTime: 87,
             master: 'my-client',
+            queue: [],
           });
         });
       });
@@ -261,6 +276,7 @@ describe(globalReducer.name, () => {
           currentTime: 75,
           seekTime: 87,
           master: 'some-master-client',
+          queue: [],
         });
       });
 
@@ -275,7 +291,7 @@ describe(globalReducer.name, () => {
 
           const result = globalReducer(stateMaster, actionPartial);
 
-          expect(result.player).toStrictEqual({
+          expect(result.player).toStrictEqual<MusicPlayer>({
             ...nullPlayer,
             master: 'some-master-client',
             currentTime: 31,
@@ -331,6 +347,7 @@ describe(globalReducer.name, () => {
           currentTime: 31,
           seekTime: -1,
           master: 'some-master-client',
+          queue: [],
         },
         myClientName: 'some-master-client',
       };
@@ -345,6 +362,7 @@ describe(globalReducer.name, () => {
           currentTime: 31,
           seekTime: 173,
           master: 'some-master-client',
+          queue: [],
         });
       });
     });
@@ -381,6 +399,7 @@ describe(globalReducer.name, () => {
         master: 'some-master-client',
         currentTime: 13,
         seekTime: -1,
+        queue: [],
       },
     };
 
@@ -531,4 +550,74 @@ describe(globalReducer.name, () => {
       });
     });
   });
+
+  describe(ActionTypeLocal.QueuePushed, () => {
+    const action = queuePushed(184);
+
+    it('should add to the beginning of the queue', () => {
+      expect.assertions(1);
+      const result = globalReducer(
+        {
+          ...initialState,
+          player: { ...initialState.player, queue: [23] },
+        },
+        action,
+      );
+      expect(result.player.queue).toStrictEqual([184, 23]);
+    });
+  });
+
+  describe(ActionTypeLocal.QueueShifted, () => {
+    const action = queueShifted();
+
+    it('should remove from the beginning of the queue', () => {
+      expect.assertions(1);
+      const result = globalReducer(
+        {
+          ...initialState,
+          player: { ...initialState.player, queue: [8843, 23] },
+        },
+        action,
+      );
+      expect(result.player.queue).toStrictEqual([23]);
+    });
+  });
+
+  describe(ActionTypeLocal.QueueRemoved, () => {
+    const action = queueRemoved(84);
+
+    it('should remove the given song ID from the queue', () => {
+      expect.assertions(1);
+      const result = globalReducer(
+        {
+          ...initialState,
+          player: { ...initialState.player, queue: [17, 84, 23] },
+        },
+        action,
+      );
+
+      expect(result.player.queue).toStrictEqual([17, 23]);
+    });
+  });
+
+  describe(ActionTypeLocal.QueueOrdered, () => {
+    it.each`
+      direction      | delta | expectedResult
+      ${'forwards'}  | ${1}  | ${[17, 23, 84]}
+      ${'backwards'} | ${-1} | ${[84, 17, 23]}
+    `('should reorder ($direction) the given song ID', ({ delta, expectedResult }) => {
+      const action = queueOrdered(84, delta);
+
+      expect.assertions(1);
+      const result = globalReducer(
+        {
+          ...initialState,
+          player: { ...initialState.player, queue: [17, 84, 23] },
+        },
+        action,
+      );
+
+      expect(result.player.queue).toStrictEqual(expectedResult);
+    });
+  });
 });

+ 40 - 0
gmus-web/src/reducer/reducer.ts

@@ -1,4 +1,5 @@
 import {
+  ActionQueueOrdered,
   ActionSongInfoFetched,
   ActionStateSetLocal,
   ActionStateSetRemote,
@@ -16,6 +17,7 @@ export const nullPlayer: MusicPlayer = {
   currentTime: 0,
   seekTime: -1,
   master: '',
+  queue: [],
 };
 
 export const initialState: GlobalState = {
@@ -75,6 +77,26 @@ function onSongFetched(state: GlobalState, action: ActionSongInfoFetched): Globa
   };
 }
 
+const reverseInArray = <T>(array: T[], index: number): T[] => [
+  ...array.slice(0, Math.max(0, index)),
+  ...array.slice(Math.max(0, index), index + 2).reverse(),
+  ...array.slice(index + 2),
+];
+
+function reorderQueue(state: GlobalState, action: ActionQueueOrdered): GlobalState {
+  const currentIndex = state.player.queue.indexOf(action.payload.songId);
+  if (currentIndex === -1) {
+    return state;
+  }
+
+  const reverseIndex = action.payload.delta === 1 ? currentIndex : currentIndex - 1;
+
+  return {
+    ...state,
+    player: { ...state.player, queue: reverseInArray(state.player.queue, reverseIndex) },
+  };
+}
+
 export function globalReducer(state: GlobalState, action: AnyAction): GlobalState {
   switch (action.type) {
     case ActionTypeRemote.StateSet:
@@ -126,6 +148,24 @@ export function globalReducer(state: GlobalState, action: AnyAction): GlobalStat
     case ActionTypeLocal.SongInfoFetched:
       return onSongFetched(state, action);
 
+    case ActionTypeLocal.QueuePushed:
+      return {
+        ...state,
+        player: { ...state.player, queue: [action.payload, ...state.player.queue] },
+      };
+    case ActionTypeLocal.QueueShifted:
+      return { ...state, player: { ...state.player, queue: state.player.queue.slice(1) } };
+    case ActionTypeLocal.QueueRemoved:
+      return {
+        ...state,
+        player: {
+          ...state.player,
+          queue: state.player.queue.filter((id) => id !== action.payload),
+        },
+      };
+    case ActionTypeLocal.QueueOrdered:
+      return reorderQueue(state, action);
+
     default:
       return state;
   }

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

@@ -9,4 +9,5 @@ export type MusicPlayer = {
   currentTime: number;
   seekTime: number;
   master: string;
+  queue: number[];
 };