diff --git a/.circleci/config.yml b/.circleci/config.yml
index e3baacf94..36b63b8eb 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -9,6 +9,6 @@ jobs:
- run: npm ci
- - run: npm test
+ - run: npm test -- --runInBand
- run: npm run build
\ No newline at end of file
diff --git a/src/components/Participant/Participant.tsx b/src/components/Participant/Participant.tsx
index c8e863cb0..4b1e15b9b 100644
--- a/src/components/Participant/Participant.tsx
+++ b/src/components/Participant/Participant.tsx
@@ -5,12 +5,19 @@ import { LocalParticipant, RemoteParticipant } from 'twilio-video';
interface ParticipantProps {
participant: LocalParticipant | RemoteParticipant;
+ disableAudio?: boolean;
}
-export default function Participant({ participant }: ParticipantProps) {
+export default function Participant({
+ participant,
+ disableAudio,
+}: ParticipantProps) {
return (
-
+
);
}
diff --git a/src/components/ParticipantStrip/ParticipantStrip.tsx b/src/components/ParticipantStrip/ParticipantStrip.tsx
index d9b42a4fe..8ae2db606 100644
--- a/src/components/ParticipantStrip/ParticipantStrip.tsx
+++ b/src/components/ParticipantStrip/ParticipantStrip.tsx
@@ -4,14 +4,14 @@ import { styled } from '@material-ui/core/styles';
import useParticipants from '../../hooks/useParticipants/useParticipants';
import { useVideoContext } from '../../hooks/context';
-const Container = styled('aside')({
+const Container = styled('aside')(({ theme }) => ({
position: 'absolute',
top: 0,
bottom: 0,
- right: '85%',
+ right: `calc(100% - ${theme.sidebarPosition})`,
left: 0,
padding: '0.5em',
-});
+}));
export default function ParticipantStrip() {
const { room } = useVideoContext();
diff --git a/src/components/ParticipantTracks/ParticipantTracks.tsx b/src/components/ParticipantTracks/ParticipantTracks.tsx
index b5a5fca35..7b447e8c2 100644
--- a/src/components/ParticipantTracks/ParticipantTracks.tsx
+++ b/src/components/ParticipantTracks/ParticipantTracks.tsx
@@ -6,10 +6,12 @@ import { useVideoContext } from '../../hooks/context';
interface ParticipantTracksProps {
participant: LocalParticipant | RemoteParticipant;
+ disableAudio?: boolean;
}
export default function ParticipantTracks({
participant,
+ disableAudio,
}: ParticipantTracksProps) {
const { room } = useVideoContext();
const publications = usePublications(participant);
@@ -23,6 +25,7 @@ export default function ParticipantTracks({
publication={publication}
participant={participant}
isLocal={isLocal}
+ disableAudio={disableAudio}
/>
))}
>
diff --git a/src/components/Publication/Publication.test.tsx b/src/components/Publication/Publication.test.tsx
index d0281134d..68d224be3 100644
--- a/src/components/Publication/Publication.test.tsx
+++ b/src/components/Publication/Publication.test.tsx
@@ -33,6 +33,20 @@ describe('the Publication component', () => {
expect(wrapper.find('AudioTrack').length).toBe(1);
});
+ it('should render null when the track has name "microphone" and disableAudio is true', () => {
+ mockUseTrack.mockImplementation(() => ({ name: 'microphone' }));
+ const wrapper = shallow(
+
+ );
+ expect(useTrack).toHaveBeenCalledWith('mockPublication');
+ expect(wrapper.find('AudioTrack').length).toBe(0);
+ });
+
it('should render null when there is no track', () => {
mockUseTrack.mockImplementation(() => null);
const wrapper = shallow(
diff --git a/src/components/Publication/Publication.tsx b/src/components/Publication/Publication.tsx
index 632317a9d..6b458d8e7 100644
--- a/src/components/Publication/Publication.tsx
+++ b/src/components/Publication/Publication.tsx
@@ -14,11 +14,13 @@ interface PublicationProps {
publication: LocalTrackPublication | RemoteTrackPublication;
participant: Participant;
isLocal: boolean;
+ disableAudio?: boolean;
}
export default function Publication({
publication,
isLocal,
+ disableAudio,
}: PublicationProps) {
const track = useTrack(publication);
@@ -28,7 +30,7 @@ export default function Publication({
case 'camera':
return ;
case 'microphone':
- return ;
+ return disableAudio ? null : ;
default:
return null;
}
diff --git a/src/components/Room/Room.tsx b/src/components/Room/Room.tsx
index b21727982..8e730a626 100644
--- a/src/components/Room/Room.tsx
+++ b/src/components/Room/Room.tsx
@@ -1,16 +1,35 @@
import React from 'react';
+import Participant from '../Participant/Participant';
import ParticipantStrip from '../ParticipantStrip/ParticipantStrip';
import { styled } from '@material-ui/core/styles';
+import useMainSpeaker from '../../hooks/useMainSpeaker/useMainSpeaker';
const Container = styled('div')({
position: 'relative',
height: '100%',
});
+const MainParticipantContainer = styled('div')(({ theme }) => ({
+ position: 'absolute',
+ left: theme.sidebarPosition,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ '& > div': {
+ height: '100%',
+ },
+}));
+
export default function Room() {
+ const mainParticipant = useMainSpeaker();
return (
+
+ {/* audio is disabled for this participant component because this participant's audio
+ is already being rendered in the component. */}
+
+
);
}
diff --git a/src/hooks/useDominantSpeaker/useDominantSpeaker.test.tsx b/src/hooks/useDominantSpeaker/useDominantSpeaker.test.tsx
new file mode 100644
index 000000000..95b7c7ee8
--- /dev/null
+++ b/src/hooks/useDominantSpeaker/useDominantSpeaker.test.tsx
@@ -0,0 +1,32 @@
+import { act, renderHook } from '@testing-library/react-hooks';
+import EventEmitter from 'events';
+import useDominantSpeaker from './useDominantSpeaker';
+import { useVideoContext } from '../context';
+
+jest.mock('../context');
+const mockUseVideoContext = useVideoContext as jest.Mock;
+
+describe('the useDominantSpeaker hook', () => {
+ const mockRoom: any = new EventEmitter();
+ mockRoom.dominantSpeaker = 'mockDominantSpeaker';
+ mockUseVideoContext.mockImplementation(() => ({ room: mockRoom }));
+
+ it('should return room.dominantSpeaker by default', () => {
+ const { result } = renderHook(useDominantSpeaker);
+ expect(result.current).toBe('mockDominantSpeaker');
+ });
+
+ it('should return respond to "dominantSpeakerChanged" events', async () => {
+ const { result } = renderHook(useDominantSpeaker);
+ act(() => {
+ mockRoom.emit('dominantSpeakerChanged', 'newDominantSpeaker');
+ });
+ expect(result.current).toBe('newDominantSpeaker');
+ });
+
+ it('should clean up listeners on unmount', () => {
+ const { unmount } = renderHook(useDominantSpeaker);
+ unmount();
+ expect(mockRoom.listenerCount('dominantSpeakerChanged')).toBe(0);
+ });
+});
diff --git a/src/hooks/useDominantSpeaker/useDominantSpeaker.tsx b/src/hooks/useDominantSpeaker/useDominantSpeaker.tsx
new file mode 100644
index 000000000..a6d0c2c3c
--- /dev/null
+++ b/src/hooks/useDominantSpeaker/useDominantSpeaker.tsx
@@ -0,0 +1,16 @@
+import { useEffect, useState } from 'react';
+import { useVideoContext } from '../context';
+
+export default function useDominantSpeaker() {
+ const { room } = useVideoContext();
+ const [dominantSpeaker, setDominantSpeaker] = useState(room.dominantSpeaker);
+
+ useEffect(() => {
+ room.on('dominantSpeakerChanged', setDominantSpeaker);
+ return () => {
+ room.off('dominantSpeakerChanged', setDominantSpeaker);
+ };
+ }, [room]);
+
+ return dominantSpeaker;
+}
diff --git a/src/hooks/useMainSpeaker/useMainSpeaker.test.tsx b/src/hooks/useMainSpeaker/useMainSpeaker.test.tsx
new file mode 100644
index 000000000..a1c6d81b0
--- /dev/null
+++ b/src/hooks/useMainSpeaker/useMainSpeaker.test.tsx
@@ -0,0 +1,42 @@
+import useMainSpeaker from './useMainSpeaker';
+import { renderHook } from '@testing-library/react-hooks';
+import { useVideoContext } from '../context';
+import { EventEmitter } from 'events';
+
+jest.mock('../context');
+const mockUseVideoContext = useVideoContext as jest.Mock;
+
+describe('the useMainSpeaker hook', () => {
+ it('should return the dominant speaker if it exists', () => {
+ const mockRoom: any = new EventEmitter();
+ mockRoom.dominantSpeaker = 'dominantSpeaker';
+ mockRoom.participants = new Map([[0, 'participant']]) as any;
+ mockRoom.localParticipant = 'localParticipant';
+ mockUseVideoContext.mockImplementation(() => ({ room: mockRoom }));
+ const { result } = renderHook(useMainSpeaker);
+ expect(result.current).toBe('dominantSpeaker');
+ });
+
+ it('should return the first remote participant if it exists', () => {
+ const mockRoom: any = new EventEmitter();
+ mockRoom.dominantSpeaker = null;
+ mockRoom.participants = new Map([
+ [0, 'participant'],
+ [1, 'secondParticipant'],
+ ]) as any;
+ mockRoom.localParticipant = 'localParticipant';
+ mockUseVideoContext.mockImplementation(() => ({ room: mockRoom }));
+ const { result } = renderHook(useMainSpeaker);
+ expect(result.current).toBe('participant');
+ });
+
+ it('should return the local participant if it exists', () => {
+ const mockRoom: any = new EventEmitter();
+ mockRoom.dominantSpeaker = null;
+ mockRoom.participants = new Map() as any;
+ mockRoom.localParticipant = 'localParticipant';
+ mockUseVideoContext.mockImplementation(() => ({ room: mockRoom }));
+ const { result } = renderHook(useMainSpeaker);
+ expect(result.current).toBe('localParticipant');
+ });
+});
diff --git a/src/hooks/useMainSpeaker/useMainSpeaker.tsx b/src/hooks/useMainSpeaker/useMainSpeaker.tsx
new file mode 100644
index 000000000..f4ae4ba37
--- /dev/null
+++ b/src/hooks/useMainSpeaker/useMainSpeaker.tsx
@@ -0,0 +1,11 @@
+import { useVideoContext } from '../context';
+import useDominantSpeaker from '../useDominantSpeaker/useDominantSpeaker';
+import useParticipants from '../useParticipants/useParticipants';
+
+export default function useMainSpeaker() {
+ const { room } = useVideoContext();
+ const participants = useParticipants();
+ const dominantSpeaker = useDominantSpeaker();
+
+ return dominantSpeaker || participants[0] || room.localParticipant;
+}
diff --git a/src/theme.ts b/src/theme.ts
index f86ac449a..7262abd96 100644
--- a/src/theme.ts
+++ b/src/theme.ts
@@ -1,5 +1,16 @@
import { createMuiTheme } from '@material-ui/core';
+declare module '@material-ui/core/styles/createMuiTheme' {
+ interface Theme {
+ sidebarPosition: string;
+ }
+
+ // allow configuration using `createMuiTheme`
+ interface ThemeOptions {
+ sidebarPosition?: string;
+ }
+}
+
export default createMuiTheme({
palette: {
type: 'dark',
@@ -7,4 +18,5 @@ export default createMuiTheme({
main: '#cc2b33',
},
},
+ sidebarPosition: '15%',
});