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%', });