Просмотр исходного кода

feat: play/pause action with keyboard shortcut

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

+ 10 - 2
gmus/src/actions/actions.ts

@@ -37,11 +37,18 @@ export const seeked = (time: number): ActionSeeked => ({
   payload: time,
 });
 
-export type ActionMasterRetaken = ActionLocal<ActionTypeLocal.MasterRetaken, null>;
+export type ActionMasterRetaken = ActionLocal<ActionTypeLocal.MasterRetaken, void>;
 
 export const masterRetaken = (): ActionMasterRetaken => ({
   type: ActionTypeLocal.MasterRetaken,
-  payload: null,
+  payload: undefined,
+});
+
+export type ActionPlayPaused = ActionLocal<ActionTypeLocal.PlayPaused, void>;
+
+export const playPaused = (): ActionPlayPaused => ({
+  type: ActionTypeLocal.PlayPaused,
+  payload: undefined,
 });
 
 export type LocalAction =
@@ -50,6 +57,7 @@ export type LocalAction =
   | ActionNameSet
   | ActionStateSetLocal
   | ActionSeeked
+  | ActionPlayPaused
   | ActionMasterRetaken;
 
 export type AnyAction = LocalAction | RemoteAction;

+ 1 - 0
gmus/src/actions/types.ts

@@ -12,6 +12,7 @@ export enum ActionTypeLocal {
   StateSet = '@@local/STATE_SET',
   Seeked = '@@local/SEEKED',
   MasterRetaken = '@@local/MASTER_RETAKEN',
+  PlayPaused = '@@local/PLAY_PAUSED',
 }
 
 export interface Action<T extends string = string, P = unknown> {

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

@@ -1,4 +1,4 @@
-import { loggedOut, stateSet } from '../../../actions';
+import { loggedOut, playPaused, stateSet } from '../../../actions';
 import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../hooks/vim';
 import { Song } from '../../../types';
 import {
@@ -156,6 +156,17 @@ describe(cmusUIReducer.name, () => {
       });
     });
 
+    describe(Keys.C, () => {
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.C };
+
+      it('should set the globalAction to play/pause', () => {
+        expect.assertions(2);
+        const result = cmusUIReducer(stateLibrary, action);
+        expect(result.globalAction).toStrictEqual(playPaused());
+        expect(result.globalActionSerialNumber).toBe(stateLibrary.globalActionSerialNumber + 1);
+      });
+    });
+
     describe(Keys.J, () => {
       const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.J };
 

+ 4 - 1
gmus/src/components/ui/cmus/reducer.ts

@@ -1,6 +1,6 @@
 import { createContext, Dispatch } from 'react';
 
-import { LocalAction, loggedOut, stateSet } from '../../../actions';
+import { LocalAction, loggedOut, playPaused, stateSet } from '../../../actions';
 import { nullDispatch } from '../../../context/state';
 import { ActionTypeKeyPressed, Keys } from '../../../hooks/vim';
 import { scrollThroughItems } from '../../../utils/delta';
@@ -174,6 +174,9 @@ function handleKeyPress(state: CmusUIState, key: string): CmusUIState {
 
       return state;
 
+    case Keys.C:
+      return withGlobalAction(state, playPaused());
+
     case Keys.J:
       return handleScrollDown(state);
     case Keys.K:

+ 45 - 0
gmus/src/effects/effects.spec.ts

@@ -3,6 +3,7 @@ import {
   ActionTypeLocal,
   ActionTypeRemote,
   masterRetaken,
+  playPaused,
   seeked,
   stateSet,
 } from '../actions';
@@ -104,4 +105,48 @@ describe(globalEffects.name, () => {
       });
     });
   });
+
+  describe(ActionTypeLocal.PlayPaused, () => {
+    const statePriorMaster: GlobalState = {
+      ...initialState,
+      player: {
+        songId: 123,
+        playing: true,
+        currentTime: 83,
+        seekTime: 5,
+        master: 'some-master-client',
+      },
+      myClientName: 'some-master-client',
+    };
+
+    describe('when the client is master', () => {
+      it('should return null', () => {
+        expect.assertions(1);
+        expect(globalEffects(statePriorMaster, playPaused())).toBeNull();
+      });
+    });
+
+    describe('when the client is a slave', () => {
+      const stateSlave: GlobalState = {
+        ...statePriorMaster,
+        myClientName: 'some-slave-client',
+      };
+
+      it('should return a StateSet action informing other clients of the updated playing state', () => {
+        expect.assertions(1);
+        const result = globalEffects(stateSlave, playPaused());
+
+        expect(result).toStrictEqual<ActionStateSetRemote>({
+          type: ActionTypeRemote.StateSet,
+          payload: {
+            songId: 123,
+            playing: false,
+            currentTime: 83,
+            seekTime: 5,
+            master: 'some-master-client',
+          },
+        });
+      });
+    });
+  });
 });

+ 13 - 0
gmus/src/effects/effects.ts

@@ -1,5 +1,6 @@
 import { ActionTypeLocal, ActionTypeRemote, LocalAction, RemoteAction } from '../actions';
 import { GlobalState } from '../reducer/types';
+import { isMaster } from '../selectors';
 
 export function globalEffects(prevState: GlobalState, action: LocalAction): RemoteAction | null {
   switch (action.type) {
@@ -26,6 +27,18 @@ export function globalEffects(prevState: GlobalState, action: LocalAction): Remo
         },
       };
 
+    case ActionTypeLocal.PlayPaused:
+      if (isMaster(prevState)) {
+        return null;
+      }
+      return {
+        type: ActionTypeRemote.StateSet,
+        payload: {
+          ...prevState.player,
+          playing: !prevState.player.playing,
+        },
+      };
+
     default:
       return null;
   }

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

@@ -7,6 +7,7 @@ export const Keys = {
   space: ' ',
   colon: ':',
   '1': '1',
+  C: 'c',
   J: 'j',
   K: 'k',
 };

+ 80 - 0
gmus/src/reducer/reducer.spec.ts

@@ -6,6 +6,7 @@ import {
   ActionTypeRemote,
   masterRetaken,
   nameSet,
+  playPaused,
   seeked,
   stateSet,
 } from '../actions';
@@ -399,4 +400,83 @@ describe(globalReducer.name, () => {
       expect(result.player.playing).toBe(false);
     });
   });
+
+  describe(ActionTypeLocal.PlayPaused, () => {
+    const action = playPaused();
+
+    describe('when the current client is master', () => {
+      const stateMaster: GlobalState = {
+        ...initialState,
+        myClientName: 'my-client',
+        player: {
+          ...nullPlayer,
+          master: 'my-client',
+        },
+      };
+
+      describe('when playing', () => {
+        const statePlaying: GlobalState = {
+          ...stateMaster,
+          player: {
+            ...stateMaster.player,
+            songId: 123,
+            playing: true,
+          },
+        };
+
+        it('should set playing=false', () => {
+          expect.assertions(1);
+          const result = globalReducer(statePlaying, action);
+
+          expect(result.player).toStrictEqual<MusicPlayer>({
+            ...stateMaster.player,
+            songId: 123,
+            playing: false,
+          });
+        });
+      });
+
+      describe('when not playing', () => {
+        const statePaused: GlobalState = {
+          ...stateMaster,
+          player: {
+            ...stateMaster.player,
+            songId: 123,
+            playing: false,
+          },
+        };
+
+        it('should set playing=true', () => {
+          expect.assertions(1);
+          const result = globalReducer(statePaused, action);
+
+          expect(result.player).toStrictEqual<MusicPlayer>({
+            ...stateMaster.player,
+            songId: 123,
+            playing: true,
+          });
+        });
+      });
+    });
+
+    describe('when the current client is a slave', () => {
+      const stateSlave: GlobalState = {
+        ...initialState,
+        myClientName: 'my-client',
+        player: {
+          ...initialState.player,
+          songId: 123,
+          playing: true,
+          master: 'some-master-client',
+        },
+      };
+
+      it('should not update the state optimistically', () => {
+        expect.assertions(1);
+        const result = globalReducer(stateSlave, action);
+
+        expect(result.player).toBe(stateSlave.player);
+      });
+    });
+  });
 });

+ 6 - 0
gmus/src/reducer/reducer.ts

@@ -88,6 +88,12 @@ export function globalReducer(state: GlobalState, action: AnyAction): GlobalStat
         },
       };
 
+    case ActionTypeLocal.PlayPaused:
+      if (!isMaster(state)) {
+        return state;
+      }
+      return { ...state, player: { ...state.player, playing: !state.player.playing } };
+
     default:
       return state;
   }