ソースを参照

feat: play music on multiple (synchronised) clients simultaneously

    * chore: updated test fixtures
    * chore: updated tests
    * feat: play music when active (not just master)
    * feat: active state in status bar
    * feat: separate client list icon for active vs master
    * feat: toggle active state of selected client when pressing space bar
    * feat: action and effect to toggle client active state
    * feat(frontend): added activeClients array to state type
    * feat: added ActiveClients to player state (backend)
Fela Maslen 4 年 前
コミット
c61b2f7bb3

+ 14 - 12
gmus-backend/pkg/server/actions_test.go

@@ -45,12 +45,13 @@ var _ = Describe("Server actions", func() {
 					Type:       server.StateSet,
 					FromClient: &myClient,
 					Payload: server.MusicPlayer{
-						SongId:      &songId,
-						Playing:     true,
-						CurrentTime: 94,
-						SeekTime:    -1,
-						Master:      "some-master-client",
-						Queue:       &[]int{},
+						SongId:        &songId,
+						Playing:       true,
+						CurrentTime:   94,
+						SeekTime:      -1,
+						Master:        "some-master-client",
+						ActiveClients: &[]string{},
+						Queue:         &[]int{},
 					},
 				}
 				expectedActionString, jsonErr := json.Marshal(expectedAction)
@@ -87,12 +88,13 @@ var _ = Describe("Server actions", func() {
 				expectedAction := server.Action{
 					Type: server.StateSet,
 					Payload: server.MusicPlayer{
-						SongId:      nil,
-						Playing:     false,
-						CurrentTime: 0,
-						SeekTime:    -1,
-						Master:      "some-master-client",
-						Queue:       &[]int{},
+						SongId:        nil,
+						Playing:       false,
+						CurrentTime:   0,
+						SeekTime:      -1,
+						Master:        "some-master-client",
+						ActiveClients: &[]string{},
+						Queue:         &[]int{},
 					},
 				}
 

+ 12 - 6
gmus-backend/pkg/server/fixtures_test.go

@@ -9,7 +9,8 @@ const actionStateSetValid = `
     "currentTime": 94,
     "seekTime": -1,
     "queue": [],
-    "master": "some-master-client"
+    "master": "some-master-client",
+    "activeClients": []
   }
 }
 `
@@ -23,7 +24,8 @@ const actionStateSetIdNonPositive = `
     "currentTime": 94,
     "seekTime": -1,
     "queue": [],
-    "master": "some-master-client"
+    "master": "some-master-client",
+    "activeClients": []
   }
 }
 `
@@ -37,7 +39,8 @@ const actionStateSetSongIdNull = `
     "currentTime": 0,
     "seekTime": -1,
     "queue": [],
-    "master": "some-master-client"
+    "master": "some-master-client",
+    "activeClients": []
   }
 }
 `
@@ -51,7 +54,8 @@ const actionStateSetCurrentTimeNegative = `
     "currentTime": -32,
     "seekTime": -1,
     "queue": [],
-    "master": "some-master-client"
+    "master": "some-master-client",
+    "activeClients": []
   }
 }
 `
@@ -65,7 +69,8 @@ const actionStateSetSeekTimeTooNegative = `
     "currentTime": 13,
     "seekTime": -3,
     "queue": [],
-    "master": "some-master-client"
+    "master": "some-master-client",
+    "activeClients": []
   }
 }
 `
@@ -79,7 +84,8 @@ const actionStateSetMasterEmpty = `
     "currentTime": 13,
     "seekTime": -3,
     "queue": [],
-    "master": ""
+    "master": "",
+    "activeClients": []
   }
 }
 `

+ 12 - 9
gmus-backend/pkg/server/types.go

@@ -26,18 +26,21 @@ type Member struct {
 // an action across first should cause the other to obey the instruction
 // and treat the first as master.
 //
-// The master client is responsible for:
-// 1. Playing the music
-// 2. Keeping the server updated regularly about the current state
+// The master client is responsible for keeping the server updated regularly about the current state.
+//
+// Active clients are all those clients which are responsible for playing the song.
+// In contrast to master, there can be more than one active client.
+// The master client is automatically active.
 //
 // This type here is merely used for validation of client state messages.
 // Each client implementation MUST adhere to this spec.
 
 type MusicPlayer struct {
-	SongId      *int    `json:"songId" validate:"omitempty,gte=1"`
-	Playing     bool    `json:"playing" validate:"-"`
-	CurrentTime float32 `json:"currentTime" validate:"gte=0"`
-	SeekTime    float32 `json:"seekTime" validate:"min=-1"`
-	Master      string  `json:"master" validate:"required"`
-	Queue       *[]int  `json:"queue" validate:"required"`
+	SongId        *int      `json:"songId" validate:"omitempty,gte=1"`
+	Playing       bool      `json:"playing" validate:"-"`
+	CurrentTime   float32   `json:"currentTime" validate:"gte=0"`
+	SeekTime      float32   `json:"seekTime" validate:"min=-1"`
+	Master        string    `json:"master" validate:"required"`
+	ActiveClients *[]string `json:"activeClients" validate:"required"`
+	Queue         *[]int    `json:"queue" validate:"required"`
 }

+ 8 - 0
gmus-web/src/actions/actions.ts

@@ -45,6 +45,13 @@ export const masterSet = (name?: string): ActionMasterSet => ({
   payload: name,
 });
 
+export type ActionActiveClientToggled = ActionLocal<ActionTypeLocal.ActiveClientToggled, string>;
+
+export const activeClientToggled = (client: string): ActionActiveClientToggled => ({
+  type: ActionTypeLocal.ActiveClientToggled,
+  payload: client,
+});
+
 export type ActionPlayPaused = ActionLocal<ActionTypeLocal.PlayPaused, void>;
 
 export const playPaused = (): ActionPlayPaused => ({
@@ -97,6 +104,7 @@ export type LocalAction =
   | ActionSeeked
   | ActionPlayPaused
   | ActionMasterSet
+  | ActionActiveClientToggled
   | ActionSongInfoFetched
   | ActionQueuePushed
   | ActionQueueShifted

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

@@ -12,6 +12,7 @@ export enum ActionTypeLocal {
   StateSet = '@@local/STATE_SET',
   Seeked = '@@local/SEEKED',
   MasterSet = '@@local/MASTER_SET',
+  ActiveClientToggled = '@@local/ACTIVE_CLIENT_TOGGLED',
   PlayPaused = '@@local/PLAY_PAUSED',
   SongInfoFetched = '@@local/SONG_INFO_FETCHED',
   QueuePushed = '@@local/QUEUE_PUSHED',

+ 29 - 5
gmus-web/src/components/app.tsx

@@ -1,4 +1,12 @@
-import React, { Suspense, useCallback, useContext, useMemo } from 'react';
+import React, {
+  Suspense,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
 import { useMediaQuery } from 'react-responsive';
 import { StateInspector } from 'reinspect';
 
@@ -7,7 +15,7 @@ import { DispatchContext, StateContext } from '../context/state';
 import { usePlayQueue } from '../hooks/queue';
 import { useKeepalive } from '../hooks/socket';
 import { useCurrentlyPlayingSongInfo } from '../hooks/status';
-import { isMaster } from '../selectors';
+import { isActiveClient, isMaster } from '../selectors';
 import { getSongUrl } from '../utils/url';
 import { LoadingWrapper } from './identify';
 import { Interact, Props as InteractProps } from './interact';
@@ -50,13 +58,29 @@ export const App: React.FC<Props> = ({
     isDesktop,
   ]);
 
+  const shouldPlay = isActiveClient(state) && !!state.player.songId;
+  const wasPlaying = useRef<boolean>(false);
+  const [seekTime, setSeekTime] = useState<number>(-1);
+  useEffect(() => {
+    if (shouldPlay && !wasPlaying.current) {
+      wasPlaying.current = true;
+      setSeekTime(state.player.seekTime === -1 ? state.player.currentTime : state.player.seekTime);
+    } else if (state.player.seekTime !== -1) {
+      setSeekTime(state.player.seekTime);
+    }
+
+    if (!shouldPlay && wasPlaying.current) {
+      wasPlaying.current = false;
+    }
+  }, [shouldPlay, state.player.currentTime, state.player.seekTime]);
+
   return (
     <>
-      {isMaster(state) && !!state.player.songId && (
+      {shouldPlay && (
         <Player
-          src={getSongUrl(state.player.songId)}
+          src={getSongUrl(state.player.songId as number)}
           playing={state.player.playing}
-          seekTime={state.player.seekTime}
+          seekTime={seekTime}
           onTimeUpdate={onTimeUpdate}
           timeUpdateFPS={1}
           onEnded={onNext}

+ 29 - 1
gmus-web/src/components/ui/cmus/reducer/keypress.spec.ts

@@ -1,4 +1,11 @@
-import { masterSet, playPaused, queuePushed, queueRemoved, stateSet } from '../../../../actions';
+import {
+  activeClientToggled,
+  masterSet,
+  playPaused,
+  queuePushed,
+  queueRemoved,
+  stateSet,
+} from '../../../../actions';
 import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../../hooks/vim';
 
 import { CmusUIState, LibraryModeWindow, Overlay, View } from '../types';
@@ -271,6 +278,27 @@ describe(ActionTypeKeyPressed, () => {
     });
   });
 
+  describe('Space bar', () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.space };
+
+    describe('when in client list view', () => {
+      const state: CmusUIState = {
+        ...initialCmusUIState,
+        globalActionSerialNumber: 123,
+        view: View.ClientList,
+        clientList: {
+          active: 'our-client',
+        },
+      };
+
+      it('should set the globalAction to toggle the active state of the given client', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(state, action);
+        expect(result.globalAction).toStrictEqual(activeClientToggled('our-client'));
+      });
+    });
+  });
+
   describe(Keys.D, () => {
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.D };
 

+ 28 - 7
gmus-web/src/components/ui/cmus/reducer/keypress.ts

@@ -1,4 +1,11 @@
-import { masterSet, playPaused, queuePushed, queueRemoved, stateSet } from '../../../../actions';
+import {
+  activeClientToggled,
+  masterSet,
+  playPaused,
+  queuePushed,
+  queueRemoved,
+  stateSet,
+} from '../../../../actions';
 import { ActionKeyPressed, Keys } from '../../../../hooks/vim';
 import { getFilteredSongs } from '../selectors';
 import { CmusUIState, LibraryModeWindow, Overlay, View } from '../types';
@@ -77,6 +84,25 @@ function handleActivate(state: CmusUIState): CmusUIState {
   }
 }
 
+function handleToggle(state: CmusUIState): CmusUIState {
+  switch (state.view) {
+    case View.Library:
+      if (state.library.modeWindow === LibraryModeWindow.ArtistList) {
+        return { ...state, library: toggleExpandArtist(state.library) };
+      }
+      return state;
+
+    case View.ClientList:
+      if (!state.clientList.active) {
+        return state;
+      }
+      return withGlobalAction(state, activeClientToggled(state.clientList.active));
+
+    default:
+      return state;
+  }
+}
+
 function addSelectedToQueue(state: CmusUIState): CmusUIState {
   if (state.view !== View.Library) {
     return state;
@@ -113,12 +139,7 @@ export function handleKeyPress(state: CmusUIState, action: ActionKeyPressed): Cm
       return state;
 
     case Keys.space:
-      if (state.view === View.Library) {
-        if (state.library.modeWindow === LibraryModeWindow.ArtistList) {
-          return { ...state, library: toggleExpandArtist(state.library) };
-        }
-      }
-      return state;
+      return handleToggle(state);
 
     case Keys.enter:
       return handleActivate(state);

+ 21 - 11
gmus-web/src/components/ui/cmus/views/clients.tsx

@@ -12,16 +12,25 @@ import * as Styled from './clients.styles';
 
 type ClientRowProps = {
   name: string;
-  active: boolean;
+  selected: boolean;
   isMe: boolean;
   isMaster: boolean;
+  isActive: boolean;
   isPaused: boolean;
 };
 
-const ClientRow: React.FC<ClientRowProps> = ({ name, active, isMe, isMaster, isPaused }) => (
-  <Styled.Client highlight={isMaster} active={active} parentActive={true}>
+const ClientRow: React.FC<ClientRowProps> = ({
+  name,
+  selected,
+  isMe,
+  isMaster,
+  isActive,
+  isPaused,
+}) => (
+  <Styled.Client highlight={isMaster} active={selected} parentActive={true}>
     <Styled.ClientName>{name}</Styled.ClientName>
-    {isMaster && (isPaused ? '🔈' : '🔊')}
+    {isMaster && '🤠'}
+    {(isActive || isMaster) && (isPaused ? '🔈' : '🔊')}
     {isMe ? '🏠' : '📶'}
   </Styled.Client>
 );
@@ -30,13 +39,13 @@ export const ViewClientList: React.FC = () => {
   const {
     clientList,
     myClientName,
-    player: { master, playing },
+    player: { master, activeClients, playing },
   } = useContext(StateContext);
 
   const dispatchUI = useContext(CmusUIDispatchContext);
   const {
     scroll,
-    clientList: { active: activeClient },
+    clientList: { active: selectedClient },
   } = useContext(CmusUIStateContext);
 
   const ref = useRef<HTMLDivElement>(null);
@@ -46,14 +55,14 @@ export const ViewClientList: React.FC = () => {
     [clientList],
   );
 
-  const setActiveClient = useCallback((name: string) => dispatchUI(clientActivated(name)), [
+  const setselectedClient = useCallback((name: string) => dispatchUI(clientActivated(name)), [
     dispatchUI,
   ]);
 
   const onScroll = useCallback(
     (delta: -1 | 1): void => {
-      setActiveClient(
-        scrollThroughItems(sortedClientList, (compare) => compare.name === activeClient, delta)
+      setselectedClient(
+        scrollThroughItems(sortedClientList, (compare) => compare.name === selectedClient, delta)
           .name,
       );
 
@@ -61,7 +70,7 @@ export const ViewClientList: React.FC = () => {
         ref.current.scrollTop += delta * lineHeight;
       }
     },
-    [sortedClientList, activeClient, setActiveClient],
+    [sortedClientList, selectedClient, setselectedClient],
   );
 
   const lastScrollSerial = useRef<number>(0);
@@ -80,9 +89,10 @@ export const ViewClientList: React.FC = () => {
           <ClientRow
             key={name}
             name={name}
-            active={name === activeClient}
+            selected={name === selectedClient}
             isMe={name === myClientName}
             isMaster={name === master}
+            isActive={activeClients.includes(name)}
             isPaused={!playing}
           />
         ))}

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

@@ -31,6 +31,7 @@ const commandsLibrary: Command[] = [
 
 const commandsClientList: Command[] = [
   { command: '<Enter>', description: 'set the selected client to master' },
+  { command: '<Space>', description: 'toggle active state of selected client' },
 ];
 
 const commandsQueue: Command[] = [

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

@@ -1,6 +1,6 @@
 import React, { useContext } from 'react';
 import { StateContext } from '../../../../context/state';
-import { isMaster } from '../../../../selectors';
+import { isActiveClient, isMaster } from '../../../../selectors';
 
 import { MusicPlayer, Song } from '../../../../types';
 import { formatTime } from '../../../../utils/time';
@@ -60,6 +60,7 @@ export const PlayerStatus: React.FC<Props> = ({ song, ...props }) => {
         </Styled.Time>
         <Styled.ClientName>
           {state.myClientName} [{isMaster(state) ? 'Master' : 'Slave'}]
+          {isActiveClient(state) ? '🔊' : '🔈'}
         </Styled.ClientName>
         &nbsp;
         <StatusIcon {...props} />

+ 58 - 0
gmus-web/src/effects/effects.spec.ts

@@ -2,6 +2,7 @@ import {
   ActionStateSetRemote,
   ActionTypeLocal,
   ActionTypeRemote,
+  activeClientToggled,
   masterSet,
   playPaused,
   queueOrdered,
@@ -28,6 +29,7 @@ describe(globalEffects.name, () => {
         currentTime: 83,
         seekTime: 87,
         master: 'my-client',
+        activeClients: [],
         queue: [],
       };
 
@@ -56,6 +58,7 @@ describe(globalEffects.name, () => {
         currentTime: 83,
         seekTime: 87,
         master: 'my-client-name',
+        activeClients: [],
         queue: [],
       },
       myClientName: 'my-client-name',
@@ -97,6 +100,7 @@ describe(globalEffects.name, () => {
         currentTime: 83,
         seekTime: 5,
         master: 'some-master-went-away',
+        activeClients: [],
         queue: [],
       },
       myClientName: 'my-client-name',
@@ -116,6 +120,7 @@ describe(globalEffects.name, () => {
           currentTime: 83,
           seekTime: -1,
           master: 'my-client-name',
+          activeClients: [],
           queue: [],
         },
       });
@@ -134,6 +139,7 @@ describe(globalEffects.name, () => {
             currentTime: 83,
             seekTime: 83,
             master: 'other-client',
+            activeClients: [],
             queue: [],
           },
         });
@@ -141,6 +147,54 @@ describe(globalEffects.name, () => {
     });
   });
 
+  describe(ActionTypeLocal.ActiveClientToggled, () => {
+    const action = activeClientToggled('some-client');
+
+    describe('when the given client is active', () => {
+      const stateWithGivenClientActive: GlobalState = {
+        ...initialState,
+        player: {
+          ...initialState.player,
+          activeClients: ['some-client', 'other-client'],
+        },
+      };
+
+      it('should remove the given client from the active clients list', () => {
+        expect.assertions(1);
+        const result = globalEffects(stateWithGivenClientActive, action);
+
+        expect(result).toStrictEqual<ActionStateSetRemote>({
+          type: ActionTypeRemote.StateSet,
+          payload: expect.objectContaining({
+            activeClients: ['other-client'],
+          }),
+        });
+      });
+    });
+
+    describe('when the given client is not active', () => {
+      const stateWithGivenClientInactive: GlobalState = {
+        ...initialState,
+        player: {
+          ...initialState.player,
+          activeClients: ['other-client'],
+        },
+      };
+
+      it('should add the given client to the active clients list', () => {
+        expect.assertions(1);
+        const result = globalEffects(stateWithGivenClientInactive, action);
+
+        expect(result).toStrictEqual<ActionStateSetRemote>({
+          type: ActionTypeRemote.StateSet,
+          payload: expect.objectContaining({
+            activeClients: expect.arrayContaining(['some-client', 'other-client']),
+          }),
+        });
+      });
+    });
+  });
+
   describe(ActionTypeLocal.PlayPaused, () => {
     const statePriorMaster: GlobalState = {
       ...initialState,
@@ -150,6 +204,7 @@ describe(globalEffects.name, () => {
         currentTime: 83,
         seekTime: 5,
         master: 'some-master-client',
+        activeClients: [],
         queue: [],
       },
       myClientName: 'some-master-client',
@@ -182,6 +237,7 @@ describe(globalEffects.name, () => {
             currentTime: 83,
             seekTime: 5,
             master: 'some-master-client',
+            activeClients: [],
             queue: [],
           },
         });
@@ -198,6 +254,7 @@ describe(globalEffects.name, () => {
         currentTime: 83,
         seekTime: 5,
         master: 'some-master-client',
+        activeClients: [],
         queue: [],
       },
       myClientName: 'some-master-client',
@@ -230,6 +287,7 @@ describe(globalEffects.name, () => {
             currentTime: 0,
             seekTime: 0,
             master: 'some-master-client',
+            activeClients: [],
             queue: [],
           },
         });

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

@@ -81,6 +81,17 @@ export function globalEffects(prevState: GlobalState, action: LocalAction): Remo
         },
       };
 
+    case ActionTypeLocal.ActiveClientToggled:
+      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],
+        },
+      };
+
     case ActionTypeLocal.PlayPaused:
       if (isMaster(prevState)) {
         return null;

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

@@ -68,6 +68,7 @@ describe(useMaster.name, () => {
         currentTime: 17,
         seekTime: -1,
         master: 'master-client-a',
+        activeClients: [],
         queue: [],
       },
     };

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

@@ -40,6 +40,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'some-master-client',
+            activeClients: [],
             queue: [],
           },
         };
@@ -54,6 +55,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'some-master-client',
+            activeClients: [],
             queue: [],
           });
         });
@@ -69,6 +71,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'some-master-client',
+            activeClients: [],
             queue: [],
           },
         };
@@ -83,6 +86,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: -1,
             master: 'some-master-client',
+            activeClients: [],
             queue: [],
           });
         });
@@ -98,6 +102,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'other-master-client',
+            activeClients: [],
             queue: [],
           },
         };
@@ -112,6 +117,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: -1,
             master: 'other-master-client',
+            activeClients: [],
             queue: [],
           });
         });
@@ -142,6 +148,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'some-master-client',
+            activeClients: [],
             queue: [],
           },
         };
@@ -156,6 +163,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: -1,
             master: 'some-master-client',
+            activeClients: [],
             queue: [],
           });
         });
@@ -171,6 +179,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'my-client',
+            activeClients: [],
             queue: [],
           },
         };
@@ -185,6 +194,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'my-client',
+            activeClients: [],
             queue: [],
           });
         });
@@ -254,6 +264,7 @@ describe(globalReducer.name, () => {
           currentTime: 75,
           seekTime: 87,
           master: 'some-master-client',
+          activeClients: [],
           queue: [],
         });
       });
@@ -325,6 +336,7 @@ describe(globalReducer.name, () => {
           currentTime: 31,
           seekTime: -1,
           master: 'some-master-client',
+          activeClients: [],
           queue: [],
         },
         myClientName: 'some-master-client',
@@ -340,6 +352,7 @@ describe(globalReducer.name, () => {
           currentTime: 173,
           seekTime: 173,
           master: 'some-master-client',
+          activeClients: [],
           queue: [],
         });
       });
@@ -378,6 +391,7 @@ describe(globalReducer.name, () => {
         master: 'some-master-client',
         currentTime: 13,
         seekTime: -1,
+        activeClients: [],
         queue: [],
       },
     };

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

@@ -16,6 +16,7 @@ export const nullPlayer: MusicPlayer = {
   currentTime: 0,
   seekTime: -1,
   master: '',
+  activeClients: [],
   queue: [],
 };
 

+ 52 - 4
gmus-web/src/selectors.spec.ts

@@ -1,8 +1,8 @@
 import { ActionTypeRemote, stateSet } from './actions';
 import { GlobalState, initialState } from './reducer';
-import { isFromOurselves, isMaster, willBeMaster } from './selectors';
+import { isActiveClient, isFromOurselves, isMaster, willBeMaster } from './selectors';
 
-describe(isMaster.name, () => {
+describe('isMaster', () => {
   describe('when the master player is the current client', () => {
     it('should return true', () => {
       expect.assertions(1);
@@ -40,7 +40,55 @@ describe(isMaster.name, () => {
   });
 });
 
-describe(isFromOurselves.name, () => {
+describe('isActiveClient', () => {
+  describe('when the client is master', () => {
+    it('should return true', () => {
+      expect.assertions(1);
+      expect(
+        isActiveClient({
+          player: { ...initialState.player, master: 'my-client-name', activeClients: [] },
+          myClientName: 'my-client-name',
+        }),
+      ).toBe(true);
+    });
+  });
+
+  describe('when the client is a slave', () => {
+    describe('when the client is in the active clients list', () => {
+      it('should return true', () => {
+        expect.assertions(1);
+        expect(
+          isActiveClient({
+            player: {
+              ...initialState.player,
+              master: 'some-other-client',
+              activeClients: ['my-client-name'],
+            },
+            myClientName: 'my-client-name',
+          }),
+        ).toBe(true);
+      });
+    });
+
+    describe('when the client is not in the active clients list', () => {
+      it('should return false', () => {
+        expect.assertions(1);
+        expect(
+          isActiveClient({
+            player: {
+              ...initialState.player,
+              master: 'some-other-client',
+              activeClients: ['different-client-name'],
+            },
+            myClientName: 'my-client-name',
+          }),
+        ).toBe(false);
+      });
+    });
+  });
+});
+
+describe('isFromOurselves', () => {
   describe('when an action was dispatched from the current client', () => {
     it('should return true', () => {
       expect.assertions(1);
@@ -90,7 +138,7 @@ describe(isFromOurselves.name, () => {
   });
 });
 
-describe(willBeMaster.name, () => {
+describe('willBeMaster', () => {
   describe('when the action will cause the current client to be master', () => {
     it('should return true', () => {
       expect.assertions(1);

+ 3 - 0
gmus-web/src/selectors.ts

@@ -4,6 +4,9 @@ import { GlobalState } from './reducer/types';
 export const isMaster = (state: Pick<GlobalState, 'player' | 'myClientName'>): boolean =>
   state.player.master === state.myClientName;
 
+export const isActiveClient = (state: Pick<GlobalState, 'player' | 'myClientName'>): boolean =>
+  isMaster(state) || state.player.activeClients.includes(state.myClientName);
+
 export const isFromOurselves = (
   state: Pick<GlobalState, 'myClientName'>,
   action: ActionRemote,

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

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