refactor: Room audio (#61)

* Revert "Revert "refactor: move room audio controls to their own component""

This reverts commit 219e0670ca2c0c5e7bb1c25d4928cdc787934c09.

* fix: prevent duplicate hook handlers
* refactor: PeerRoom cleanup
This commit is contained in:
Jeremy Kahn 2022-11-03 21:36:30 -05:00 committed by GitHub
parent a87b0d3367
commit 8947bace94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 179 additions and 161 deletions

View File

@ -1,18 +1,9 @@
import { useState } from 'react'
import Accordion from '@mui/material/Accordion' import Accordion from '@mui/material/Accordion'
import AccordionSummary from '@mui/material/AccordionSummary' import AccordionSummary from '@mui/material/AccordionSummary'
import AccordionDetails from '@mui/material/AccordionDetails' import AccordionDetails from '@mui/material/AccordionDetails'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore' 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 { v4 as uuid } from 'uuid'
import { rtcConfig } from 'config/rtcConfig' import { rtcConfig } from 'config/rtcConfig'
@ -21,6 +12,7 @@ import { MessageForm } from 'components/MessageForm'
import { ChatTranscript } from 'components/ChatTranscript' import { ChatTranscript } from 'components/ChatTranscript'
import { useRoom } from './useRoom' import { useRoom } from './useRoom'
import { RoomAudioControls } from './RoomAudioControls'
export interface RoomProps { export interface RoomProps {
appId?: string appId?: string
@ -37,15 +29,7 @@ export function Room({
password, password,
userId, userId,
}: RoomProps) { }: RoomProps) {
const { const { messageLog, peerRoom, sendMessage, isMessageSending } = useRoom(
audioDevices,
messageLog,
sendMessage,
isMessageSending,
isSpeakingToRoom,
setIsSpeakingToRoom,
handleAudioDeviceSelect,
} = useRoom(
{ {
appId, appId,
trackerUrls, trackerUrls,
@ -59,37 +43,10 @@ export function Room({
} }
) )
const [audioAnchorEl, setAudioAnchorEl] = useState<null | HTMLElement>(null)
const isAudioDeviceSelectOpen = Boolean(audioAnchorEl)
const [selectedAudioDeviceIdx, setSelectedAudioDeviceIdx] = useState(0)
const handleMessageSubmit = async (message: string) => { const handleMessageSubmit = async (message: string) => {
await sendMessage(message) 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 ( return (
<Box <Box
className="Room" className="Room"
@ -114,70 +71,7 @@ export function Room({
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
<Fab <RoomAudioControls peerRoom={peerRoom} />
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> </Box>
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>

View File

@ -0,0 +1,121 @@
import { useState } from 'react'
import Box from '@mui/material/Box'
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 { PeerRoom } from 'services/PeerRoom/PeerRoom'
import { useRoomAudio } from './useRoomAudio'
export interface RoomAudioControlsProps {
peerRoom: PeerRoom
}
export function RoomAudioControls({ peerRoom }: RoomAudioControlsProps) {
const {
audioDevices,
isSpeakingToRoom,
setIsSpeakingToRoom,
handleAudioDeviceSelect,
} = useRoomAudio({ peerRoom })
const [audioAnchorEl, setAudioAnchorEl] = useState<null | HTMLElement>(null)
const isAudioDeviceSelectOpen = Boolean(audioAnchorEl)
const [selectedAudioDeviceIdx, setSelectedAudioDeviceIdx] = useState(0)
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 (
<>
<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>
)}
</>
)
}

View File

@ -17,12 +17,11 @@ import { funAnimalName } from 'fun-animal-names'
import { getPeerName } from 'components/PeerNameDisplay' import { getPeerName } from 'components/PeerNameDisplay'
import { NotificationService } from 'services/Notification' import { NotificationService } from 'services/Notification'
import { Audio as AudioService } from 'services/Audio' import { Audio as AudioService } from 'services/Audio'
import { PeerRoom } from 'services/PeerRoom' import { PeerRoom, PeerHookType } from 'services/PeerRoom'
import { messageTranscriptSizeLimit } from 'config/messaging' import { messageTranscriptSizeLimit } from 'config/messaging'
import { usePeerRoomAction } from './usePeerRoomAction' import { usePeerRoomAction } from './usePeerRoomAction'
import { useRoomAudio } from './useRoomAudio'
interface UseRoomConfig { interface UseRoomConfig {
roomId: string roomId: string
@ -57,15 +56,6 @@ export function useRoom(
_setMessageLog(messages.slice(-messageTranscriptSizeLimit)) _setMessageLog(messages.slice(-messageTranscriptSizeLimit))
} }
const {
audioDevices,
isSpeakingToRoom,
setIsSpeakingToRoom,
handleAudioDeviceSelect,
handleAudioForNewPeer,
handleAudioForLeavingPeer,
} = useRoomAudio({ peerRoom })
useEffect(() => { useEffect(() => {
return () => { return () => {
peerRoom.leaveRoom() peerRoom.leaveRoom()
@ -153,7 +143,7 @@ export function useRoom(
setMessageLog([...messageLog, { ...message, timeReceived: Date.now() }]) setMessageLog([...messageLog, { ...message, timeReceived: Date.now() }])
}) })
peerRoom.onPeerJoin((peerId: string) => { peerRoom.onPeerJoin(PeerHookType.NEW_PEER, (peerId: string) => {
shellContext.showAlert(`Someone has joined the room`, { shellContext.showAlert(`Someone has joined the room`, {
severity: 'success', severity: 'success',
}) })
@ -178,11 +168,7 @@ export function useRoom(
})() })()
}) })
peerRoom.onPeerJoin((peerId: string) => { peerRoom.onPeerLeave(PeerHookType.NEW_PEER, (peerId: string) => {
handleAudioForNewPeer(peerId)
})
peerRoom.onPeerLeave((peerId: string) => {
const peerIndex = shellContext.peerList.findIndex( const peerIndex = shellContext.peerList.findIndex(
peer => peer.peerId === peerId peer => peer.peerId === peerId
) )
@ -209,18 +195,10 @@ export function useRoom(
} }
}) })
peerRoom.onPeerLeave((peerId: string) => {
handleAudioForLeavingPeer(peerId)
})
return { return {
audioDevices,
peerRoom, peerRoom,
messageLog, messageLog,
sendMessage, sendMessage,
isMessageSending, isMessageSending,
isSpeakingToRoom,
setIsSpeakingToRoom,
handleAudioDeviceSelect,
} }
} }

View File

@ -3,7 +3,7 @@ import { useContext, useEffect, useCallback, useState } from 'react'
import { ShellContext } from 'contexts/ShellContext' import { ShellContext } from 'contexts/ShellContext'
import { PeerActions } from 'models/network' import { PeerActions } from 'models/network'
import { AudioState, Peer } from 'models/chat' import { AudioState, Peer } from 'models/chat'
import { PeerRoom } from 'services/PeerRoom' import { PeerRoom, PeerHookType, PeerStreamType } from 'services/PeerRoom'
import { usePeerRoomAction } from './usePeerRoomAction' import { usePeerRoomAction } from './usePeerRoomAction'
@ -57,7 +57,7 @@ export function useRoomAudio({ peerRoom }: UseRoomAudioConfig) {
shellContext.setPeerList(newPeerList) shellContext.setPeerList(newPeerList)
}) })
peerRoom.onPeerStream((stream, peerId) => { peerRoom.onPeerStream(PeerStreamType.AUDIO, (stream, peerId) => {
const audio = new Audio() const audio = new Audio()
audio.srcObject = stream audio.srcObject = stream
audio.autoplay = true audio.autoplay = true
@ -161,12 +161,18 @@ export function useRoomAudio({ peerRoom }: UseRoomAudioConfig) {
} }
} }
peerRoom.onPeerJoin(PeerHookType.AUDIO, (peerId: string) => {
handleAudioForNewPeer(peerId)
})
peerRoom.onPeerLeave(PeerHookType.AUDIO, (peerId: string) => {
handleAudioForLeavingPeer(peerId)
})
return { return {
audioDevices, audioDevices,
isSpeakingToRoom, isSpeakingToRoom,
setIsSpeakingToRoom, setIsSpeakingToRoom,
handleAudioDeviceSelect, handleAudioDeviceSelect,
handleAudioForNewPeer,
handleAudioForLeavingPeer,
} }
} }

View File

@ -1,37 +1,53 @@
import { joinRoom, Room, BaseRoomConfig } from 'trystero' import { joinRoom, Room, BaseRoomConfig } from 'trystero'
import { TorrentRoomConfig } from 'trystero/torrent' import { TorrentRoomConfig } from 'trystero/torrent'
export enum PeerHookType {
NEW_PEER = 'NEW_PEER',
AUDIO = 'AUDIO',
}
export enum PeerStreamType {
AUDIO = 'AUDIO',
}
export class PeerRoom { export class PeerRoom {
private room: Room private room: Room
private roomConfig: TorrentRoomConfig & BaseRoomConfig private roomConfig: TorrentRoomConfig & BaseRoomConfig
private peerJoinHandlers: Set<(peerId: string) => void> = new Set() private peerJoinHandlers: Map<
PeerHookType,
Parameters<Room['onPeerJoin']>[0]
> = new Map()
private peerLeaveHandlers: Set<(peerId: string) => void> = new Set() private peerLeaveHandlers: Map<
PeerHookType,
Parameters<Room['onPeerLeave']>[0]
> = new Map()
private peerStreamHandlers: Set< private peerStreamHandlers: Map<
(stream: MediaStream, peerId: string) => void PeerStreamType,
> = new Set() Parameters<Room['onPeerStream']>[0]
> = new Map()
constructor(config: TorrentRoomConfig & BaseRoomConfig, roomId: string) { constructor(config: TorrentRoomConfig & BaseRoomConfig, roomId: string) {
this.roomConfig = config this.roomConfig = config
this.room = joinRoom(this.roomConfig, roomId) this.room = joinRoom(this.roomConfig, roomId)
this.room.onPeerJoin((...args) => { this.room.onPeerJoin((...args) => {
for (const peerJoinHandler of this.peerJoinHandlers) { for (const [, peerJoinHandler] of this.peerJoinHandlers) {
peerJoinHandler(...args) peerJoinHandler(...args)
} }
}) })
this.room.onPeerLeave((...args) => { this.room.onPeerLeave((...args) => {
for (const peerLeaveHandler of this.peerLeaveHandlers) { for (const [, peerLeaveHandler] of this.peerLeaveHandlers) {
peerLeaveHandler(...args) peerLeaveHandler(...args)
} }
}) })
this.room.onPeerStream((...args) => { this.room.onPeerStream((...args) => {
for (const peerStreamHandler of this.peerStreamHandlers) { for (const [, peerStreamHandler] of this.peerStreamHandlers) {
peerStreamHandler(...args) peerStreamHandler(...args)
} }
}) })
@ -48,34 +64,37 @@ export class PeerRoom {
this.flush() this.flush()
} }
onPeerJoin: Room['onPeerJoin'] = fn => { onPeerJoin = (
this.peerJoinHandlers.add(fn) peerHookType: PeerHookType,
fn: Parameters<Room['onPeerJoin']>[0]
) => {
this.peerJoinHandlers.set(peerHookType, fn)
} }
onPeerJoinFlush = () => { onPeerJoinFlush = () => {
this.peerJoinHandlers.forEach(handler => this.peerJoinHandlers = new Map()
this.peerJoinHandlers.delete(handler)
)
} }
onPeerLeave: Room['onPeerLeave'] = fn => { onPeerLeave = (
this.peerLeaveHandlers.add(fn) peerHookType: PeerHookType,
fn: Parameters<Room['onPeerLeave']>[0]
) => {
this.peerLeaveHandlers.set(peerHookType, fn)
} }
onPeerLeaveFlush = () => { onPeerLeaveFlush = () => {
this.peerLeaveHandlers.forEach(handler => this.peerLeaveHandlers = new Map()
this.peerLeaveHandlers.delete(handler)
)
} }
onPeerStream: Room['onPeerStream'] = fn => { onPeerStream = (
this.peerStreamHandlers.add(fn) peerStreamType: PeerStreamType,
fn: Parameters<Room['onPeerStream']>[0]
) => {
this.peerStreamHandlers.set(peerStreamType, fn)
} }
onPeerStreamFlush = () => { onPeerStreamFlush = () => {
this.peerStreamHandlers.forEach(handler => this.peerStreamHandlers = new Map()
this.peerStreamHandlers.delete(handler)
)
} }
getPeers: Room['getPeers'] = () => { getPeers: Room['getPeers'] = () => {