Quellcode durchsuchen

feat: retake control of master if master goes away

Fela Maslen vor 5 Jahren
Ursprung
Commit
8fd32a8130

+ 13 - 1
gmus/src/actions/actions.ts

@@ -34,6 +34,18 @@ export const seeked = (time: number): ActionSeeked => ({
   payload: time,
 });
 
-export type LocalAction = ActionErrorOccurred | ActionNameSet | ActionStateSetLocal | ActionSeeked;
+export type ActionMasterRetaken = ActionLocal<ActionTypeLocal.MasterRetaken, null>;
+
+export const masterRetaken = (): ActionMasterRetaken => ({
+  type: ActionTypeLocal.MasterRetaken,
+  payload: null,
+});
+
+export type LocalAction =
+  | ActionErrorOccurred
+  | ActionNameSet
+  | ActionStateSetLocal
+  | ActionSeeked
+  | ActionMasterRetaken;
 
 export type AnyAction = LocalAction | RemoteAction;

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

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

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

@@ -2,6 +2,7 @@ import {
   ActionStateSetRemote,
   ActionTypeLocal,
   ActionTypeRemote,
+  masterRetaken,
   seeked,
   stateSet,
 } from '../actions';
@@ -72,4 +73,35 @@ describe(globalEffects.name, () => {
       });
     });
   });
+
+  describe(ActionTypeLocal.MasterRetaken, () => {
+    const stateMasterWentAway: GlobalState = {
+      ...initialState,
+      clientList: [{ name: 'my-client-name', lastPing: 0 }],
+      player: {
+        songId: 123,
+        playing: true,
+        currentTime: 83,
+        seekTime: 5,
+        master: 'some-master-went-away',
+      },
+      myClientName: 'my-client-name',
+    };
+
+    it('should return a StateSet action informing other clients that we are the new master', () => {
+      expect.assertions(1);
+      const result = globalEffects(stateMasterWentAway, masterRetaken());
+
+      expect(result).toStrictEqual<ActionStateSetRemote>({
+        type: ActionTypeRemote.StateSet,
+        payload: {
+          songId: 123,
+          playing: false,
+          currentTime: 83,
+          seekTime: -1,
+          master: 'my-client-name',
+        },
+      });
+    });
+  });
 });

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

@@ -15,6 +15,17 @@ export function globalEffects(prevState: GlobalState, action: LocalAction): Remo
         payload: { ...prevState.player, seekTime: action.payload },
       };
 
+    case ActionTypeLocal.MasterRetaken:
+      return {
+        type: ActionTypeRemote.StateSet,
+        payload: {
+          ...prevState.player,
+          playing: false,
+          seekTime: -1,
+          master: prevState.myClientName,
+        },
+      };
+
     default:
       return null;
   }

+ 93 - 1
gmus/src/hooks/master.spec.tsx

@@ -1,7 +1,7 @@
 import { act, render } from '@testing-library/react';
 import React from 'react';
 
-import { stateSet } from '../actions';
+import { masterRetaken, stateSet } from '../actions';
 import { masterStateUpdateTimeout } from '../constants/system';
 import { GlobalState, initialState, nullPlayer } from '../reducer';
 
@@ -53,11 +53,99 @@ describe(useMaster.name, () => {
     });
   });
 
+  describe('when master goes away', () => {
+    const stateWithMaster: GlobalState = {
+      ...initialState,
+      initialised: true,
+      myClientName: 'my-client-name',
+      clientList: [
+        { name: 'master-client-a', lastPing: 0 },
+        { name: 'my-client-name', lastPing: 0 },
+        { name: 'other-slave-client', lastPing: 0 },
+      ],
+      player: {
+        songId: 123,
+        playing: true,
+        currentTime: 17,
+        seekTime: -1,
+        master: 'master-client-a',
+      },
+    };
+
+    const stateMasterWentAway: GlobalState = {
+      ...stateWithMaster,
+      clientList: [
+        { name: 'my-client-name', lastPing: 0 },
+        { name: 'other-slave-client', lastPing: 0 },
+      ],
+    };
+
+    it('should take control of master after a delay, and pause the client', () => {
+      expect.assertions(2);
+      jest.useFakeTimers();
+
+      const { container, unmount } = render(<TestComponent {...stateWithMaster} />);
+
+      act(() => {
+        render(<TestComponent {...stateMasterWentAway} />, { container });
+      });
+
+      expect(dispatch).not.toHaveBeenCalled();
+
+      act(() => {
+        jest.runAllTimers();
+      });
+
+      expect(dispatch).toHaveBeenCalledWith(masterRetaken());
+
+      unmount();
+      jest.useRealTimers();
+    });
+
+    describe('and a third client takes over control', () => {
+      const stateMasterWentAwayAnotherTookControl: GlobalState = {
+        ...stateMasterWentAway,
+        clientList: [
+          { name: 'my-client-name', lastPing: 0 },
+          { name: 'other-slave-client', lastPing: 0 },
+        ],
+        player: {
+          ...stateMasterWentAway.player,
+          master: 'other-slave-client',
+        },
+      };
+
+      it('should not take control of master', () => {
+        expect.assertions(1);
+        jest.useFakeTimers();
+
+        const { container, unmount } = render(<TestComponent {...stateWithMaster} />);
+        act(() => {
+          render(<TestComponent {...stateMasterWentAway} />, { container });
+        });
+
+        setImmediate(() => {
+          act(() => {
+            render(<TestComponent {...stateMasterWentAwayAnotherTookControl} />, { container });
+          });
+        });
+
+        jest.runAllTimers();
+
+        expect(dispatch).not.toHaveBeenCalled();
+
+        unmount();
+        jest.useRealTimers();
+      });
+    });
+  });
+
   describe('when the client is master', () => {
     const stateMaster: GlobalState = {
       ...initialState,
       initialised: true,
       myClientName: 'the-master-client',
+      clientList: [{ name: 'the-master-client', lastPing: 0 }],
       player: {
         ...nullPlayer,
         master: 'the-master-client',
@@ -101,6 +189,10 @@ describe(useMaster.name, () => {
       ...initialState,
       initialised: true,
       myClientName: 'a-slave-client',
+      clientList: [
+        { name: 'the-master-client', lastPing: 0 },
+        { name: 'a-slave-client', lastPing: 0 },
+      ],
       player: {
         ...nullPlayer,
         master: 'the-master-client',

+ 18 - 5
gmus/src/hooks/master.ts

@@ -1,14 +1,11 @@
 import { Dispatch, useEffect, useRef } from 'react';
 
-import { LocalAction, stateSet } from '../actions';
+import { LocalAction, masterRetaken, stateSet } from '../actions';
 import { masterStateUpdateTimeout } from '../constants/system';
 import { GlobalState } from '../reducer';
 import { isMaster } from '../selectors';
 
-export function useMaster(
-  state: Pick<GlobalState, 'initialised' | 'player' | 'myClientName'>,
-  dispatch: Dispatch<LocalAction>,
-): void {
+export function useMaster(state: GlobalState, dispatch: Dispatch<LocalAction>): void {
   const clientIsMaster = isMaster(state);
 
   const masterUpdateTimer = useRef<number>(0);
@@ -30,4 +27,20 @@ export function useMaster(
       dispatch(stateSet({ master: state.myClientName }));
     }
   }, [dispatch, shouldInitMaster, state.myClientName]);
+
+  const masterWentAway =
+    state.initialised && !state.clientList.some(({ name }) => name === state.player.master);
+  const retakeControlTimer = useRef<number>(0);
+
+  useEffect(() => {
+    if (masterWentAway) {
+      retakeControlTimer.current = window.setTimeout(() => {
+        dispatch(masterRetaken());
+      }, Math.floor(Math.random() * 1000));
+    }
+
+    return (): void => {
+      clearTimeout(retakeControlTimer.current);
+    };
+  }, [dispatch, masterWentAway, state.myClientName]);
 }

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

@@ -4,6 +4,7 @@ import {
   ActionStateSetRemote,
   ActionTypeLocal,
   ActionTypeRemote,
+  masterRetaken,
   nameSet,
   seeked,
   stateSet,
@@ -364,4 +365,38 @@ describe(globalReducer.name, () => {
       });
     });
   });
+
+  describe(ActionTypeLocal.MasterRetaken, () => {
+    const action = masterRetaken();
+
+    const stateBefore: GlobalState = {
+      ...initialState,
+      myClientName: 'my-client',
+      player: {
+        songId: 174,
+        playing: true,
+        master: 'some-master-client',
+        currentTime: 13,
+        seekTime: -1,
+      },
+    };
+
+    it('should set the master player to the current client', () => {
+      expect.assertions(1);
+      const result = globalReducer(stateBefore, action);
+      expect(result.player.master).toBe('my-client');
+    });
+
+    it('should seek to the current time', () => {
+      expect.assertions(1);
+      const result = globalReducer(stateBefore, action);
+      expect(result.player.seekTime).toBe(13);
+    });
+
+    it('should pause the client', () => {
+      expect.assertions(1);
+      const result = globalReducer(stateBefore, action);
+      expect(result.player.playing).toBe(false);
+    });
+  });
 });

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

@@ -77,6 +77,17 @@ export function globalReducer(state: GlobalState, action: AnyAction): GlobalStat
       }
       return { ...state, player: { ...state.player, seekTime: action.payload } };
 
+    case ActionTypeLocal.MasterRetaken:
+      return {
+        ...state,
+        player: {
+          ...state.player,
+          master: state.myClientName,
+          seekTime: state.player.currentTime,
+          playing: false,
+        },
+      };
+
     default:
       return state;
   }