소스 검색

feat: synchronised playback time demo

    * feat: change playback position remotely
    * fix: reuse existing connection pool
Fela Maslen 5 년 전
부모
커밋
45f8bacee8

+ 18 - 21
gmus/src/actions/actions.ts

@@ -1,42 +1,39 @@
 import { Member, MusicPlayer } from '../types/state';
+import { ActionErrorOccurred } from './error';
+import { ActionLocal, ActionRemote, ActionTypeLocal, ActionTypeRemote } from './types';
 
-interface Action<T extends string = string, P = unknown> {
-  type: T;
-  payload: P;
-}
+export * from './types';
 
-export enum ActionTypeRemote {
-  StateSet = 'STATE_SET',
-  ClientListUpdated = 'CLIENT_LIST_UPDATED',
-}
+export type ActionStateSetRemote = ActionRemote<ActionTypeRemote.StateSet, MusicPlayer | null>;
 
-// Remote actions - these only come FROM the socket
-export type ActionStateSetRemote = Action<ActionTypeRemote.StateSet, MusicPlayer | null>;
-
-export type ActionClientListUpdated = Action<ActionTypeRemote.ClientListUpdated, Member[]>;
+export type ActionClientListUpdated = ActionRemote<ActionTypeRemote.ClientListUpdated, Member[]>;
 
 export type RemoteAction = ActionStateSetRemote | ActionClientListUpdated;
 
-// Local actions - these are dispatched from this client
-export enum ActionTypeLocal {
-  NameSet = '@@local/NAME_SET',
-  StateSet = '@@local/STATE_SET',
-}
-
-export type ActionNameSet = Action<ActionTypeLocal.NameSet, string>;
+export type ActionNameSet = ActionLocal<ActionTypeLocal.NameSet, string>;
 
 export const nameSet = (name: string): ActionNameSet => ({
   type: ActionTypeLocal.NameSet,
   payload: name,
 });
 
-export type ActionStateSetLocal = Action<ActionTypeLocal.StateSet, Partial<MusicPlayer>>;
+export type ActionStateSetLocal = ActionLocal<
+  ActionTypeLocal.StateSet,
+  Omit<Partial<MusicPlayer>, 'seekTime'>
+>;
 
 export const stateSet = (state: Partial<MusicPlayer> = {}): ActionStateSetLocal => ({
   type: ActionTypeLocal.StateSet,
   payload: state,
 });
 
-export type LocalAction = ActionNameSet | ActionStateSetLocal;
+export type ActionSeeked = ActionLocal<ActionTypeLocal.Seeked, number>;
+
+export const seeked = (time: number): ActionSeeked => ({
+  type: ActionTypeLocal.Seeked,
+  payload: time,
+});
+
+export type LocalAction = ActionErrorOccurred | ActionNameSet | ActionStateSetLocal | ActionSeeked;
 
 export type AnyAction = LocalAction | RemoteAction;

+ 22 - 0
gmus/src/actions/error.ts

@@ -0,0 +1,22 @@
+import { ActionLocal, ActionTypeLocal } from './types';
+
+export enum ErrorLevel {
+  Debug,
+  Warn,
+  Err,
+}
+
+type ErrorType = {
+  message: string;
+  level: ErrorLevel;
+};
+
+export type ActionErrorOccurred = ActionLocal<ActionTypeLocal.ErrorOccurred, ErrorType>;
+
+export const errorOccurred = (
+  message: string,
+  level: ErrorLevel = ErrorLevel.Err,
+): ActionErrorOccurred => ({
+  type: ActionTypeLocal.ErrorOccurred,
+  payload: { message, level },
+});

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

@@ -0,0 +1,25 @@
+// Remote actions - these only come FROM the socket
+export enum ActionTypeRemote {
+  StateSet = 'STATE_SET',
+  ClientListUpdated = 'CLIENT_LIST_UPDATED',
+}
+
+// Local actions - these are dispatched from this client
+export enum ActionTypeLocal {
+  ErrorOccurred = '@@local/ERROR_OCCURRED',
+  NameSet = '@@local/NAME_SET',
+  StateSet = '@@local/STATE_SET',
+  Seeked = '@@local/SEEKED',
+}
+
+interface Action<T extends string = string, P = unknown> {
+  type: T;
+  payload: P;
+}
+
+export type ActionRemote<T extends ActionTypeRemote = ActionTypeRemote, P = unknown> = Action<
+  T,
+  P
+> & { fromClient?: string | null };
+
+export type ActionLocal<T extends ActionTypeLocal = ActionTypeLocal, P = unknown> = Action<T, P>;

+ 32 - 15
gmus/src/components/gmus/index.tsx

@@ -1,20 +1,20 @@
-import { useThrottleCallback } from '@react-hook/throttle';
 import React, { Dispatch, useCallback, useEffect, useRef, useState } from 'react';
 
-import { AnyAction, stateSet } from '../../actions';
+import { LocalAction, seeked, stateSet } from '../../actions';
 import { masterStateUpdateTimeout } from '../../constants/system';
-import { useDispatchEffects, useKeepalive } from '../../hooks/socket';
+import { useKeepalive } from '../../hooks/socket';
 import { GlobalState } from '../../reducer/types';
+import { getSongUrl } from '../../utils/url';
 import { ClientList } from '../client-list';
 import { Player } from '../player';
 
 export type Props = {
   socket: WebSocket;
   state: GlobalState;
-  dispatch: Dispatch<AnyAction>;
+  dispatch: Dispatch<LocalAction>;
 };
 
-function useMaster(dispatch: Dispatch<AnyAction>, isMaster: boolean): void {
+function useMaster(dispatch: Dispatch<LocalAction>, isMaster: boolean): void {
   const masterUpdateTimer = useRef<number>(0);
   useEffect(() => {
     if (isMaster) {
@@ -31,7 +31,6 @@ function useMaster(dispatch: Dispatch<AnyAction>, isMaster: boolean): void {
 
 export const Gmus: React.FC<Props> = ({ socket, state, dispatch }) => {
   useKeepalive(socket);
-  useDispatchEffects(socket, state);
 
   const { clientList, player, myClientName } = state;
 
@@ -50,29 +49,37 @@ export const Gmus: React.FC<Props> = ({ socket, state, dispatch }) => {
         songId: tempSongId,
         currentTime: 0,
         playing: true,
-        master: myClientName,
       }),
     );
-  }, [dispatch, tempSongId, myClientName]);
+  }, [dispatch, tempSongId]);
 
   const playPause = useCallback(() => {
     dispatch(stateSet({ playing: !player.playing }));
   }, [dispatch, player.playing]);
 
+  const takeControl = useCallback(() => {
+    dispatch(stateSet({ master: myClientName }));
+  }, [dispatch, myClientName]);
+
   const onTimeUpdate = useCallback(
     (currentTime: number): void => {
-      if (isMaster) {
-        dispatch(stateSet({ currentTime }));
-      }
+      dispatch(stateSet({ currentTime }));
     },
-    [dispatch, isMaster],
+    [dispatch],
+  );
+
+  const seekTo = useCallback(
+    (time: number) => {
+      dispatch(seeked(time));
+    },
+    [dispatch],
   );
-  const onTimeUpdateThrottled = useThrottleCallback(onTimeUpdate, 1000);
 
   return (
     <div>
       <div>
         <button onClick={playPause}>{player.playing ? 'Pause' : 'Play'}</button>
+        {!isMaster && <button onClick={takeControl}>Take control</button>}
       </div>
       <div>
         <input
@@ -83,6 +90,14 @@ export const Gmus: React.FC<Props> = ({ socket, state, dispatch }) => {
         />
         <button onClick={playSong}>Change track</button>
       </div>
+      <div>
+        <input
+          type="number"
+          onBlur={({ target: { value } }): void => seekTo(Number(value))}
+          min={0}
+          step={0.01}
+        />
+      </div>
       <ClientList myClientName={myClientName} clients={clientList} />
       <div>
         <h6>Player State</h6>
@@ -90,9 +105,11 @@ export const Gmus: React.FC<Props> = ({ socket, state, dispatch }) => {
       </div>
       {isMaster && !!player.songId && (
         <Player
+          src={getSongUrl(player.songId)}
           playing={player.playing}
-          onTimeUpdate={onTimeUpdateThrottled}
-          songId={player.songId}
+          seekTime={player.seekTime}
+          onTimeUpdate={onTimeUpdate}
+          timeUpdateFPS={1}
         />
       )}
     </div>

+ 0 - 0
gmus/src/components/player/index.spec.tsx


+ 122 - 15
gmus/src/components/player/index.tsx

@@ -1,36 +1,143 @@
 /* eslint-disable jsx-a11y/media-has-caption */
-import React, { CSSProperties, useEffect, useRef } from 'react';
-import { getSongUrl } from '../../utils/url';
+import { useThrottleCallback } from '@react-hook/throttle';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
 
 type Props = {
+  src: string;
   playing: boolean;
+  seekTime: number;
   onTimeUpdate: (time: number) => void;
-  songId: number;
+  timeUpdateFPS: number;
 };
 
-const hidden: CSSProperties = { visibility: 'hidden' };
+type AudioState = {
+  bufferRange: {
+    start: number;
+    end: number;
+  };
+  time: number;
+  duration: number;
+  paused: boolean;
+  waiting: boolean;
+};
+
+const initialAudioState: AudioState = {
+  bufferRange: { start: 0, end: 0 },
+  time: 0,
+  duration: 0,
+  paused: true,
+  waiting: false,
+};
+
+function parseBufferRange(bufferRange: TimeRanges): AudioState['bufferRange'] {
+  if (!bufferRange.length) {
+    return initialAudioState.bufferRange;
+  }
+  return { start: bufferRange.start(0), end: bufferRange.end(0) };
+}
 
-export const Player: React.FC<Props> = ({ playing, onTimeUpdate, songId }) => {
+export const Player: React.FC<Props> = ({
+  src,
+  playing,
+  seekTime,
+  onTimeUpdate: reportTimeUpdate,
+  timeUpdateFPS,
+}) => {
   const audio = useRef<HTMLAudioElement | null>(null);
+  const [state, setState] = useState(initialAudioState);
+
+  const onPlay = useCallback(() => setState((last) => ({ ...last, paused: false })), []);
+  const onPause = useCallback(() => setState((last) => ({ ...last, paused: true })), []);
+  const onWaiting = useCallback(() => setState((last) => ({ ...last, waiting: true })), []);
+  const onPlaying = useCallback(() => setState((last) => ({ ...last, waiting: false })), []);
+
+  const onDurationChange = useCallback(() => {
+    setState((last) => {
+      if (!audio.current) {
+        return last;
+      }
+      const { duration, buffered } = audio.current;
+      return {
+        ...last,
+        duration,
+        bufferRange: parseBufferRange(buffered),
+      };
+    });
+  }, []);
+
+  const onTimeUpdate = useCallback(() => {
+    setState((last) => (audio.current ? { ...last, time: audio.current.currentTime } : last));
+  }, []);
+  const onTimeUpdateThrottled = useThrottleCallback(onTimeUpdate, timeUpdateFPS);
 
   useEffect(() => {
+    reportTimeUpdate(state.time);
+  }, [state.time, reportTimeUpdate]);
+
+  const onProgress = useCallback(() => {
+    setState((last) =>
+      audio.current ? { ...last, bufferRange: parseBufferRange(audio.current.buffered) } : last,
+    );
+  }, []);
+
+  const play = useCallback(() => {
+    audio.current?.play();
+  }, []);
+
+  const pause = useCallback(() => {
+    audio.current?.pause();
+  }, []);
+
+  const shouldSeekTo = useRef<number>(-1);
+  const onCanPlay = useCallback(() => {
     if (audio.current) {
-      audio.current.ontimeupdate = (): void => {
-        onTimeUpdate(audio.current?.currentTime ?? 0);
-      };
+      if (state.duration && shouldSeekTo.current !== -1) {
+        audio.current.currentTime = Math.min(state.duration, Math.max(0, shouldSeekTo.current));
+        shouldSeekTo.current = -1;
+      }
     }
-  }, [onTimeUpdate]);
+  }, [state.duration]);
+
+  const seek = useCallback(
+    (time: number) => {
+      if (audio.current && state.duration) {
+        audio.current.currentTime = Math.min(state.duration, Math.max(0, time));
+      } else {
+        shouldSeekTo.current = time;
+      }
+    },
+    [state.duration],
+  );
 
+  const lastSeekTime = useRef<number>(-1);
   useEffect(() => {
-    if (!audio.current) {
-      return;
+    if (seekTime !== lastSeekTime.current && seekTime !== -1) {
+      lastSeekTime.current = seekTime;
+      seek(seekTime);
     }
+  }, [seekTime, seek]);
+
+  useEffect(() => {
     if (playing) {
-      audio.current.play();
+      play();
     } else {
-      audio.current.pause();
+      pause();
     }
-  }, [playing]);
+  }, [src, playing, play, pause]);
 
-  return <audio ref={audio} src={getSongUrl(songId)} style={hidden} />;
+  return (
+    <audio
+      controls={false}
+      onCanPlay={onCanPlay}
+      onDurationChange={onDurationChange}
+      onPause={onPause}
+      onPlay={onPlay}
+      onPlaying={onPlaying}
+      onProgress={onProgress}
+      onTimeUpdate={onTimeUpdateThrottled}
+      onWaiting={onWaiting}
+      ref={audio}
+      src={src}
+    />
+  );
 };

+ 6 - 4
gmus/src/components/root/index.tsx

@@ -1,14 +1,14 @@
 import React, { Reducer, useCallback, useReducer } from 'react';
 import { AnyAction, nameSet } from '../../actions';
 
-import { useOnMessage, useSocket } from '../../hooks/socket';
-import { composedGlobalReducer, GlobalState, init, initialState } from '../../reducer';
+import { useDispatchWithEffects, useOnMessage, useSocket } from '../../hooks/socket';
+import { globalReducer, GlobalState, init, initialState } from '../../reducer';
 import { Gmus } from '../gmus';
 import { Identify } from '../identify';
 
 export const Root: React.FC = () => {
   const [state, dispatch] = useReducer<Reducer<GlobalState, AnyAction>, GlobalState>(
-    composedGlobalReducer,
+    globalReducer,
     initialState,
     init,
   );
@@ -24,9 +24,11 @@ export const Root: React.FC = () => {
 
   const { name, onIdentify, socket, connecting, connected, error } = useSocket(onMessage, onLogin);
 
+  const dispatchWithEffects = useDispatchWithEffects(state, dispatch, socket);
+
   if (!(socket && connected && name) || error) {
     return <Identify connecting={connecting} onIdentify={onIdentify} />;
   }
 
-  return <Gmus socket={socket} state={state} dispatch={dispatch} />;
+  return <Gmus socket={socket} state={state} dispatch={dispatchWithEffects} />;
 };

+ 52 - 3
gmus/src/effects/effects.spec.ts

@@ -1,5 +1,11 @@
-import { ActionStateSetRemote, ActionTypeLocal, ActionTypeRemote, stateSet } from '../actions';
-import { composedGlobalReducer, initialState } from '../reducer';
+import {
+  ActionStateSetRemote,
+  ActionTypeLocal,
+  ActionTypeRemote,
+  seeked,
+  stateSet,
+} from '../actions';
+import { GlobalState, initialState } from '../reducer';
 import { MusicPlayer } from '../types/state';
 import { globalEffects } from './effects';
 
@@ -12,10 +18,16 @@ describe(globalEffects.name, () => {
         songId: 123,
         playing: false,
         currentTime: 83,
+        seekTime: 87,
         master: 'my-client',
       };
 
-      const result = globalEffects(composedGlobalReducer(initialState, stateSet(localPlayer)));
+      const prevState: GlobalState = {
+        ...initialState,
+        myClientName: 'my-client-name',
+      };
+
+      const result = globalEffects(prevState, stateSet(localPlayer));
 
       expect(result).toStrictEqual<ActionStateSetRemote>({
         type: ActionTypeRemote.StateSet,
@@ -23,4 +35,41 @@ describe(globalEffects.name, () => {
       });
     });
   });
+
+  describe(ActionTypeLocal.Seeked, () => {
+    const stateMaster: GlobalState = {
+      ...initialState,
+      player: {
+        songId: 123,
+        playing: false,
+        currentTime: 83,
+        seekTime: 87,
+        master: 'my-client-name',
+      },
+      myClientName: 'my-client-name',
+    };
+
+    const stateSlave: GlobalState = {
+      ...initialState,
+      player: { ...initialState.player, master: 'some-master-client' },
+      myClientName: 'some-slave-client',
+    };
+
+    describe.each`
+      clientType   | state
+      ${'master'}  | ${stateMaster}
+      ${'a slave'} | ${stateSlave}
+    `('when the client is $clientType', ({ state }) => {
+      it('should create a remote state set action', () => {
+        expect.assertions(1);
+
+        const result = globalEffects(state, seeked(776));
+
+        expect(result).toStrictEqual<ActionStateSetRemote>({
+          type: ActionTypeRemote.StateSet,
+          payload: { ...state.player, seekTime: 776 },
+        });
+      });
+    });
+  });
 });

+ 14 - 9
gmus/src/effects/effects.ts

@@ -1,14 +1,19 @@
-import { ActionTypeLocal, ActionTypeRemote, RemoteAction } from '../actions';
-import { GlobalState } from '../reducer';
+import { ActionTypeLocal, ActionTypeRemote, LocalAction, RemoteAction } from '../actions';
+import { GlobalState } from '../reducer/types';
 
-export function globalEffects(state: GlobalState): RemoteAction | null {
-  if (!state.lastAction) {
-    return null;
-  }
-
-  switch (state.lastAction.type) {
+export function globalEffects(prevState: GlobalState, action: LocalAction): RemoteAction | null {
+  switch (action.type) {
     case ActionTypeLocal.StateSet:
-      return { type: ActionTypeRemote.StateSet, payload: state.player };
+      return {
+        type: ActionTypeRemote.StateSet,
+        payload: { ...prevState.player, ...action.payload },
+      };
+
+    case ActionTypeLocal.Seeked:
+      return {
+        type: ActionTypeRemote.StateSet,
+        payload: { ...prevState.player, seekTime: action.payload },
+      };
 
     default:
       return null;

+ 80 - 14
gmus/src/hooks/socket.spec.tsx

@@ -3,11 +3,11 @@ import WS from 'jest-websocket-mock';
 import React, { Dispatch } from 'react';
 import * as storageHooks from 'react-storage-hooks';
 
-import { AnyAction, RemoteAction } from '../actions';
+import { AnyAction, LocalAction, RemoteAction } from '../actions';
 import * as effects from '../effects/effects';
 import { GlobalState } from '../reducer';
 
-import { useDispatchEffects, useOnMessage, useSocket } from './socket';
+import { useOnMessage, useDispatchWithEffects, useSocket } from './socket';
 
 jest.mock('nanoid', () => ({
   nanoid: (): string => 'A5v3D',
@@ -49,33 +49,99 @@ describe(useOnMessage.name, () => {
   });
 });
 
-describe(useDispatchEffects.name, () => {
+describe(useDispatchWithEffects.name, () => {
   const someAction = ({
     type: 'SOME_ACTION',
     payload: 'yes',
-  } as unknown) as RemoteAction;
+  } as unknown) as LocalAction;
+
+  const state = ({ my: 'state' } as unknown) as GlobalState;
 
-  const state = {} as GlobalState;
+  const dispatch: Dispatch<AnyAction> = jest.fn();
 
   const socket = ({
     send: jest.fn(),
+    OPEN: WebSocket.OPEN,
+    readyState: WebSocket.OPEN,
   } as unknown) as WebSocket;
 
+  const someEffect = ({
+    type: 'SOME_EFFECT',
+    payload: {
+      fromClient: 'us',
+      data: 'yes',
+    },
+  } as unknown) as RemoteAction;
+
   const TestComponent: React.FC = () => {
-    useDispatchEffects(socket, state);
-    return null;
+    const dispatchWithEffects = useDispatchWithEffects(state, dispatch, socket);
+
+    return (
+      <>
+        <button onClick={(): void => dispatchWithEffects(someAction)}>Dispatch!</button>
+      </>
+    );
   };
 
-  describe('when an action is dispatched locally which produces an effect', () => {
-    it('should send the effect action to the socket', async () => {
-      expect.assertions(2);
+  describe('when an action is dispatched', () => {
+    let globalEffectsSpy: jest.SpyInstance;
+
+    describe('and no effect is associated', () => {
+      beforeEach(() => {
+        globalEffectsSpy = jest.spyOn(effects, 'globalEffects').mockReturnValueOnce(null);
+      });
 
-      jest.spyOn(effects, 'globalEffects').mockReturnValueOnce(someAction);
+      it('should dispatch the action to the local store', () => {
+        expect.assertions(2);
+        const { getByText } = render(<TestComponent />);
+        act(() => {
+          fireEvent.click(getByText('Dispatch!'));
+        });
 
-      render(<TestComponent />);
+        expect(dispatch).toHaveBeenCalledTimes(1);
+        expect(dispatch).toHaveBeenCalledWith(someAction);
+      });
+
+      it('should not send a message to the socket', () => {
+        expect.assertions(1);
+        const { getByText } = render(<TestComponent />);
+        act(() => {
+          fireEvent.click(getByText('Dispatch!'));
+        });
+
+        expect(socket.send).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('and an effect is associated', () => {
+      beforeEach(() => {
+        globalEffectsSpy = jest.spyOn(effects, 'globalEffects').mockReturnValueOnce(someEffect);
+      });
 
-      expect(socket.send).toHaveBeenCalledTimes(1);
-      expect(socket.send).toHaveBeenCalledWith(JSON.stringify(someAction));
+      it('should dispatch the action to the local store', () => {
+        expect.assertions(2);
+        const { getByText } = render(<TestComponent />);
+        act(() => {
+          fireEvent.click(getByText('Dispatch!'));
+        });
+
+        expect(dispatch).toHaveBeenCalledTimes(1);
+        expect(dispatch).toHaveBeenCalledWith(someAction);
+      });
+
+      it('should send a message to the socket', () => {
+        expect.assertions(4);
+        const { getByText } = render(<TestComponent />);
+        act(() => {
+          fireEvent.click(getByText('Dispatch!'));
+        });
+
+        expect(globalEffectsSpy).toHaveBeenCalledTimes(1);
+        expect(globalEffectsSpy).toHaveBeenCalledWith(state, someAction);
+
+        expect(socket.send).toHaveBeenCalledTimes(1);
+        expect(socket.send).toHaveBeenCalledWith(JSON.stringify(someEffect));
+      });
     });
   });
 });

+ 28 - 8
gmus/src/hooks/socket.ts

@@ -2,7 +2,8 @@ import { nanoid } from 'nanoid';
 import { Dispatch, useCallback, useEffect, useRef, useState } from 'react';
 import { useStorageState } from 'react-storage-hooks';
 
-import { AnyAction } from '../actions';
+import { AnyAction, LocalAction, RemoteAction } from '../actions';
+import { errorOccurred } from '../actions/error';
 import { socketKeepaliveTimeout } from '../constants/system';
 import { globalEffects } from '../effects';
 import { GlobalState } from '../reducer';
@@ -16,23 +17,42 @@ export function useOnMessage(dispatch: Dispatch<AnyAction>): OnMessage {
   return useCallback<OnMessage>(
     ({ data }: MessageEvent<unknown>): void => {
       try {
-        const action = JSON.parse(data as string) as AnyAction;
+        const action = JSON.parse(data as string) as RemoteAction;
         dispatch(action);
       } catch (err) {
-        console.warn('Error parsing message from websocket', err.message);
+        dispatch(errorOccurred(`Error parsing message from websocket: ${err.message}`));
       }
     },
     [dispatch],
   );
 }
 
-export function useDispatchEffects(socket: WebSocket, state: GlobalState): void {
+export function useDispatchWithEffects(
+  state: GlobalState,
+  dispatch: Dispatch<AnyAction>,
+  socket: WebSocket | null,
+): Dispatch<LocalAction> {
+  const [lastAction, setLastAction] = useState<LocalAction | null>(null);
+
+  const dispatchWithEffects = useCallback(
+    (action: LocalAction): void => {
+      setLastAction(action);
+      dispatch(action);
+    },
+    [dispatch],
+  );
+
   useEffect(() => {
-    const remoteEffect = globalEffects(state);
-    if (remoteEffect) {
-      socket.send(JSON.stringify(remoteEffect));
+    if (lastAction) {
+      const effect = globalEffects(state, lastAction);
+      setLastAction(null);
+      if (effect && socket && socket.readyState === socket.OPEN) {
+        socket.send(JSON.stringify(effect));
+      }
     }
-  }, [socket, state]);
+  }, [state, lastAction, socket]);
+
+  return dispatchWithEffects;
 }
 
 export function useSocket(

+ 275 - 97
gmus/src/reducer/reducer.spec.ts

@@ -5,130 +5,176 @@ import {
   ActionTypeLocal,
   ActionTypeRemote,
   nameSet,
+  seeked,
   stateSet,
 } from '../actions';
 import { MusicPlayer } from '../types/state';
-import { composedGlobalReducer, globalReducer, initialState, nullPlayer } from './reducer';
+import { globalReducer, initialState, nullPlayer } from './reducer';
 import { GlobalState } from './types';
 
 describe(globalReducer.name, () => {
   describe(ActionTypeRemote.StateSet, () => {
-    const action: ActionStateSetRemote = {
-      type: ActionTypeRemote.StateSet,
-      payload: {
-        songId: 123,
-        playing: true,
-        currentTime: 75,
-        master: 'some-client',
-      },
-    };
-
-    it('should set the player state', () => {
-      expect.assertions(1);
-      const result = globalReducer(initialState, action);
-
-      expect(result.player).toStrictEqual({
-        songId: 123,
-        playing: true,
-        currentTime: 75,
-        master: 'some-client',
-      });
-    });
-
     describe('when the client is master', () => {
       const stateMaster: GlobalState = {
         ...initialState,
         player: {
           ...nullPlayer,
-          master: 'some-client',
+          master: 'some-master-client',
           currentTime: 31,
+          seekTime: -1,
         },
-        myClientName: 'some-client',
+        myClientName: 'some-master-client',
       };
 
-      it('should not update the currentTime', () => {
-        expect.assertions(1);
-        const result = globalReducer(stateMaster, action);
+      describe('and the action came from a different client', () => {
+        const actionFromOtherClient: ActionStateSetRemote = {
+          type: ActionTypeRemote.StateSet,
+          fromClient: 'other-client',
+          payload: {
+            songId: 123,
+            playing: true,
+            currentTime: 75,
+            seekTime: 87,
+            master: 'some-master-client',
+          },
+        };
 
-        expect(result.player).toStrictEqual<MusicPlayer>({
-          songId: 123,
-          playing: true,
-          currentTime: 31, // not updated from the action
-          master: 'some-client',
+        it('should update the state, including seekTime', () => {
+          expect.assertions(1);
+          const result = globalReducer(stateMaster, actionFromOtherClient);
+
+          expect(result.player).toStrictEqual<MusicPlayer>({
+            songId: 123,
+            playing: true,
+            currentTime: 75,
+            seekTime: 87,
+            master: 'some-master-client',
+          });
         });
       });
-    });
 
-    describe('when the client was the master but no longer', () => {
-      const stateDifferentMaster: GlobalState = {
-        ...initialState,
-        player: {
-          ...nullPlayer,
-          master: 'a-client-16b3',
-          currentTime: 31,
-        },
-        myClientName: 'a-client-16b3',
-      };
+      describe('and the action came from ourselves', () => {
+        const actionFromOurselves: ActionStateSetRemote = {
+          type: ActionTypeRemote.StateSet,
+          fromClient: 'some-master-client',
+          payload: {
+            songId: 123,
+            playing: true,
+            currentTime: 75,
+            seekTime: 87,
+            master: 'some-master-client',
+          },
+        };
 
-      it('should update the currentTime', () => {
-        expect.assertions(1);
-        const result = globalReducer(stateDifferentMaster, action);
+        it('should update the state, except the seekTime', () => {
+          expect.assertions(1);
+          const result = globalReducer(stateMaster, actionFromOurselves);
 
-        expect(result.player).toStrictEqual<MusicPlayer>({
-          songId: 123,
-          playing: true,
-          currentTime: 75,
-          master: 'some-client',
+          expect(result.player).toStrictEqual<MusicPlayer>({
+            songId: 123,
+            playing: true,
+            currentTime: 75,
+            seekTime: -1,
+            master: 'some-master-client',
+          });
         });
       });
-    });
-  });
 
-  describe(ActionTypeLocal.StateSet, () => {
-    const action = stateSet({
-      songId: 123,
-      playing: true,
-      currentTime: 75,
-      master: 'some-client',
-    });
+      describe('but will no longer be master', () => {
+        const actionToSlave: ActionStateSetRemote = {
+          type: ActionTypeRemote.StateSet,
+          fromClient: 'other-client',
+          payload: {
+            songId: 123,
+            playing: true,
+            currentTime: 75,
+            seekTime: 87,
+            master: 'other-master-client',
+          },
+        };
 
-    it('should set the player state', () => {
-      expect.assertions(1);
-      const result = globalReducer(initialState, action);
+        it('should update the state, setting seekTime to -1', () => {
+          expect.assertions(1);
+          const result = globalReducer(stateMaster, actionToSlave);
 
-      expect(result.player).toStrictEqual({
-        songId: 123,
-        playing: true,
-        currentTime: 75,
-        master: 'some-client',
+          expect(result.player).toStrictEqual<MusicPlayer>({
+            songId: 123,
+            playing: true,
+            currentTime: 75,
+            seekTime: -1,
+            master: 'other-master-client',
+          });
+        });
       });
     });
 
-    describe('when the state update is partial', () => {
-      const actionPartial: ActionStateSetLocal = stateSet({
-        playing: false,
-      });
+    describe('when the client is a slave', () => {
+      const stateSlave: GlobalState = {
+        ...initialState,
+        player: {
+          ...initialState.player,
+          master: 'some-master-client',
+        },
+        myClientName: 'my-client',
+      };
 
-      it('should update the given part of the state', () => {
-        expect.assertions(1);
+      describe.each`
+        case                    | fromClient
+        ${'the master client'}  | ${'some-master-client'}
+        ${'a different client'} | ${'other-client'}
+      `('and the action came from $case', ({ fromClient }) => {
+        const action: ActionStateSetRemote = {
+          type: ActionTypeRemote.StateSet,
+          fromClient,
+          payload: {
+            songId: 123,
+            playing: true,
+            currentTime: 75,
+            seekTime: 87,
+            master: 'some-master-client',
+          },
+        };
 
-        const result = globalReducer(globalReducer(initialState, action), actionPartial);
+        it('should set the player state, except seekTime', () => {
+          expect.assertions(1);
+          const result = globalReducer(stateSlave, action);
 
-        expect(result.player).toStrictEqual({
-          songId: 123,
-          playing: false,
-          currentTime: 75,
-          master: 'some-client',
+          expect(result.player).toStrictEqual({
+            songId: 123,
+            playing: true,
+            currentTime: 75,
+            seekTime: -1,
+            master: 'some-master-client',
+          });
         });
       });
-    });
-  });
 
-  describe(ActionTypeLocal.NameSet, () => {
-    it('should set the name', () => {
-      expect.assertions(1);
+      describe('but will be set to master', () => {
+        const actionToMaster: ActionStateSetRemote = {
+          type: ActionTypeRemote.StateSet,
+          fromClient: 'other-client',
+          payload: {
+            songId: 123,
+            playing: true,
+            currentTime: 75,
+            seekTime: 87,
+            master: 'my-client',
+          },
+        };
 
-      expect(globalReducer(initialState, nameSet('foo')).myClientName).toBe('foo');
+        it('should set the player state, including seekTime', () => {
+          expect.assertions(1);
+          const result = globalReducer(stateSlave, actionToMaster);
+
+          expect(result.player).toStrictEqual({
+            songId: 123,
+            playing: true,
+            currentTime: 75,
+            seekTime: 87,
+            master: 'my-client',
+          });
+        });
+      });
     });
   });
 
@@ -154,19 +200,151 @@ describe(globalReducer.name, () => {
       expect(result.clientList).toStrictEqual(action.payload);
     });
   });
-});
 
-describe(composedGlobalReducer.name, () => {
-  it('should set the lastAction prop', () => {
-    expect.assertions(1);
+  describe(ActionTypeLocal.NameSet, () => {
+    it('should set the name', () => {
+      expect.assertions(1);
 
-    const action: ActionStateSetRemote = {
-      type: ActionTypeRemote.StateSet,
-      payload: null,
-    };
+      expect(globalReducer(initialState, nameSet('foo')).myClientName).toBe('foo');
+    });
+  });
+
+  describe(ActionTypeLocal.StateSet, () => {
+    const action = stateSet({
+      songId: 123,
+      playing: true,
+      currentTime: 75,
+      seekTime: 87,
+      master: 'some-master-client',
+    });
+
+    describe('when the client is master', () => {
+      const stateMaster: GlobalState = {
+        ...initialState,
+        player: {
+          ...nullPlayer,
+          master: 'some-master-client',
+          currentTime: 31,
+          seekTime: -1,
+        },
+        myClientName: 'some-master-client',
+      };
+
+      it('should set the player state optimistically', () => {
+        expect.assertions(1);
+        const result = globalReducer(stateMaster, action);
+
+        expect(result.player).toStrictEqual<MusicPlayer>({
+          songId: 123,
+          playing: true,
+          currentTime: 75,
+          seekTime: 87,
+          master: 'some-master-client',
+        });
+      });
+
+      describe('when the state update is partial', () => {
+        const actionPartial: ActionStateSetLocal = stateSet({
+          songId: 3,
+          playing: true,
+        });
+
+        it('should update the given part of the state', () => {
+          expect.assertions(1);
+
+          const result = globalReducer(stateMaster, actionPartial);
+
+          expect(result.player).toStrictEqual({
+            ...nullPlayer,
+            master: 'some-master-client',
+            currentTime: 31,
+            seekTime: -1,
+            songId: 3,
+            playing: true,
+          });
+        });
+      });
+    });
+
+    describe('when the client is a slave', () => {
+      const stateSlave: GlobalState = {
+        ...initialState,
+        player: {
+          ...initialState.player,
+          master: 'some-master-client',
+        },
+        myClientName: 'my-client',
+      };
+
+      it('should not update the state optimistically', () => {
+        expect.assertions(1);
+        const result = globalReducer(stateSlave, action);
+
+        expect(result.player).toBe(stateSlave.player);
+      });
+
+      describe('but will be master', () => {
+        const actionToMaster = stateSet({
+          master: 'my-client',
+        });
 
-    const result = composedGlobalReducer(initialState, action);
+        it('should set the seekTime to the currentTime value', () => {
+          expect.assertions(1);
+          const result = globalReducer(stateSlave, actionToMaster);
 
-    expect(result.lastAction).toBe(action);
+          expect(result.player.seekTime).toBe(stateSlave.player.currentTime);
+        });
+      });
+    });
+  });
+
+  describe(ActionTypeLocal.Seeked, () => {
+    const action = seeked(173);
+
+    describe('when the client is master', () => {
+      const stateMaster: GlobalState = {
+        ...initialState,
+        player: {
+          songId: 123,
+          playing: true,
+          currentTime: 31,
+          seekTime: -1,
+          master: 'some-master-client',
+        },
+        myClientName: 'some-master-client',
+      };
+
+      it('should set the seek time', () => {
+        expect.assertions(1);
+        const result = globalReducer(stateMaster, action);
+
+        expect(result.player).toStrictEqual<MusicPlayer>({
+          songId: 123,
+          playing: true,
+          currentTime: 31,
+          seekTime: 173,
+          master: 'some-master-client',
+        });
+      });
+    });
+
+    describe('when the client is a slave', () => {
+      const stateSlave: GlobalState = {
+        ...initialState,
+        player: {
+          ...initialState.player,
+          seekTime: 101,
+          master: 'some-master-client',
+        },
+        myClientName: 'my-client',
+      };
+
+      it('should not update the player', () => {
+        expect.assertions(1);
+        const result = globalReducer(stateSlave, action);
+
+        expect(result.player).toBe(stateSlave.player);
+      });
+    });
   });
 });

+ 47 - 23
gmus/src/reducer/reducer.ts

@@ -1,5 +1,11 @@
-import { ActionStateSetRemote, ActionTypeLocal, ActionTypeRemote, AnyAction } from '../actions';
-import { isMaster } from '../selectors';
+import {
+  ActionStateSetLocal,
+  ActionStateSetRemote,
+  ActionTypeLocal,
+  ActionTypeRemote,
+  AnyAction,
+} from '../actions';
+import { isFromOurselves, isMaster, willBeMaster } from '../selectors';
 import { MusicPlayer } from '../types/state';
 import { GlobalState } from './types';
 
@@ -7,25 +13,47 @@ export const nullPlayer: MusicPlayer = {
   songId: null,
   playing: false,
   currentTime: 0,
+  seekTime: -1,
   master: '',
 };
 
 export const initialState: GlobalState = {
-  lastAction: null,
   player: nullPlayer,
   clientList: [],
   myClientName: '',
 };
 
+function shouldSetSeekTime(state: GlobalState, action: ActionStateSetRemote): boolean {
+  return willBeMaster(state, action) && !(isMaster(state) && isFromOurselves(state, action));
+}
+
 function onRemoteStateSet(state: GlobalState, action: ActionStateSetRemote): GlobalState {
-  const isAndWillBeMaster =
-    isMaster(state) && !(action.payload?.master && action.payload.master !== state.player.master);
+  const nextPlayer = action.payload ?? nullPlayer;
+  const seekTime = shouldSetSeekTime(state, action) ? nextPlayer.seekTime : -1;
+
+  const nextPlayerWithSeekTime: MusicPlayer = { ...nextPlayer, seekTime };
+
+  return { ...state, player: nextPlayerWithSeekTime };
+}
 
-  const currentTime = isAndWillBeMaster
-    ? state.player.currentTime
-    : action.payload?.currentTime ?? state.player.currentTime;
+function onLocalStateSet(state: GlobalState, action: ActionStateSetLocal): GlobalState {
+  const nextPlayer: MusicPlayer = { ...state.player, ...action.payload };
+
+  if (isMaster(state)) {
+    return { ...state, player: nextPlayer };
+  }
+
+  if (willBeMaster(state, action)) {
+    return {
+      ...state,
+      player: {
+        ...nextPlayer,
+        seekTime: nextPlayer.currentTime,
+      },
+    };
+  }
 
-  return { ...state, player: { ...(action.payload ?? nullPlayer), currentTime } };
+  return state;
 }
 
 export function globalReducer(state: GlobalState, action: AnyAction): GlobalState {
@@ -33,26 +61,22 @@ export function globalReducer(state: GlobalState, action: AnyAction): GlobalStat
     case ActionTypeRemote.StateSet:
       return onRemoteStateSet(state, action);
 
-    case ActionTypeLocal.StateSet:
-      return { ...state, player: { ...state.player, ...action.payload } };
+    case ActionTypeRemote.ClientListUpdated:
+      return { ...state, clientList: action.payload };
 
     case ActionTypeLocal.NameSet:
       return { ...state, myClientName: action.payload };
 
-    case ActionTypeRemote.ClientListUpdated:
-      return { ...state, clientList: action.payload };
+    case ActionTypeLocal.StateSet:
+      return onLocalStateSet(state, action);
+
+    case ActionTypeLocal.Seeked:
+      if (!isMaster(state)) {
+        return state;
+      }
+      return { ...state, player: { ...state.player, seekTime: action.payload } };
 
     default:
       return state;
   }
 }
-
-export function composedGlobalReducer(state: GlobalState, action: AnyAction): GlobalState {
-  return globalReducer(
-    {
-      ...state,
-      lastAction: action,
-    },
-    action,
-  );
-}

+ 0 - 2
gmus/src/reducer/types.ts

@@ -1,8 +1,6 @@
-import { AnyAction } from '../actions';
 import { Member, MusicPlayer } from '../types/state';
 
 export type GlobalState = {
-  lastAction: AnyAction | null;
   player: MusicPlayer;
   clientList: Member[];
   myClientName: string;

+ 9 - 0
gmus/src/selectors.ts

@@ -1,3 +1,12 @@
+import { ActionRemote, ActionStateSetLocal, ActionStateSetRemote } from './actions';
 import { GlobalState } from './reducer/types';
 
 export const isMaster = (state: GlobalState): boolean => state.player.master === state.myClientName;
+
+export const isFromOurselves = (state: GlobalState, action: ActionRemote): boolean =>
+  state.myClientName === action.fromClient;
+
+export const willBeMaster = (
+  state: GlobalState,
+  action: ActionStateSetLocal | ActionStateSetRemote,
+): boolean => state.myClientName === action.payload?.master;

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

@@ -7,5 +7,6 @@ export type MusicPlayer = {
   songId: number | null;
   playing: boolean;
   currentTime: number;
+  seekTime: number;
   master: string;
 };

+ 1 - 1
music-player/pkg/database/main.go

@@ -23,7 +23,7 @@ func GetConnection() *sqlx.DB {
     return db
   }
 
-  db := sqlx.MustConnect("pgx", config.GetConfig().DatabaseUrl)
+  db = sqlx.MustConnect("pgx", config.GetConfig().DatabaseUrl)
 
   return db
 }

+ 2 - 2
music-player/pkg/server/actions.go

@@ -32,11 +32,11 @@ func broadcastAction(thisPodClients *map[string]*Client, action *Action) []error
 }
 
 func publishAction(rdb *redis.Client, action *Action) error {
-  payload, err := json.Marshal(action)
+  pubsubPayload, err := json.Marshal(action)
   if err != nil {
     return err
   }
-  if _, err := rdb.Publish(TOPIC_BROADCAST, payload).Result(); err != nil {
+  if _, err := rdb.Publish(TOPIC_BROADCAST, pubsubPayload).Result(); err != nil {
     return err
   }
   return nil

+ 2 - 1
music-player/pkg/server/clients.go

@@ -81,7 +81,6 @@ func (c *Client) subscribeToMe(l *logger.Logger, rdb *redis.Client) {
   for {
     var actionFromClient Action
     if err := c.conn.ReadJSON(&actionFromClient); err != nil {
-      l.Verbose("calling close(c.closeChan) %s\n", c.name)
       close(c.closeChan)
       return
     }
@@ -104,6 +103,8 @@ func (c *Client) subscribeToMe(l *logger.Logger, rdb *redis.Client) {
 }
 
 func (c *Client) onConnect(l *logger.Logger, rdb *redis.Client) error {
+  l.Verbose("[Client connected] %s\n", c.name)
+
   if err := c.exposeToNetwork(l, rdb); err != nil {
     l.Error("Error exposing new client to network: %v\n", err)
     return err

+ 0 - 2
music-player/pkg/server/pubsub.go

@@ -27,8 +27,6 @@ func handleClientSubscription(thisPodClients *map[string]*Client) RouteHandler {
       return nil
     }
 
-    l.Verbose("[Client connected] %s\n", clientName)
-
     conn, err := upgrader.Upgrade(w, r, nil)
     if err != nil {
       w.WriteHeader(400)

+ 2 - 1
music-player/pkg/server/state.go

@@ -30,7 +30,8 @@ type Member struct {
 type MusicPlayer struct {
   SongId int 		`json:"songId"`
   Playing bool 		`json:"playing"`
-  PlayTimeSeconds int 	`json:"currentTime"`
+  CurrentTime float32 	`json:"currentTime"`
+  SeekTime int 		`json:"setTime"`
 
   Master string 	`json:"master"`
 }