Ver Fonte

feat: shuffle mode

    * chore: updated mobile spec to include new state props
    * feat: shuffle mode help and status bar
    * feat: toggle shuffle mode with S key
    * feat: global state setter actions using functions of previous state
    * feat: use shuffle endpoint when shuffle mode is true in queue hook
    * feat: added shuffleMode to client player state
    * chore: updated mobile player state type
    * feat: added shuffleMode to MusicPlayer type
    * feat: standardise query parameter name
    * feat: handle case in shuffle repository when there are no songs in the database
    * feat: documented shuffle-song route
    * feat: route to fetch a shuffled song
    * feat: repository layer to select shuffled song
Fela Maslen há 4 anos atrás
pai
commit
66622a6d7a

+ 4 - 0
gmus-backend/README.md

@@ -79,3 +79,7 @@ Fetches next song info.
 #### `GET /prev-song?id=<id>`
 
 Fetches previous song info.
+
+#### `GET /shuffle-song?id=<id>`
+
+Fetches shuffled song info. Note that `id` is an optional query parameter specifying the currently playing song.

+ 17 - 0
gmus-backend/pkg/repository/player.go

@@ -32,3 +32,20 @@ func GetPrevSong(db *sqlx.DB, nextSongId int) (prevSong *types.Song, err error)
 	}
 	return
 }
+
+func GetShuffledSong(db *sqlx.DB, currentSongId *int) (shuffledSong *types.Song, err error) {
+	shuffledSong = &types.Song{}
+
+	if currentSongId == nil {
+		err = db.QueryRowx(querySelectFirstShuffledSong).StructScan(shuffledSong)
+	} else {
+		err = db.QueryRowx(querySelectNextShuffledSong, *currentSongId).StructScan(shuffledSong)
+	}
+
+	if err != nil && err == sql.ErrNoRows {
+		err = nil
+		shuffledSong = &types.Song{Id: 0}
+	}
+
+	return
+}

+ 46 - 0
gmus-backend/pkg/repository/player_test.go

@@ -7,6 +7,7 @@ import (
 	"github.com/felamaslen/gmus-backend/pkg/database"
 	"github.com/felamaslen/gmus-backend/pkg/repository"
 	"github.com/felamaslen/gmus-backend/pkg/testing"
+	"github.com/felamaslen/gmus-backend/pkg/types"
 )
 
 var _ = Describe("Player repository", func() {
@@ -147,4 +148,49 @@ var _ = Describe("Player repository", func() {
 			})
 		})
 	})
+
+	Describe("GetShuffledSong", func() {
+		It("should return a random song", func() {
+			result, _ := repository.GetShuffledSong(db, &ids[0])
+			Expect(result).NotTo(BeNil())
+			Expect(result.Id).To(BeElementOf(ids))
+		})
+
+		It("should not return the given song", func() {
+			// Iterate 10 times to be quite confident - it's not a mathematical proof
+			// but it doesn't need to be
+			for i := 0; i < 10; i++ {
+				result0, _ := repository.GetShuffledSong(db, &ids[0])
+				result4, _ := repository.GetShuffledSong(db, &ids[4])
+
+				Expect(result0).NotTo(BeNil())
+				Expect(result4).NotTo(BeNil())
+
+				Expect(result0.Id).NotTo(Equal(ids[0]))
+				Expect(result4.Id).NotTo(Equal(ids[4]))
+			}
+		})
+
+		Context("when no currentSongId is given", func() {
+			It("should return a random song", func() {
+				result, _ := repository.GetShuffledSong(db, nil)
+				Expect(result).NotTo(BeNil())
+				Expect(result.Id).To(BeElementOf(ids))
+			})
+		})
+
+		Context("when there are no songs in the database", func() {
+			BeforeEach(func() {
+				db.MustExec(`truncate songs`)
+			})
+
+			It("should return an empty result", func() {
+				result, err := repository.GetShuffledSong(db, nil)
+				Expect(err).To(BeNil())
+				Expect(result).To(Equal(&types.Song{
+					Id: 0,
+				}))
+			})
+		})
+	})
 })

+ 27 - 0
gmus-backend/pkg/repository/queries.go

@@ -175,6 +175,33 @@ order by
 limit 1
 `
 
+const querySelectFirstShuffledSong = `
+select
+	s.id
+	,s.track_number
+	,s.title
+	,s.artist
+	,s.album
+	,s.duration
+from songs s
+limit 1
+offset floor(random() * (select count(*) from songs))
+`
+
+const querySelectNextShuffledSong = `
+select
+	s.id
+	,s.track_number
+	,s.title
+	,s.artist
+	,s.album
+	,s.duration
+from songs s
+where s.id != $1
+limit 1
+offset floor(random() * greatest(0, ((select count(*) from songs) - 1)))
+`
+
 const queryInsertScanError = `
 insert into scan_errors (created_at, base_path, relative_path, error)
 values ($1, $2, $3, $4)

+ 26 - 4
gmus-backend/pkg/server/fetch.go

@@ -10,7 +10,6 @@ import (
 	"github.com/felamaslen/gmus-backend/pkg/repository"
 	"github.com/felamaslen/gmus-backend/pkg/types"
 	"github.com/go-redis/redis"
-	"github.com/jmoiron/sqlx"
 )
 
 type ArtistsResponse struct {
@@ -206,7 +205,7 @@ type NullResponse struct {
 	Id int `json:"id"`
 }
 
-func respondWithSongOrNull(db *sqlx.DB, w http.ResponseWriter, song *types.Song) error {
+func respondWithSongOrNull(w http.ResponseWriter, song *types.Song) error {
 	if song.Id == 0 {
 		response, _ := json.Marshal(NullResponse{})
 		w.Write(response)
@@ -242,7 +241,7 @@ func routeFetchNextSong(l *logger.Logger, rdb redis.Cmdable, w http.ResponseWrit
 		return err
 	}
 
-	if err := respondWithSongOrNull(db, w, nextSong); err != nil {
+	if err := respondWithSongOrNull(w, nextSong); err != nil {
 		return err
 	}
 	return nil
@@ -260,7 +259,30 @@ func routeFetchPrevSong(l *logger.Logger, rdb redis.Cmdable, w http.ResponseWrit
 		return err
 	}
 
-	if err := respondWithSongOrNull(db, w, prevSong); err != nil {
+	if err := respondWithSongOrNull(w, prevSong); err != nil {
+		return err
+	}
+	return nil
+}
+
+func routeFetchShuffledSong(l *logger.Logger, rdb redis.Cmdable, w http.ResponseWriter, r *http.Request) error {
+	idRaw := r.URL.Query().Get("id")
+	id, err := strconv.Atoi(idRaw)
+	db := database.GetConnection()
+
+	var song *types.Song
+
+	if err != nil || id < 1 {
+		// No (valid) song ID passed; fetch any random song
+		song, err = repository.GetShuffledSong(db, nil)
+	} else {
+		song, err = repository.GetShuffledSong(db, &id)
+	}
+
+	if err != nil {
+		return err
+	}
+	if err = respondWithSongOrNull(w, song); err != nil {
 		return err
 	}
 	return nil

+ 2 - 0
gmus-backend/pkg/server/server.go

@@ -55,5 +55,7 @@ func StartServer() {
 	server.router.Path("/next-song").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchNextSong))
 	server.router.Path("/prev-song").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchPrevSong))
 
+	server.router.Path("/shuffle-song").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchShuffledSong))
+
 	server.Listen()
 }

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

@@ -51,4 +51,5 @@ type MusicPlayer struct {
 	Master        string    `json:"master" validate:"required"`
 	ActiveClients *[]string `json:"activeClients" validate:"required"`
 	Queue         *[]int    `json:"queue" validate:"required"`
+	ShuffleMode   bool      `json:"shuffleMode" validate:"-"`
 }

+ 6 - 2
gmus-mobile/lib/controller.dart

@@ -11,18 +11,22 @@ class Player {
   double currentTime = 0;
   double seekTime = -1;
   String master;
+  List<String> activeClients = [];
   int songId;
   bool playing = false;
   List<int> queue = [];
+  bool shuffleMode = false;
 
   Map<String, dynamic> stringify() {
     return {
+      'songId': this.songId,
+      'playing': this.playing,
       'currentTime': this.currentTime,
       'seekTime': this.seekTime,
       'master': this.master,
-      'songId': this.songId,
-      'playing': this.playing,
+      'activeClients': this.activeClients,
       'queue': this.queue,
+      'shuffleMode': this.shuffleMode,
     };
   }
 }

+ 6 - 0
gmus-mobile/lib/socket.dart

@@ -120,12 +120,18 @@ void onRemoteMessage(Controller controller, String message) {
       nextPlayer.master = action['payload']['master'];
       nextPlayer.songId = action['payload']['songId'];
       nextPlayer.playing = action['payload']['playing'];
+      nextPlayer.shuffleMode = action['payload']['shuffleMode'];
 
       nextPlayer.queue = [];
       for (var i = 0; i < action['payload']['queue'].length; i++) {
         nextPlayer.queue.add(action['payload']['queue'][i]);
       }
 
+      nextPlayer.activeClients = [];
+      for (var i = 0; i < action['payload']['activeClients'].length; i++) {
+        nextPlayer.activeClients.add(action['payload']['activeClients'][i]);
+      }
+
       controller.player.value = nextPlayer;
 
       break;

+ 14 - 5
gmus-mobile/test/controller_test.dart

@@ -28,7 +28,7 @@ void main() {
       });
 
       group(STATE_SET, () {
-        String message = '{"type":"STATE_SET","payload":{"master":"new-master-client","songId":7123,"currentTime":10.843,"seekTime":23.001,"playing":false,"queue":[9750]}}';
+        String message = '{"type":"STATE_SET","payload":{"master":"new-master-client","activeClients":["some-client"],"songId":7123,"currentTime":10.843,"seekTime":23.001,"playing":false,"queue":[9750],"shuffleMode":true}}';
 
         test('player state should be updated', () {
           Controller controller = Controller();
@@ -39,6 +39,7 @@ void main() {
           expect(controller.player.value.currentTime, 10.843);
           expect(controller.player.value.seekTime, 23.001);
           expect(controller.player.value.playing, false);
+          expect(controller.player.value.shuffleMode, true);
         });
 
         test('queue should be updated', () {
@@ -48,6 +49,14 @@ void main() {
           expect(controller.player.value.queue.length, 1);
           expect(controller.player.value.queue[0], 9750);
         });
+
+        test('active clients should be updated', () {
+          Controller controller = Controller();
+          onRemoteMessage(controller, message);
+
+          expect(controller.player.value.activeClients.length, 1);
+          expect(controller.player.value.activeClients[0], 'some-client');
+        });
       });
     });
 
@@ -60,7 +69,7 @@ void main() {
 
             controller.playPause();
 
-            verify(controller.socket.channel.sink.add('{"type":"STATE_SET","payload":{"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","songId":null,"playing":true,"queue":[]}}')).called(1);
+            verify(controller.socket.channel.sink.add('{"type":"STATE_SET","payload":{"songId":null,"playing":true,"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","activeClients":[],"queue":[],"shuffleMode":false}}')).called(1);
           });
         });
 
@@ -72,7 +81,7 @@ void main() {
 
             controller.playPause();
 
-            verify(controller.socket.channel.sink.add('{"type":"STATE_SET","payload":{"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","songId":182,"playing":false,"queue":[]}}')).called(1);
+            verify(controller.socket.channel.sink.add('{"type":"STATE_SET","payload":{"songId":182,"playing":false,"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","activeClients":[],"queue":[],"shuffleMode":false}}')).called(1);
           });
         });
       });
@@ -83,7 +92,7 @@ void main() {
           controller.player.value.playing = false;
           controller.playPause();
 
-          verifyNever(controller.socket.channel.sink.add('{"type":"STATE_SET","payload":{"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","songId":null,"playing":true,"queue":[]}}'));
+          verifyNever(controller.socket.channel.sink.add('{"type":"STATE_SET","payload":{"songId":null,"playing":true,"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","activeClients":[],"queue":[],"shuffleMode":false}}'));
         });
       });
     });
@@ -95,7 +104,7 @@ void main() {
 
           controller.playSong(871);
 
-          verify(controller.socket.channel.sink.add('{"type":"STATE_SET","payload":{"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","songId":871,"playing":true,"queue":[]}}')).called(1);
+          verify(controller.socket.channel.sink.add('{"type":"STATE_SET","payload":{"songId":871,"playing":true,"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","activeClients":[],"queue":[],"shuffleMode":false}}')).called(1);
         });
       });
 

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

@@ -1,3 +1,4 @@
+import { SetStateAction } from 'react';
 import { Song } from '../types';
 import { Member, MusicPlayer } from '../types/state';
 import { ActionErrorOccurred } from './error';
@@ -23,12 +24,14 @@ export const nameSet = (name: string): ActionNameSet => ({
 
 export type ActionStateSetLocal = ActionLocal<
   ActionTypeLocal.StateSet,
-  Omit<Partial<MusicPlayer>, 'seekTime'>
+  SetStateAction<Omit<Partial<MusicPlayer>, 'seekTime'>>
 >;
 
-export const stateSet = (state: Partial<MusicPlayer> = {}): ActionStateSetLocal => ({
+export const stateSet = (
+  payload: SetStateAction<Partial<MusicPlayer>> = {},
+): ActionStateSetLocal => ({
   type: ActionTypeLocal.StateSet,
-  payload: state,
+  payload,
 });
 
 export type ActionSeeked = ActionLocal<ActionTypeLocal.Seeked, number>;

+ 1 - 1
gmus-web/src/components/app.tsx

@@ -50,7 +50,7 @@ export const App: React.FC<Props> = ({
     [dispatch],
   );
 
-  const { onNext, onPrev } = usePlayQueue();
+  const { onNext, onPrev } = usePlayQueue(state.player.shuffleMode);
 
   const isDesktop = useMediaQuery({ query: '(min-device-width: 1280px)' });
 

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

@@ -1,5 +1,6 @@
 import {
   activeClientToggled,
+  LocalAction,
   masterSet,
   playPaused,
   queuePushed,
@@ -7,6 +8,7 @@ import {
   stateSet,
 } from '../../../../actions';
 import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../../hooks/vim';
+import { globalReducer, initialState } from '../../../../reducer';
 
 import { CmusUIState, LibraryModeWindow, Overlay, View } from '../types';
 
@@ -116,6 +118,20 @@ describe(ActionTypeKeyPressed, () => {
     });
   });
 
+  describe(Keys.S, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.S };
+
+    it('should toggle the shuffle value', () => {
+      expect.assertions(2);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      const { globalAction } = result;
+      const globalResult = globalReducer(initialState, globalAction as LocalAction);
+      expect(globalResult.player.shuffleMode).toBe(true);
+      const nextGlobalResult = globalReducer(globalResult, globalAction as LocalAction);
+      expect(nextGlobalResult.player.shuffleMode).toBe(false);
+    });
+  });
+
   describe(Keys.space, () => {
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.space };
 

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

@@ -159,6 +159,12 @@ export function handleKeyPress(state: CmusUIState, action: ActionKeyPressed): Cm
     case Keys.Z:
       return { ...state, skipSong: { delta: -1, serialNumber: state.skipSong.serialNumber + 1 } };
 
+    case Keys.S:
+      return withGlobalAction(
+        state,
+        stateSet((last) => ({ shuffleMode: !last.shuffleMode })),
+      );
+
     case Keys.C:
       return withGlobalAction(state, playPaused());
 

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

@@ -10,6 +10,7 @@ const commandsGeneral: Command[] = [
   { command: 'z', description: 'previous song' },
   { command: 'c', description: 'play / pause' },
   { command: 'b', description: 'next song' },
+  { command: 's', description: 'toggle shuffle mode' },
   { command: 'j', description: 'select next list item' },
   { command: 'k', description: 'select previous list item' },
   { command: '<PageDown>', description: 'select next page of list items' },

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

@@ -63,6 +63,8 @@ export const PlayerStatus: React.FC<Props> = ({ song, ...props }) => {
           {isActiveClient(state) ? '🔊' : '🔈'}
         </Styled.ClientName>
         &nbsp;
+        {state.player.shuffleMode ? 'S' : ' '}
+        &nbsp;
         <StatusIcon {...props} />
       </Styled.PlayStatus>
     </Styled.StatusContainer>

+ 18 - 9
gmus-web/src/effects/effects.spec.ts

@@ -13,13 +13,18 @@ import {
   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';
 
 describe(globalEffects.name, () => {
   describe(ActionTypeLocal.StateSet, () => {
+    const state: GlobalState = {
+      ...initialState,
+      myClientName: 'my-client-name',
+    };
+
     it('should create a remote state set action', () => {
       expect.assertions(1);
 
@@ -28,23 +33,19 @@ describe(globalEffects.name, () => {
         playing: false,
         currentTime: 83,
         seekTime: 87,
-        master: 'my-client',
+        master: 'my-client-name',
         activeClients: [],
         queue: [],
-      };
-
-      const prevState: GlobalState = {
-        ...initialState,
-        myClientName: 'my-client-name',
+        shuffleMode: false,
       };
 
       const action = stateSet(localPlayer);
 
-      const result = globalEffects(prevState, action);
+      const result = globalEffects(globalReducer(state, action), action);
 
       expect(result).toStrictEqual<ActionStateSetRemote>({
         type: ActionTypeRemote.StateSet,
-        payload: localPlayer,
+        payload: { ...localPlayer, seekTime: 83 },
       });
     });
   });
@@ -60,6 +61,7 @@ describe(globalEffects.name, () => {
         master: 'my-client-name',
         activeClients: [],
         queue: [],
+        shuffleMode: false,
       },
       myClientName: 'my-client-name',
     };
@@ -102,6 +104,7 @@ describe(globalEffects.name, () => {
         master: 'some-master-went-away',
         activeClients: [],
         queue: [],
+        shuffleMode: false,
       },
       myClientName: 'my-client-name',
     };
@@ -122,6 +125,7 @@ describe(globalEffects.name, () => {
           master: 'my-client-name',
           activeClients: [],
           queue: [],
+          shuffleMode: false,
         },
       });
     });
@@ -141,6 +145,7 @@ describe(globalEffects.name, () => {
             master: 'other-client',
             activeClients: [],
             queue: [],
+            shuffleMode: false,
           },
         });
       });
@@ -213,6 +218,7 @@ describe(globalEffects.name, () => {
           master: 'some-master-client',
           activeClients: [],
           queue: [],
+          shuffleMode: false,
         },
         myClientName,
       };
@@ -231,6 +237,7 @@ describe(globalEffects.name, () => {
             master: 'some-master-client',
             activeClients: [],
             queue: [],
+            shuffleMode: false,
           },
         });
       });
@@ -248,6 +255,7 @@ describe(globalEffects.name, () => {
         master: 'some-master-client',
         activeClients: [],
         queue: [],
+        shuffleMode: false,
       },
       myClientName: 'some-master-client',
     };
@@ -281,6 +289,7 @@ describe(globalEffects.name, () => {
             master: 'some-master-client',
             activeClients: [],
             queue: [],
+            shuffleMode: false,
           },
         });
       });

+ 36 - 32
gmus-web/src/effects/effects.ts

@@ -39,24 +39,28 @@ function pushToQueue(state: GlobalState, action: ActionQueuePushed): RemoteActio
   };
 }
 
-export function globalEffects(prevState: GlobalState, action: LocalAction): RemoteAction | null {
+function sendStateUpdateToServer(state: GlobalState): RemoteAction | null {
+  if (!state.player.master) {
+    return null;
+  }
+  return {
+    type: ActionTypeRemote.StateSet,
+    payload: state.player,
+  };
+}
+
+export function globalEffects(state: GlobalState, action: LocalAction): 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 },
-      };
+      return sendStateUpdateToServer(state);
 
     case ActionTypeLocal.Seeked:
-      if (!prevState.player.master) {
+      if (!state.player.master) {
         return null;
       }
       return {
         type: ActionTypeRemote.StateSet,
-        payload: { ...prevState.player, seekTime: action.payload },
+        payload: { ...state.player, seekTime: action.payload },
       };
 
     case ActionTypeLocal.MasterSet:
@@ -64,8 +68,8 @@ export function globalEffects(prevState: GlobalState, action: LocalAction): Remo
         return {
           type: ActionTypeRemote.StateSet,
           payload: {
-            ...prevState.player,
-            seekTime: prevState.player.currentTime,
+            ...state.player,
+            seekTime: state.player.currentTime,
             master: action.payload,
           },
         };
@@ -74,10 +78,10 @@ export function globalEffects(prevState: GlobalState, action: LocalAction): Remo
       return {
         type: ActionTypeRemote.StateSet,
         payload: {
-          ...prevState.player,
+          ...state.player,
           playing: false,
           seekTime: -1,
-          master: prevState.myClientName,
+          master: state.myClientName,
         },
       };
 
@@ -85,10 +89,10 @@ export function globalEffects(prevState: GlobalState, action: LocalAction): Remo
       return {
         type: ActionTypeRemote.StateSet,
         payload: {
-          ...prevState.player,
-          activeClients: prevState.player.activeClients.includes(action.payload)
-            ? prevState.player.activeClients.filter((client) => client !== action.payload)
-            : [...prevState.player.activeClients, action.payload],
+          ...state.player,
+          activeClients: state.player.activeClients.includes(action.payload)
+            ? state.player.activeClients.filter((client) => client !== action.payload)
+            : [...state.player.activeClients, action.payload],
         },
       };
 
@@ -96,19 +100,19 @@ export function globalEffects(prevState: GlobalState, action: LocalAction): Remo
       return {
         type: ActionTypeRemote.StateSet,
         payload: {
-          ...prevState.player,
-          playing: !prevState.player.playing,
+          ...state.player,
+          playing: !state.player.playing,
         },
       };
 
     case ActionTypeLocal.SongInfoFetched:
-      if (isMaster(prevState) || !action.payload.replace || !prevState.player.master) {
+      if (isMaster(state) || !action.payload.replace || !state.player.master) {
         return null;
       }
       return {
         type: ActionTypeRemote.StateSet,
         payload: {
-          ...prevState.player,
+          ...state.player,
           songId: action.payload.song?.id ?? null,
           playing: !!action.payload.song,
           currentTime: 0,
@@ -117,37 +121,37 @@ export function globalEffects(prevState: GlobalState, action: LocalAction): Remo
       };
 
     case ActionTypeLocal.QueuePushed:
-      return pushToQueue(prevState, action);
+      return pushToQueue(state, action);
     case ActionTypeLocal.QueueShifted:
-      if (!prevState.player.master) {
+      if (!state.player.master) {
         return null;
       }
       return {
         type: ActionTypeRemote.StateSet,
         payload: {
-          ...prevState.player,
-          queue: prevState.player.queue.slice(1),
-          playing: !!prevState.player.queue[0],
-          songId: prevState.player.queue[0],
+          ...state.player,
+          queue: state.player.queue.slice(1),
+          playing: !!state.player.queue[0],
+          songId: state.player.queue[0],
           currentTime: 0,
           seekTime: 0,
         },
       };
     case ActionTypeLocal.QueueRemoved:
-      if (!prevState.player.master) {
+      if (!state.player.master) {
         return null;
       }
       return {
         type: ActionTypeRemote.StateSet,
         payload: {
-          ...prevState.player,
-          queue: prevState.player.queue.filter((id) => id !== action.payload),
+          ...state.player,
+          queue: state.player.queue.filter((id) => id !== action.payload),
         },
       };
     case ActionTypeLocal.QueueOrdered:
       return {
         type: ActionTypeRemote.StateSet,
-        payload: { ...prevState.player, queue: reorderQueue(prevState.player.queue, action) },
+        payload: { ...state.player, queue: reorderQueue(state.player.queue, action) },
       };
 
     default:

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

@@ -70,6 +70,7 @@ describe(useMaster.name, () => {
         master: 'master-client-a',
         activeClients: [],
         queue: [],
+        shuffleMode: false,
       },
     };
 

+ 12 - 7
gmus-web/src/hooks/queue.ts

@@ -9,16 +9,16 @@ import { getApiUrl } from '../utils/url';
 import { useRequestCallback } from './request';
 
 function useNextOrPrevSong(
-  key: 'next' | 'prev',
+  key: 'next' | 'prev' | 'shuffle',
   dispatch: Dispatch<LocalAction>,
-): [(songId: number) => void, boolean] {
+): [(songId: number | null) => void, boolean] {
   const sendRequest = useCallback(
-    (axios: AxiosInstance, id: number): Promise<AxiosResponse<Song | NullSong>> =>
-      axios.get(`${getApiUrl()}/${key}-song?id=${id}`),
+    (axios: AxiosInstance, id: number | null): Promise<AxiosResponse<Song | NullSong>> =>
+      axios.get(`${getApiUrl()}/${key}-song${id === null ? '' : `?id=${id}`}`),
     [key],
   );
 
-  const [onRequest, response, loading] = useRequestCallback<number, Song | NullSong>({
+  const [onRequest, response, loading] = useRequestCallback<number | null, Song | NullSong>({
     sendRequest,
   });
 
@@ -37,7 +37,9 @@ function useNextOrPrevSong(
   return [debouncedRequest, loading];
 }
 
-export function usePlayQueue(): {
+export function usePlayQueue(
+  shuffleMode: boolean,
+): {
   onNext: () => void;
   onPrev: () => void;
   loading: boolean;
@@ -47,7 +49,10 @@ export function usePlayQueue(): {
     player: { queue, songId },
   } = useContext(StateContext);
 
-  const [onRequestNext, loadingNext] = useNextOrPrevSong('next', dispatch);
+  const [onRequestNext, loadingNext] = useNextOrPrevSong(
+    shuffleMode ? 'shuffle' : 'next',
+    dispatch,
+  );
   const [onRequestPrev, loadingPrev] = useNextOrPrevSong('prev', dispatch);
 
   const loading = loadingNext || loadingPrev;

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

@@ -24,6 +24,7 @@ export const Keys = {
   K: 'k',
   P: 'P',
   p: 'p',
+  S: 's',
   Z: 'z',
 };
 

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

@@ -41,6 +41,7 @@ describe(globalReducer.name, () => {
             master: 'some-master-client',
             activeClients: [],
             queue: [],
+            shuffleMode: false,
           },
         };
 
@@ -56,6 +57,7 @@ describe(globalReducer.name, () => {
             master: 'some-master-client',
             activeClients: [],
             queue: [],
+            shuffleMode: false,
           });
         });
       });
@@ -72,6 +74,7 @@ describe(globalReducer.name, () => {
             master: 'some-master-client',
             activeClients: [],
             queue: [],
+            shuffleMode: false,
           },
         };
 
@@ -87,6 +90,7 @@ describe(globalReducer.name, () => {
             master: 'some-master-client',
             activeClients: [],
             queue: [],
+            shuffleMode: false,
           });
         });
       });
@@ -103,6 +107,7 @@ describe(globalReducer.name, () => {
             master: 'other-master-client',
             activeClients: [],
             queue: [],
+            shuffleMode: false,
           },
         };
 
@@ -118,6 +123,7 @@ describe(globalReducer.name, () => {
             master: 'other-master-client',
             activeClients: [],
             queue: [],
+            shuffleMode: false,
           });
         });
       });
@@ -149,6 +155,7 @@ describe(globalReducer.name, () => {
             master: 'some-master-client',
             activeClients: [],
             queue: [],
+            shuffleMode: false,
           },
         };
 
@@ -164,6 +171,7 @@ describe(globalReducer.name, () => {
             master: 'some-master-client',
             activeClients: [],
             queue: [],
+            shuffleMode: false,
           });
         });
       });
@@ -180,6 +188,7 @@ describe(globalReducer.name, () => {
             master: 'my-client',
             activeClients: [],
             queue: [],
+            shuffleMode: false,
           },
         };
 
@@ -195,6 +204,7 @@ describe(globalReducer.name, () => {
             master: 'my-client',
             activeClients: [],
             queue: [],
+            shuffleMode: false,
           });
         });
       });
@@ -265,6 +275,7 @@ describe(globalReducer.name, () => {
           master: 'some-master-client',
           activeClients: [],
           queue: [],
+          shuffleMode: false,
         });
       });
 
@@ -289,6 +300,27 @@ describe(globalReducer.name, () => {
           });
         });
       });
+
+      describe('when the state update is a function', () => {
+        const actionFn = stateSet((last) => ({
+          ...last,
+          currentTime: (last.currentTime ?? 0) + 4,
+        }));
+
+        it('should set the state from the given function', () => {
+          expect.assertions(1);
+          const result = globalReducer(stateMaster, actionFn);
+
+          expect(result.player).toStrictEqual<MusicPlayer>({
+            ...nullPlayer,
+            master: 'some-master-client',
+            seekTime: -1,
+            songId: null,
+            playing: false,
+            currentTime: 31 + 4,
+          });
+        });
+      });
     });
 
     describe('when the client is a slave', () => {
@@ -337,6 +369,7 @@ describe(globalReducer.name, () => {
           master: 'some-master-client',
           activeClients: [],
           queue: [],
+          shuffleMode: false,
         },
         myClientName: 'some-master-client',
       };
@@ -353,6 +386,7 @@ describe(globalReducer.name, () => {
           master: 'some-master-client',
           activeClients: [],
           queue: [],
+          shuffleMode: false,
         });
       });
     });
@@ -392,6 +426,7 @@ describe(globalReducer.name, () => {
         seekTime: -1,
         activeClients: [],
         queue: [],
+        shuffleMode: false,
       },
     };
 

+ 11 - 3
gmus-web/src/reducer/reducer.ts

@@ -6,7 +6,12 @@ import {
   ActionTypeRemote,
   AnyAction,
 } from '../actions';
-import { isFromOurselves, isMaster, willBeMaster } from '../selectors';
+import {
+  getNextPlayerStateFromAction,
+  isFromOurselves,
+  isMaster,
+  willBeMaster,
+} from '../selectors';
 import { MusicPlayer } from '../types/state';
 import { GlobalState } from './types';
 
@@ -18,6 +23,7 @@ export const nullPlayer: MusicPlayer = {
   master: '',
   activeClients: [],
   queue: [],
+  shuffleMode: false,
 };
 
 export const initialState: GlobalState = {
@@ -41,8 +47,10 @@ function onRemoteStateSet(state: GlobalState, action: ActionStateSetRemote): Glo
 }
 
 function onLocalStateSet(state: GlobalState, action: ActionStateSetLocal): GlobalState {
-  const nextPlayer: MusicPlayer = { ...state.player, ...action.payload };
-
+  const nextPlayer = getNextPlayerStateFromAction(state.player, action.payload);
+  if (!nextPlayer) {
+    return state;
+  }
   if (isMaster(state)) {
     return { ...state, player: nextPlayer };
   }

+ 9 - 5
gmus-web/src/selectors.spec.ts

@@ -1,5 +1,5 @@
 import { ActionTypeRemote, stateSet } from './actions';
-import { GlobalState, initialState } from './reducer';
+import { GlobalState, initialState, nullPlayer } from './reducer';
 import { isActiveClient, isFromOurselves, isMaster, willBeMaster } from './selectors';
 
 describe('isMaster', () => {
@@ -139,12 +139,16 @@ describe('isFromOurselves', () => {
 });
 
 describe('willBeMaster', () => {
-  describe('when the action will cause the current client to be master', () => {
+  describe.each`
+    type                | action
+    ${'object-based'}   | ${stateSet({ master: 'a-slave-client' })}
+    ${'function-based'} | ${stateSet(() => ({ master: 'a-slave-client' }))}
+  `('when the $type action will cause the current client to be master', ({ action }) => {
     it('should return true', () => {
       expect.assertions(1);
-      expect(
-        willBeMaster({ myClientName: 'a-slave-client' }, stateSet({ master: 'a-slave-client' })),
-      ).toBe(true);
+      expect(willBeMaster({ player: nullPlayer, myClientName: 'a-slave-client' }, action)).toBe(
+        true,
+      );
     });
   });
 

+ 22 - 1
gmus-web/src/selectors.ts

@@ -1,5 +1,19 @@
 import { ActionRemote, ActionStateSetLocal, ActionStateSetRemote } from './actions';
 import { GlobalState } from './reducer/types';
+import { MusicPlayer } from './types';
+
+export function getNextPlayerStateFromAction(
+  player: MusicPlayer | undefined,
+  payload: ActionStateSetLocal['payload'] | null,
+): MusicPlayer | null {
+  if (!(payload && player)) {
+    return null;
+  }
+  if (typeof payload === 'function') {
+    return { ...player, ...payload(player) };
+  }
+  return { ...player, ...payload };
+}
 
 export const isMaster = (state: Pick<GlobalState, 'player' | 'myClientName'>): boolean =>
   state.player.master === state.myClientName;
@@ -15,6 +29,13 @@ export const isFromOurselves = (
 export const willBeMaster = (
   state: Partial<GlobalState> & Pick<GlobalState, 'myClientName'>,
   action: ActionStateSetLocal | ActionStateSetRemote,
-): boolean => state.myClientName === action.payload?.master;
+): boolean => {
+  const actionHasMaster =
+    typeof action.payload === 'function' ? !!action.payload({}).master : !!action.payload?.master;
+  return (
+    actionHasMaster &&
+    state.myClientName === getNextPlayerStateFromAction(state.player, action.payload)?.master
+  );
+};
 
 export const getSongId = (state: Pick<GlobalState, 'player'>): number | null => state.player.songId;

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

@@ -11,4 +11,5 @@ export type MusicPlayer = {
   master: string;
   activeClients: string[];
   queue: number[];
+  shuffleMode: boolean;
 };