feat: (closes #19) Audio calls (#60)

* feat: [#19] set up voice call UI
* feat: [#19] get audio calls
* feat: [#19] implement audio device selection
This commit is contained in:
Jeremy Kahn 2022-10-30 16:07:32 -05:00 committed by GitHub
parent 02a2e53b64
commit f219218d02
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 234 additions and 3 deletions

View File

@ -1,5 +1,18 @@
import { useState } from 'react'
import Accordion from '@mui/material/Accordion'
import AccordionSummary from '@mui/material/AccordionSummary'
import AccordionDetails from '@mui/material/AccordionDetails'
import Box from '@mui/material/Box'
import Divider from '@mui/material/Divider'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import RecordVoiceOver from '@mui/icons-material/RecordVoiceOver'
import VoiceOverOff from '@mui/icons-material/VoiceOverOff'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import ListItemText from '@mui/material/ListItemText'
import Menu from '@mui/material/Menu'
import MenuItem from '@mui/material/MenuItem'
import Fab from '@mui/material/Fab'
import { v4 as uuid } from 'uuid'
import { rtcConfig } from 'config/rtcConfig'
@ -24,7 +37,15 @@ export function Room({
password,
userId,
}: RoomProps) {
const { messageLog, sendMessage, isMessageSending } = useRoom(
const {
audioDevices,
messageLog,
sendMessage,
isMessageSending,
isSpeakingToRoom,
setIsSpeakingToRoom,
handleAudioDeviceSelect,
} = useRoom(
{
appId,
trackerUrls,
@ -38,10 +59,37 @@ export function Room({
}
)
const [audioAnchorEl, setAudioAnchorEl] = useState<null | HTMLElement>(null)
const isAudioDeviceSelectOpen = Boolean(audioAnchorEl)
const [selectedAudioDeviceIdx, setSelectedAudioDeviceIdx] = useState(0)
const handleMessageSubmit = async (message: string) => {
await sendMessage(message)
}
const handleVoiceCallClick = () => {
setIsSpeakingToRoom(!isSpeakingToRoom)
}
const handleAudioDeviceListItemClick = (
event: React.MouseEvent<HTMLElement>
) => {
setAudioAnchorEl(event.currentTarget)
}
const handleAudioDeviceMenuItemClick = (
_event: React.MouseEvent<HTMLElement>,
idx: number
) => {
setSelectedAudioDeviceIdx(idx)
handleAudioDeviceSelect(audioDevices[idx])
setAudioAnchorEl(null)
}
const handleAudioInputSelectMenuClose = () => {
setAudioAnchorEl(null)
}
return (
<Box
className="Room"
@ -51,6 +99,88 @@ export function Room({
flexDirection: 'column',
}}
>
<Accordion>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="panel1a-content"
id="panel1a-header"
></AccordionSummary>
<AccordionDetails>
<Box
sx={{
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
<Fab
variant="extended"
color={isSpeakingToRoom ? 'error' : 'success'}
aria-label="call"
onClick={handleVoiceCallClick}
>
{isSpeakingToRoom ? (
<>
<VoiceOverOff sx={{ mr: 1 }} />
Stop speaking to room
</>
) : (
<>
<RecordVoiceOver sx={{ mr: 1 }} />
Start speaking to room
</>
)}
</Fab>
{audioDevices.length > 0 && (
<Box sx={{ mt: 1 }}>
<List
component="nav"
aria-label="Audio device selection"
sx={{ bgcolor: 'background.paper' }}
>
<ListItem
button
id="audio-input-select-button"
aria-haspopup="listbox"
aria-controls="audio-input-select-menu"
aria-label="Audio input device to use"
aria-expanded={isAudioDeviceSelectOpen ? 'true' : undefined}
onClick={handleAudioDeviceListItemClick}
>
<ListItemText
primary="Selected audio input device"
secondary={audioDevices[selectedAudioDeviceIdx]?.label}
/>
</ListItem>
</List>
<Menu
id="audio-input-select-menu"
anchorEl={audioAnchorEl}
open={isAudioDeviceSelectOpen}
onClose={handleAudioInputSelectMenuClose}
MenuListProps={{
'aria-labelledby': 'audio-input-select-button',
role: 'listbox',
}}
>
{audioDevices.map((audioDevice, idx) => (
<MenuItem
key={audioDevice.deviceId}
selected={idx === selectedAudioDeviceIdx}
onClick={event =>
handleAudioDeviceMenuItemClick(event, idx)
}
>
{audioDevice.label}
</MenuItem>
))}
</Menu>
</Box>
)}
</Box>
</AccordionDetails>
</Accordion>
<ChatTranscript
messageLog={messageLog}
userId={userId}

View File

@ -15,7 +15,7 @@ import {
import { funAnimalName } from 'fun-animal-names'
import { getPeerName } from 'components/PeerNameDisplay'
import { NotificationService } from 'services/Notification'
import { Audio } from 'services/Audio'
import { Audio as AudioService } from 'services/Audio'
import { PeerRoom } from 'services/PeerRoom'
import { messageTranscriptSizeLimit } from 'config/messaging'
@ -45,12 +45,18 @@ export function useRoom(
Array<ReceivedMessage | UnsentMessage>
>([])
const [newMessageAudio] = useState(
() => new Audio(process.env.PUBLIC_URL + '/sounds/new-message.aac')
() => new AudioService(process.env.PUBLIC_URL + '/sounds/new-message.aac')
)
const [isSpeakingToRoom, setIsSpeakingToRoom] = useState(false)
const [peerAudios, setPeerAudios] = useState<
Record<string, HTMLAudioElement>
>({})
const [audioStream, setAudioStream] = useState<MediaStream | null>()
const setMessageLog = (messages: Message[]) => {
_setMessageLog(messages.slice(-messageTranscriptSizeLimit))
}
const [audioDevices, setAudioDevices] = useState<MediaDeviceInfo[]>([])
useEffect(() => {
return () => {
@ -66,6 +72,16 @@ export function useRoom(
}
}, [shellContext])
useEffect(() => {
;(async () => {
if (!audioStream) return
const devices = await window.navigator.mediaDevices.enumerateDevices()
const audioDevices = devices.filter(({ kind }) => kind === 'audioinput')
setAudioDevices(audioDevices)
})()
}, [audioStream])
const [sendPeerId, receivePeerId] = usePeerRoomAction<string>(
peerRoom,
PeerActions.PEER_NAME
@ -147,6 +163,11 @@ export function useRoom(
const newNumberOfPeers = numberOfPeers + 1
setNumberOfPeers(newNumberOfPeers)
shellContext.setNumberOfPeers(newNumberOfPeers)
if (audioStream) {
peerRoom.addStream(audioStream, peerId)
}
;(async () => {
try {
const promises: Promise<any>[] = [sendPeerId(userId, peerId)]
@ -184,6 +205,10 @@ export function useRoom(
setNumberOfPeers(newNumberOfPeers)
shellContext.setNumberOfPeers(newNumberOfPeers)
if (audioStream) {
peerRoom.removeStream(audioStream, peerId)
}
if (peerExist) {
const peerListClone = [...shellContext.peerList]
peerListClone.splice(peerIndex, 1)
@ -191,10 +216,70 @@ export function useRoom(
}
})
peerRoom.onPeerStream((stream, peerId) => {
const audio = new Audio()
audio.srcObject = stream
audio.autoplay = true
setPeerAudios({ ...peerAudios, [peerId]: audio })
})
useEffect(() => {
;(async () => {
if (isSpeakingToRoom) {
if (!audioStream) {
const newSelfStream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false,
})
peerRoom.addStream(newSelfStream)
setAudioStream(newSelfStream)
}
} else {
if (audioStream) {
for (const audioTrack of audioStream.getTracks()) {
audioTrack.stop()
audioStream.removeTrack(audioTrack)
}
peerRoom.removeStream(audioStream, peerRoom.getPeers())
setAudioStream(null)
}
}
})()
}, [isSpeakingToRoom, peerAudios, peerRoom, audioStream])
const handleAudioDeviceSelect = async (audioDevice: MediaDeviceInfo) => {
if (!audioStream) return
for (const audioTrack of audioStream.getTracks()) {
audioTrack.stop()
audioStream.removeTrack(audioTrack)
}
peerRoom.removeStream(audioStream, peerRoom.getPeers())
const newSelfStream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: audioDevice.deviceId,
},
video: false,
})
peerRoom.addStream(newSelfStream)
setAudioStream(newSelfStream)
}
return {
audioDevices,
peerRoom,
messageLog,
sendMessage,
isMessageSending,
isSpeakingToRoom,
setIsSpeakingToRoom,
handleAudioDeviceSelect,
}
}

View File

@ -26,7 +26,23 @@ export class PeerRoom {
this.room.onPeerLeave((...args) => fn(...args))
}
onPeerStream: Room['onPeerStream'] = fn => {
this.room.onPeerStream((...args) => fn(...args))
}
getPeers: Room['getPeers'] = () => {
return this.room.getPeers()
}
makeAction = <T>(namespace: string) => {
return this.room.makeAction<T>(namespace)
}
addStream: Room['addStream'] = stream => {
return this.room.addStream(stream)
}
removeStream: Room['removeStream'] = (stream, targetPeers) => {
return this.room.removeStream(stream, targetPeers)
}
}