feat(audio) [closes #230] Screenshare audio control (#248)

* feat(ui): present mic volume icon

* feat(ui): improve mic volume display

* refactor(ui): nest mic audio as a channel

* fix(ui): prevent volume control from reappearing for returning peers

* refactor(audio): update specific audio channel states

* refactor(audio): use enum for audio channel name

* refactor(types): improve audio type names

* feat(audio): wire up screen share audio

* refactor(networking): always provide stream metadata

* fix(audio): remove screen audio when stream ends

* fix(audio): stop audio when removing it

* feat(audio): show appropriate icon for channel

* fix(audio): clean up audio for leaving peers consistently

* fix(audio): use up-to-date peerAudios reference

* refactor(audio): simplify audio state updating

* refactor(audio): use functional setState to update peer list

* refactor(variables): rename peerAudios to peerAudioChannels

* refactor(types): consolidate stream types

* refactor(types): require stream type metadata
This commit is contained in:
Jeremy Kahn 2024-04-01 21:25:12 -05:00 committed by GitHub
parent 89abe718db
commit 05b4615af9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 342 additions and 119 deletions

View File

@ -1,16 +1,25 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import Slider from '@mui/material/Slider' import Slider from '@mui/material/Slider'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper'
import ListItemIcon from '@mui/material/ListItemIcon' import ListItemIcon from '@mui/material/ListItemIcon'
import VolumeUp from '@mui/icons-material/VolumeUp' import VolumeUpIcon from '@mui/icons-material/VolumeUp'
import VolumeDown from '@mui/icons-material/VolumeDown' import VolumeDownIcon from '@mui/icons-material/VolumeDown'
import VolumeMute from '@mui/icons-material/VolumeMute' import VolumeMuteIcon from '@mui/icons-material/VolumeMute'
import MicIcon from '@mui/icons-material/Mic'
import LaptopWindowsIcon from '@mui/icons-material/LaptopWindows'
import Tooltip from '@mui/material/Tooltip'
import { AudioChannelName } from 'models/chat'
interface AudioVolumeProps { interface AudioVolumeProps {
audioEl: HTMLAudioElement audioEl: HTMLAudioElement
audioChannelName: AudioChannelName
} }
export const AudioVolume = ({ audioEl }: AudioVolumeProps) => { export const AudioVolume = ({
audioEl,
audioChannelName,
}: AudioVolumeProps) => {
const [audioVolume, setAudioVolume] = useState(audioEl.volume) const [audioVolume, setAudioVolume] = useState(audioEl.volume)
useEffect(() => { useEffect(() => {
@ -32,27 +41,48 @@ export const AudioVolume = ({ audioEl }: AudioVolumeProps) => {
const formatLabelValue = () => `${Math.round(audioVolume * 100)}%` const formatLabelValue = () => `${Math.round(audioVolume * 100)}%`
let VolumeIcon = VolumeUp let VolumeIcon = VolumeUpIcon
if (audioVolume === 0) { if (audioVolume === 0) {
VolumeIcon = VolumeMute VolumeIcon = VolumeMuteIcon
} else if (audioVolume < 0.5) { } else if (audioVolume < 0.5) {
VolumeIcon = VolumeDown VolumeIcon = VolumeDownIcon
} }
return ( return (
<Box sx={{ display: 'flex', pt: 1, pr: 3, alignItems: 'center' }}> <Paper
<ListItemIcon> sx={{
<VolumeIcon sx={{ cursor: 'pointer' }} onClick={handleIconClick} /> alignItems: 'center',
display: 'flex',
mt: 1.5,
pl: 2,
pr: 3,
py: 1,
}}
>
<ListItemIcon sx={{ cursor: 'pointer' }} onClick={handleIconClick}>
<VolumeIcon fontSize="small" />
{audioChannelName === AudioChannelName.MICROPHONE && (
<Tooltip title="Their microphone volume">
<MicIcon fontSize="small" sx={{ ml: 1, mr: 2 }} />
</Tooltip>
)}
{audioChannelName === AudioChannelName.SCREEN_SHARE && (
<Tooltip title="Their screen's volume">
<LaptopWindowsIcon fontSize="small" sx={{ ml: 1, mr: 2 }} />
</Tooltip>
)}
</ListItemIcon> </ListItemIcon>
<Slider <Box display="flex" width={1}>
aria-label="Volume" <Slider
getAriaValueText={formatLabelValue} aria-label="Volume"
valueLabelFormat={formatLabelValue} getAriaValueText={formatLabelValue}
valueLabelDisplay="auto" valueLabelFormat={formatLabelValue}
onChange={handleSliderChange} valueLabelDisplay="auto"
value={audioVolume * 100} onChange={handleSliderChange}
></Slider> value={audioVolume * 100}
</Box> ></Slider>
</Box>
</Paper>
) )
} }

View File

@ -3,7 +3,7 @@ import Paper from '@mui/material/Paper'
import Tooltip from '@mui/material/Tooltip' import Tooltip from '@mui/material/Tooltip'
import { PeerNameDisplay } from 'components/PeerNameDisplay' import { PeerNameDisplay } from 'components/PeerNameDisplay'
import { VideoStreamType } from 'models/chat' import { StreamType } from 'models/chat'
import { SelectedPeerStream } from './RoomVideoDisplay' import { SelectedPeerStream } from './RoomVideoDisplay'
@ -13,13 +13,13 @@ interface PeerVideoProps {
numberOfVideos: number numberOfVideos: number
onVideoClick?: ( onVideoClick?: (
userId: string, userId: string,
videoStreamType: VideoStreamType, streamType: StreamType,
videoStream: MediaStream videoStream: MediaStream
) => void ) => void
selectedPeerStream: SelectedPeerStream | null selectedPeerStream: SelectedPeerStream | null
userId: string userId: string
videoStream: MediaStream videoStream: MediaStream
videoStreamType: VideoStreamType streamType: StreamType
} }
// Adapted from https://www.geeksforgeeks.org/find-the-next-perfect-square-greater-than-a-given-number/ // Adapted from https://www.geeksforgeeks.org/find-the-next-perfect-square-greater-than-a-given-number/
@ -37,7 +37,7 @@ export const PeerVideo = ({
userId, userId,
selectedPeerStream, selectedPeerStream,
videoStream, videoStream,
videoStreamType, streamType,
}: PeerVideoProps) => { }: PeerVideoProps) => {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
@ -47,13 +47,14 @@ export const PeerVideo = ({
video.autoplay = true video.autoplay = true
video.srcObject = videoStream video.srcObject = videoStream
video.muted = true
}, [videoRef, videoStream]) }, [videoRef, videoStream])
const cols = Math.sqrt(nextPerfectSquare(numberOfVideos - 1)) const cols = Math.sqrt(nextPerfectSquare(numberOfVideos - 1))
const rows = Math.ceil(numberOfVideos / cols) const rows = Math.ceil(numberOfVideos / cols)
const handleVideoClick = () => { const handleVideoClick = () => {
onVideoClick?.(userId, videoStreamType, videoStream) onVideoClick?.(userId, streamType, videoStream)
} }
return ( return (

View File

@ -4,7 +4,7 @@ import Paper from '@mui/material/Paper'
import { RoomContext } from 'contexts/RoomContext' import { RoomContext } from 'contexts/RoomContext'
import { ShellContext } from 'contexts/ShellContext' import { ShellContext } from 'contexts/ShellContext'
import { Peer, VideoStreamType } from 'models/chat' import { Peer, StreamType } from 'models/chat'
import { PeerVideo } from './PeerVideo' import { PeerVideo } from './PeerVideo'
@ -16,7 +16,7 @@ interface PeerWithVideo {
export interface SelectedPeerStream { export interface SelectedPeerStream {
peerId: string peerId: string
videoStreamType: VideoStreamType streamType: StreamType
videoStream: MediaStream videoStream: MediaStream
} }
@ -105,13 +105,13 @@ export const RoomVideoDisplay = ({
const handleVideoClick = ( const handleVideoClick = (
peerId: string, peerId: string,
videoStreamType: VideoStreamType, streamType: StreamType,
videoStream: MediaStream videoStream: MediaStream
) => { ) => {
if (selectedPeerStream?.videoStream === videoStream) { if (selectedPeerStream?.videoStream === videoStream) {
setSelectedPeerStream(null) setSelectedPeerStream(null)
} else if (numberOfVideos > 1) { } else if (numberOfVideos > 1) {
setSelectedPeerStream({ peerId, videoStreamType, videoStream }) setSelectedPeerStream({ peerId, streamType, videoStream })
} }
} }
@ -139,7 +139,7 @@ export const RoomVideoDisplay = ({
userId={selectedPeerStream.peerId} userId={selectedPeerStream.peerId}
selectedPeerStream={selectedPeerStream} selectedPeerStream={selectedPeerStream}
videoStream={selectedPeerStream.videoStream} videoStream={selectedPeerStream.videoStream}
videoStreamType={selectedPeerStream.videoStreamType} streamType={selectedPeerStream.streamType}
/> />
</Box> </Box>
)} )}
@ -168,7 +168,7 @@ export const RoomVideoDisplay = ({
userId={userId} userId={userId}
selectedPeerStream={selectedPeerStream} selectedPeerStream={selectedPeerStream}
videoStream={selfVideoStream} videoStream={selfVideoStream}
videoStreamType={VideoStreamType.WEBCAM} streamType={StreamType.WEBCAM}
/> />
)} )}
{selfScreenStream && ( {selfScreenStream && (
@ -179,7 +179,7 @@ export const RoomVideoDisplay = ({
userId={userId} userId={userId}
selectedPeerStream={selectedPeerStream} selectedPeerStream={selectedPeerStream}
videoStream={selfScreenStream} videoStream={selfScreenStream}
videoStreamType={VideoStreamType.SCREEN_SHARE} streamType={StreamType.SCREEN_SHARE}
/> />
)} )}
{peersWithVideo.map(peerWithVideo => ( {peersWithVideo.map(peerWithVideo => (
@ -191,7 +191,7 @@ export const RoomVideoDisplay = ({
userId={peerWithVideo.peer.userId} userId={peerWithVideo.peer.userId}
selectedPeerStream={selectedPeerStream} selectedPeerStream={selectedPeerStream}
videoStream={peerWithVideo.videoStream} videoStream={peerWithVideo.videoStream}
videoStreamType={VideoStreamType.WEBCAM} streamType={StreamType.WEBCAM}
/> />
)} )}
{peerWithVideo.screenStream && ( {peerWithVideo.screenStream && (
@ -201,7 +201,7 @@ export const RoomVideoDisplay = ({
userId={peerWithVideo.peer.userId} userId={peerWithVideo.peer.userId}
selectedPeerStream={selectedPeerStream} selectedPeerStream={selectedPeerStream}
videoStream={peerWithVideo.screenStream} videoStream={peerWithVideo.screenStream}
videoStreamType={VideoStreamType.SCREEN_SHARE} streamType={StreamType.SCREEN_SHARE}
/> />
)} )}
</Fragment> </Fragment>

View File

@ -23,6 +23,7 @@ import {
TypingStatus, TypingStatus,
Peer, Peer,
PeerVerificationState, PeerVerificationState,
AudioChannelName,
} from 'models/chat' } from 'models/chat'
import { getPeerName, usePeerNameDisplay } from 'components/PeerNameDisplay' import { getPeerName, usePeerNameDisplay } from 'components/PeerNameDisplay'
import { Audio } from 'lib/Audio' import { Audio } from 'lib/Audio'
@ -269,7 +270,10 @@ export function useRoom(
userId, userId,
publicKey, publicKey,
customUsername, customUsername,
audioState: AudioState.STOPPED, audioChannelState: {
[AudioChannelName.MICROPHONE]: AudioState.STOPPED,
[AudioChannelName.SCREEN_SHARE]: AudioState.STOPPED,
},
videoState: VideoState.STOPPED, videoState: VideoState.STOPPED,
screenShareState: ScreenShareState.NOT_SHARING, screenShareState: ScreenShareState.NOT_SHARING,
offeredFileId: null, offeredFileId: null,

View File

@ -2,7 +2,13 @@ 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,
AudioChannelName,
PeerAudioChannelState,
StreamType,
} from 'models/chat'
import { PeerRoom, PeerHookType, PeerStreamType } from 'lib/PeerRoom' import { PeerRoom, PeerHookType, PeerStreamType } from 'lib/PeerRoom'
interface UseRoomAudioConfig { interface UseRoomAudioConfig {
@ -19,7 +25,7 @@ export function useRoomAudio({ peerRoom }: UseRoomAudioConfig) {
string | null string | null
>(null) >(null)
const { peerList, setPeerList, setAudioState, peerAudios, setPeerAudios } = const { setPeerList, setAudioChannelState, setPeerAudioChannels } =
shellContext shellContext
useEffect(() => { useEffect(() => {
@ -32,29 +38,46 @@ export function useRoomAudio({ peerRoom }: UseRoomAudioConfig) {
})() })()
}, [audioStream]) }, [audioStream])
const [sendAudioChange, receiveAudioChange] = peerRoom.makeAction<AudioState>( const [sendAudioChange, receiveAudioChange] = peerRoom.makeAction<
PeerActions.AUDIO_CHANGE Partial<PeerAudioChannelState>
) >(PeerActions.AUDIO_CHANGE)
receiveAudioChange((audioState, peerId) => { receiveAudioChange((peerAudioChannelState, peerId) => {
const newPeerList = peerList.map(peer => { setPeerList(peerList => {
const newPeer: Peer = { ...peer } return peerList.map(peer => {
const newPeer: Peer = { ...peer }
if (peer.peerId === peerId) { const microphoneAudioChannel =
newPeer.audioState = audioState peerAudioChannelState[AudioChannelName.MICROPHONE]
if (audioState === AudioState.STOPPED) { if (microphoneAudioChannel) {
deletePeerAudio(peerId) if (peer.peerId === peerId) {
newPeer.audioChannelState = {
...newPeer.audioChannelState,
...peerAudioChannelState,
}
if (microphoneAudioChannel === AudioState.STOPPED) {
deletePeerAudio(peerId)
}
}
} }
}
return newPeer return newPeer
})
}) })
setPeerList(newPeerList)
}) })
peerRoom.onPeerStream(PeerStreamType.AUDIO, (stream, peerId) => { peerRoom.onPeerStream(PeerStreamType.AUDIO, (stream, peerId, metadata) => {
if (
typeof metadata === 'object' &&
metadata !== null &&
'type' in metadata &&
metadata.type !== StreamType.MICROPHONE
) {
return
}
const audioTracks = stream.getAudioTracks() const audioTracks = stream.getAudioTracks()
if (audioTracks.length === 0) return if (audioTracks.length === 0) return
@ -63,7 +86,13 @@ export function useRoomAudio({ peerRoom }: UseRoomAudioConfig) {
audio.srcObject = stream audio.srcObject = stream
audio.autoplay = true audio.autoplay = true
setPeerAudios({ ...peerAudios, [peerId]: audio }) setPeerAudioChannels(peerAudioChannels => ({
...peerAudioChannels,
[peerId]: {
...peerAudioChannels[peerId],
[AudioChannelName.MICROPHONE]: audio,
},
}))
}) })
const cleanupAudio = useCallback(() => { const cleanupAudio = useCallback(() => {
@ -86,9 +115,19 @@ export function useRoomAudio({ peerRoom }: UseRoomAudioConfig) {
video: false, video: false,
}) })
peerRoom.addStream(newSelfStream) peerRoom.addStream(newSelfStream, null, {
sendAudioChange(AudioState.PLAYING) type: StreamType.MICROPHONE,
setAudioState(AudioState.PLAYING) })
sendAudioChange({
[AudioChannelName.MICROPHONE]: AudioState.PLAYING,
})
setAudioChannelState(prevState => ({
...prevState,
[AudioChannelName.MICROPHONE]: AudioState.PLAYING,
}))
setAudioStream(newSelfStream) setAudioStream(newSelfStream)
} }
} else { } else {
@ -96,8 +135,16 @@ export function useRoomAudio({ peerRoom }: UseRoomAudioConfig) {
cleanupAudio() cleanupAudio()
peerRoom.removeStream(audioStream, peerRoom.getPeers()) peerRoom.removeStream(audioStream, peerRoom.getPeers())
sendAudioChange(AudioState.STOPPED)
setAudioState(AudioState.STOPPED) sendAudioChange({
[AudioChannelName.MICROPHONE]: AudioState.STOPPED,
})
setAudioChannelState(prevState => ({
...prevState,
[AudioChannelName.MICROPHONE]: AudioState.STOPPED,
}))
setAudioStream(null) setAudioStream(null)
} }
} }
@ -106,11 +153,10 @@ export function useRoomAudio({ peerRoom }: UseRoomAudioConfig) {
audioStream, audioStream,
cleanupAudio, cleanupAudio,
isSpeakingToRoom, isSpeakingToRoom,
peerAudios,
peerRoom, peerRoom,
selectedAudioDeviceId, selectedAudioDeviceId,
sendAudioChange, sendAudioChange,
setAudioState, setAudioChannelState,
]) ])
useEffect(() => { useEffect(() => {
@ -139,27 +185,45 @@ export function useRoomAudio({ peerRoom }: UseRoomAudioConfig) {
video: false, video: false,
}) })
peerRoom.addStream(newSelfStream) peerRoom.addStream(newSelfStream, null, {
type: StreamType.MICROPHONE,
})
setAudioStream(newSelfStream) setAudioStream(newSelfStream)
} }
const deletePeerAudio = (peerId: string) => { const deletePeerAudio = (peerId: string) => {
const newPeerAudios = { ...peerAudios } setPeerAudioChannels(({ ...newPeerAudios }) => {
delete newPeerAudios[peerId] if (!newPeerAudios[peerId]) {
setPeerAudios(newPeerAudios) return newPeerAudios
}
const microphoneAudio = newPeerAudios[peerId][AudioChannelName.MICROPHONE]
microphoneAudio?.pause()
const { [AudioChannelName.MICROPHONE]: _, ...newPeerAudioChannels } =
newPeerAudios[peerId]
newPeerAudios[peerId] = newPeerAudioChannels
return newPeerAudios
})
} }
const handleAudioForNewPeer = (peerId: string) => { const handleAudioForNewPeer = (peerId: string) => {
if (audioStream) { if (audioStream) {
peerRoom.addStream(audioStream, peerId) peerRoom.addStream(audioStream, peerId, {
type: StreamType.MICROPHONE,
})
} }
} }
const handleAudioForLeavingPeer = (peerId: string) => { const handleAudioForLeavingPeer = (peerId: string) => {
if (audioStream) { if (audioStream) {
peerRoom.removeStream(audioStream, peerId) peerRoom.removeStream(audioStream, peerId)
deletePeerAudio(peerId)
} }
deletePeerAudio(peerId)
} }
peerRoom.onPeerJoin(PeerHookType.AUDIO, (peerId: string) => { peerRoom.onPeerJoin(PeerHookType.AUDIO, (peerId: string) => {

View File

@ -4,7 +4,13 @@ import { isRecord } from 'lib/type-guards'
import { RoomContext } from 'contexts/RoomContext' import { RoomContext } from 'contexts/RoomContext'
import { ShellContext } from 'contexts/ShellContext' import { ShellContext } from 'contexts/ShellContext'
import { PeerActions } from 'models/network' import { PeerActions } from 'models/network'
import { ScreenShareState, Peer, VideoStreamType } from 'models/chat' import {
ScreenShareState,
Peer,
StreamType,
AudioChannelName,
AudioState,
} from 'models/chat'
import { PeerRoom, PeerHookType, PeerStreamType } from 'lib/PeerRoom' import { PeerRoom, PeerHookType, PeerStreamType } from 'lib/PeerRoom'
interface UseRoomScreenShareConfig { interface UseRoomScreenShareConfig {
@ -16,7 +22,13 @@ export function useRoomScreenShare({ peerRoom }: UseRoomScreenShareConfig) {
const roomContext = useContext(RoomContext) const roomContext = useContext(RoomContext)
const [isSharingScreen, setIsSharingScreen] = useState(false) const [isSharingScreen, setIsSharingScreen] = useState(false)
const { peerList, setPeerList, setScreenState } = shellContext const {
peerList,
setPeerList,
setScreenState,
setAudioChannelState,
setPeerAudioChannels,
} = shellContext
const { const {
peerScreenStreams, peerScreenStreams,
@ -50,7 +62,7 @@ export function useRoomScreenShare({ peerRoom }: UseRoomScreenShareConfig) {
const isScreenShareStream = const isScreenShareStream =
isRecord(metadata) && isRecord(metadata) &&
'type' in metadata && 'type' in metadata &&
metadata.type === VideoStreamType.SCREEN_SHARE metadata.type === StreamType.SCREEN_SHARE
if (!isScreenShareStream) return if (!isScreenShareStream) return
@ -58,6 +70,33 @@ export function useRoomScreenShare({ peerRoom }: UseRoomScreenShareConfig) {
...peerScreenStreams, ...peerScreenStreams,
[peerId]: stream, [peerId]: stream,
}) })
const [audioStream] = stream.getAudioTracks()
if (audioStream) {
setAudioChannelState(prevState => ({
...prevState,
[AudioChannelName.SCREEN_SHARE]: AudioState.PLAYING,
}))
const audioTracks = stream.getAudioTracks()
if (audioTracks.length > 0) {
const audio = new Audio()
audio.srcObject = stream
audio.autoplay = true
setPeerAudioChannels(peerAudioChannels => {
return {
...peerAudioChannels,
[peerId]: {
...peerAudioChannels[peerId],
[AudioChannelName.SCREEN_SHARE]: audio,
},
}
})
}
}
}) })
const cleanupScreenStream = useCallback(() => { const cleanupScreenStream = useCallback(() => {
@ -78,8 +117,9 @@ export function useRoomScreenShare({ peerRoom }: UseRoomScreenShareConfig) {
}) })
peerRoom.addStream(displayMedia, null, { peerRoom.addStream(displayMedia, null, {
type: VideoStreamType.SCREEN_SHARE, type: StreamType.SCREEN_SHARE,
}) })
setSelfScreenStream(displayMedia) setSelfScreenStream(displayMedia)
sendScreenShare(ScreenShareState.SHARING) sendScreenShare(ScreenShareState.SHARING)
setScreenState(ScreenShareState.SHARING) setScreenState(ScreenShareState.SHARING)
@ -119,15 +159,33 @@ export function useRoomScreenShare({ peerRoom }: UseRoomScreenShareConfig) {
}, [setPeerScreenStreams]) }, [setPeerScreenStreams])
const deletePeerScreen = (peerId: string) => { const deletePeerScreen = (peerId: string) => {
const newPeerScreens = { ...peerScreenStreams } setPeerScreenStreams(({ [peerId]: _, ...newPeerScreens }) => {
delete newPeerScreens[peerId] return newPeerScreens
setPeerScreenStreams(newPeerScreens) })
setPeerAudioChannels(({ ...newPeerAudios }) => {
if (!newPeerAudios[peerId]) {
return newPeerAudios
}
const screenShareAudio =
newPeerAudios[peerId][AudioChannelName.SCREEN_SHARE]
screenShareAudio?.pause()
const { [AudioChannelName.SCREEN_SHARE]: _, ...newPeerAudioChannels } =
newPeerAudios[peerId]
newPeerAudios[peerId] = newPeerAudioChannels
return newPeerAudios
})
} }
const handleScreenForNewPeer = (peerId: string) => { const handleScreenForNewPeer = (peerId: string) => {
if (selfScreenStream) { if (selfScreenStream) {
peerRoom.addStream(selfScreenStream, peerId, { peerRoom.addStream(selfScreenStream, peerId, {
type: VideoStreamType.SCREEN_SHARE, type: StreamType.SCREEN_SHARE,
}) })
} }
} }

View File

@ -3,7 +3,7 @@ import { useContext, useEffect, useCallback, useState } from 'react'
import { RoomContext } from 'contexts/RoomContext' import { RoomContext } from 'contexts/RoomContext'
import { ShellContext } from 'contexts/ShellContext' import { ShellContext } from 'contexts/ShellContext'
import { PeerActions } from 'models/network' import { PeerActions } from 'models/network'
import { VideoState, Peer, VideoStreamType } from 'models/chat' import { VideoState, Peer, StreamType } from 'models/chat'
import { PeerRoom, PeerHookType, PeerStreamType } from 'lib/PeerRoom' import { PeerRoom, PeerHookType, PeerStreamType } from 'lib/PeerRoom'
import { isRecord } from 'lib/type-guards' import { isRecord } from 'lib/type-guards'
@ -60,8 +60,9 @@ export function useRoomVideo({ peerRoom }: UseRoomVideoConfig) {
}) })
peerRoom.addStream(newSelfStream, null, { peerRoom.addStream(newSelfStream, null, {
type: VideoStreamType.WEBCAM, type: StreamType.WEBCAM,
}) })
setSelfVideoStream(newSelfStream) setSelfVideoStream(newSelfStream)
} }
})() })()
@ -93,7 +94,7 @@ export function useRoomVideo({ peerRoom }: UseRoomVideoConfig) {
const isWebcamStream = const isWebcamStream =
isRecord(metadata) && isRecord(metadata) &&
'type' in metadata && 'type' in metadata &&
metadata.type === VideoStreamType.WEBCAM metadata.type === StreamType.WEBCAM
if (!isWebcamStream) return if (!isWebcamStream) return
@ -124,8 +125,9 @@ export function useRoomVideo({ peerRoom }: UseRoomVideoConfig) {
}) })
peerRoom.addStream(newSelfStream, null, { peerRoom.addStream(newSelfStream, null, {
type: VideoStreamType.WEBCAM, type: StreamType.WEBCAM,
}) })
sendVideoChange(VideoState.PLAYING) sendVideoChange(VideoState.PLAYING)
setVideoState(VideoState.PLAYING) setVideoState(VideoState.PLAYING)
setSelfVideoStream(newSelfStream) setSelfVideoStream(newSelfStream)
@ -193,7 +195,7 @@ export function useRoomVideo({ peerRoom }: UseRoomVideoConfig) {
}, },
}) })
peerRoom.addStream(newSelfStream, null, { type: VideoStreamType.WEBCAM }) peerRoom.addStream(newSelfStream, null, { type: StreamType.WEBCAM })
setSelfVideoStream(newSelfStream) setSelfVideoStream(newSelfStream)
} }
@ -206,7 +208,7 @@ export function useRoomVideo({ peerRoom }: UseRoomVideoConfig) {
const handleVideoForNewPeer = (peerId: string) => { const handleVideoForNewPeer = (peerId: string) => {
if (selfVideoStream) { if (selfVideoStream) {
peerRoom.addStream(selfVideoStream, peerId, { peerRoom.addStream(selfVideoStream, peerId, {
type: VideoStreamType.WEBCAM, type: StreamType.WEBCAM,
}) })
} }
} }

View File

@ -9,7 +9,13 @@ import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress' import CircularProgress from '@mui/material/CircularProgress'
import { UserInfo } from 'components/UserInfo' import { UserInfo } from 'components/UserInfo'
import { AudioState, Peer } from 'models/chat' import {
AudioState,
Peer,
AudioChannel,
AudioChannelName,
PeerAudioChannelState,
} from 'models/chat'
import { PeerConnectionType } from 'lib/PeerRoom' import { PeerConnectionType } from 'lib/PeerRoom'
import { TrackerConnection } from 'lib/ConnectionTest' import { TrackerConnection } from 'lib/ConnectionTest'
@ -25,8 +31,8 @@ export interface PeerListProps extends PropsWithChildren {
onPeerListClose: () => void onPeerListClose: () => void
peerList: Peer[] peerList: Peer[]
peerConnectionTypes: Record<string, PeerConnectionType> peerConnectionTypes: Record<string, PeerConnectionType>
audioState: AudioState peerAudioChannelState: PeerAudioChannelState
peerAudios: Record<string, HTMLAudioElement> peerAudioChannels: Record<string, AudioChannel>
connectionTestResults: IConnectionTestResults connectionTestResults: IConnectionTestResults
} }
@ -36,8 +42,8 @@ export const PeerList = ({
onPeerListClose, onPeerListClose,
peerList, peerList,
peerConnectionTypes, peerConnectionTypes,
audioState, peerAudioChannelState,
peerAudios, peerAudioChannels,
connectionTestResults, connectionTestResults,
}: PeerListProps) => { }: PeerListProps) => {
return ( return (
@ -49,7 +55,8 @@ export const PeerList = ({
<Divider /> <Divider />
<List> <List>
<ListItem divider={true}> <ListItem divider={true}>
{audioState === AudioState.PLAYING && ( {peerAudioChannelState[AudioChannelName.MICROPHONE] ===
AudioState.PLAYING && (
<ListItemIcon> <ListItemIcon>
<VolumeUp /> <VolumeUp />
</ListItemIcon> </ListItemIcon>
@ -63,7 +70,7 @@ export const PeerList = ({
key={peer.peerId} key={peer.peerId}
peer={peer} peer={peer}
peerConnectionTypes={peerConnectionTypes} peerConnectionTypes={peerConnectionTypes}
peerAudios={peerAudios} peerAudioChannels={peerAudioChannels}
/> />
))} ))}
{peerList.length === 0 && {peerList.length === 0 &&

View File

@ -18,7 +18,12 @@ import EnhancedEncryptionIcon from '@mui/icons-material/EnhancedEncryption'
import { AudioVolume } from 'components/AudioVolume' import { AudioVolume } from 'components/AudioVolume'
import { PeerNameDisplay } from 'components/PeerNameDisplay' import { PeerNameDisplay } from 'components/PeerNameDisplay'
import { PublicKey } from 'components/PublicKey' import { PublicKey } from 'components/PublicKey'
import { Peer, PeerVerificationState } from 'models/chat' import {
Peer,
AudioChannel,
AudioChannelName,
PeerVerificationState,
} from 'models/chat'
import { PeerConnectionType } from 'lib/PeerRoom' import { PeerConnectionType } from 'lib/PeerRoom'
import { PeerDownloadFileButton } from './PeerDownloadFileButton' import { PeerDownloadFileButton } from './PeerDownloadFileButton'
@ -26,7 +31,7 @@ import { PeerDownloadFileButton } from './PeerDownloadFileButton'
interface PeerListItemProps { interface PeerListItemProps {
peer: Peer peer: Peer
peerConnectionTypes: Record<string, PeerConnectionType> peerConnectionTypes: Record<string, PeerConnectionType>
peerAudios: Record<string, HTMLAudioElement> peerAudioChannels: Record<string, AudioChannel>
} }
const verificationStateDisplayMap = { const verificationStateDisplayMap = {
@ -52,8 +57,8 @@ const iconRightPadding = 1
export const PeerListItem = ({ export const PeerListItem = ({
peer, peer,
peerConnectionTypes, peerConnectionTypes,
peerAudios, peerAudioChannels,
}: PeerListItemProps): JSX.Element => { }: PeerListItemProps) => {
const [showPeerDialog, setShowPeerDialog] = useState(false) const [showPeerDialog, setShowPeerDialog] = useState(false)
const hasPeerConnection = peer.peerId in peerConnectionTypes const hasPeerConnection = peer.peerId in peerConnectionTypes
@ -69,6 +74,11 @@ export const PeerListItem = ({
setShowPeerDialog(false) setShowPeerDialog(false)
} }
const microphoneAudio =
peerAudioChannels[peer.peerId]?.[AudioChannelName.MICROPHONE]
const screenShareAudio =
peerAudioChannels[peer.peerId]?.[AudioChannelName.SCREEN_SHARE]
return ( return (
<> <>
<ListItem key={peer.peerId} divider={true}> <ListItem key={peer.peerId} divider={true}>
@ -124,8 +134,17 @@ export const PeerListItem = ({
</Box> </Box>
<PeerNameDisplay>{peer.userId}</PeerNameDisplay> <PeerNameDisplay>{peer.userId}</PeerNameDisplay>
</Box> </Box>
{peer.peerId in peerAudios && ( {microphoneAudio && (
<AudioVolume audioEl={peerAudios[peer.peerId]} /> <AudioVolume
audioEl={microphoneAudio}
audioChannelName={AudioChannelName.MICROPHONE}
/>
)}
{screenShareAudio && (
<AudioVolume
audioEl={screenShareAudio}
audioChannelName={AudioChannelName.SCREEN_SHARE}
/>
)} )}
</ListItemText> </ListItemText>
</ListItem> </ListItem>

View File

@ -19,7 +19,15 @@ import { useWindowSize } from '@react-hook/window-size'
import { ShellContext } from 'contexts/ShellContext' import { ShellContext } from 'contexts/ShellContext'
import { SettingsContext } from 'contexts/SettingsContext' import { SettingsContext } from 'contexts/SettingsContext'
import { AlertOptions, QueryParamKeys } from 'models/shell' import { AlertOptions, QueryParamKeys } from 'models/shell'
import { AudioState, ScreenShareState, VideoState, Peer } from 'models/chat' import {
AudioState,
ScreenShareState,
VideoState,
Peer,
AudioChannel,
PeerAudioChannelState,
AudioChannelName,
} from 'models/chat'
import { ErrorBoundary } from 'components/ErrorBoundary' import { ErrorBoundary } from 'components/ErrorBoundary'
import { PeerConnectionType } from 'lib/PeerRoom' import { PeerConnectionType } from 'lib/PeerRoom'
@ -86,7 +94,11 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
Record<string, PeerConnectionType> Record<string, PeerConnectionType>
>({}) >({})
const [tabHasFocus, setTabHasFocus] = useState(true) const [tabHasFocus, setTabHasFocus] = useState(true)
const [audioState, setAudioState] = useState<AudioState>(AudioState.STOPPED) const [audioChannelState, setAudioChannelState] =
useState<PeerAudioChannelState>({
[AudioChannelName.MICROPHONE]: AudioState.STOPPED,
[AudioChannelName.SCREEN_SHARE]: AudioState.STOPPED,
})
const [videoState, setVideoState] = useState<VideoState>(VideoState.STOPPED) const [videoState, setVideoState] = useState<VideoState>(VideoState.STOPPED)
const [screenState, setScreenState] = useState<ScreenShareState>( const [screenState, setScreenState] = useState<ScreenShareState>(
ScreenShareState.NOT_SHARING ScreenShareState.NOT_SHARING
@ -94,8 +106,8 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
const [customUsername, setCustomUsername] = useState( const [customUsername, setCustomUsername] = useState(
getUserSettings().customUsername getUserSettings().customUsername
) )
const [peerAudios, setPeerAudios] = useState< const [peerAudioChannels, setPeerAudioChannels] = useState<
Record<string, HTMLAudioElement> Record<string, AudioChannel>
>({}) >({})
const showAlert = useCallback((message: string, options?: AlertOptions) => { const showAlert = useCallback((message: string, options?: AlertOptions) => {
@ -144,14 +156,14 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
setIsServerConnectionFailureDialogOpen, setIsServerConnectionFailureDialogOpen,
peerConnectionTypes, peerConnectionTypes,
setPeerConnectionTypes, setPeerConnectionTypes,
audioState, audioChannelState,
setAudioState, setAudioChannelState,
videoState, videoState,
setVideoState, setVideoState,
screenState, screenState,
setScreenState, setScreenState,
peerAudios, peerAudioChannels,
setPeerAudios, setPeerAudioChannels,
customUsername, customUsername,
setCustomUsername, setCustomUsername,
connectionTestResults, connectionTestResults,
@ -174,14 +186,14 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
setShowRoomControls, setShowRoomControls,
setTitle, setTitle,
showAlert, showAlert,
audioState, audioChannelState,
setAudioState, setAudioChannelState,
videoState, videoState,
setVideoState, setVideoState,
screenState, screenState,
setScreenState, setScreenState,
peerAudios, peerAudioChannels,
setPeerAudios, setPeerAudioChannels,
customUsername, customUsername,
setCustomUsername, setCustomUsername,
connectionTestResults, connectionTestResults,
@ -393,8 +405,8 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
onPeerListClose={handlePeerListClick} onPeerListClose={handlePeerListClick}
peerList={peerList} peerList={peerList}
peerConnectionTypes={peerConnectionTypes} peerConnectionTypes={peerConnectionTypes}
audioState={audioState} peerAudioChannelState={audioChannelState}
peerAudios={peerAudios} peerAudioChannels={peerAudioChannels}
connectionTestResults={connectionTestResults} connectionTestResults={connectionTestResults}
/> />
{isEmbedded ? ( {isEmbedded ? (

View File

@ -1,7 +1,15 @@
import { createContext, Dispatch, SetStateAction } from 'react' import { createContext, Dispatch, SetStateAction } from 'react'
import { AlertOptions } from 'models/shell' import { AlertOptions } from 'models/shell'
import { AudioState, ScreenShareState, VideoState, Peer } from 'models/chat' import {
AudioState,
ScreenShareState,
VideoState,
Peer,
AudioChannel,
PeerAudioChannelState,
AudioChannelName,
} from 'models/chat'
import { PeerConnectionType } from 'lib/PeerRoom' import { PeerConnectionType } from 'lib/PeerRoom'
import { ConnectionTestResults } from 'components/Shell/useConnectionTest' import { ConnectionTestResults } from 'components/Shell/useConnectionTest'
import { TrackerConnection } from 'lib/ConnectionTest' import { TrackerConnection } from 'lib/ConnectionTest'
@ -27,14 +35,14 @@ interface ShellContextProps {
setPeerConnectionTypes: Dispatch< setPeerConnectionTypes: Dispatch<
SetStateAction<Record<string, PeerConnectionType>> SetStateAction<Record<string, PeerConnectionType>>
> >
audioState: AudioState audioChannelState: PeerAudioChannelState
setAudioState: Dispatch<SetStateAction<AudioState>> setAudioChannelState: Dispatch<SetStateAction<PeerAudioChannelState>>
videoState: VideoState videoState: VideoState
setVideoState: Dispatch<SetStateAction<VideoState>> setVideoState: Dispatch<SetStateAction<VideoState>>
screenState: ScreenShareState screenState: ScreenShareState
setScreenState: Dispatch<SetStateAction<ScreenShareState>> setScreenState: Dispatch<SetStateAction<ScreenShareState>>
peerAudios: Record<string, HTMLAudioElement> peerAudioChannels: Record<string, AudioChannel>
setPeerAudios: Dispatch<SetStateAction<Record<string, HTMLAudioElement>>> setPeerAudioChannels: Dispatch<SetStateAction<Record<string, AudioChannel>>>
customUsername: string customUsername: string
setCustomUsername: Dispatch<SetStateAction<string>> setCustomUsername: Dispatch<SetStateAction<string>>
connectionTestResults: ConnectionTestResults connectionTestResults: ConnectionTestResults
@ -60,14 +68,17 @@ export const ShellContext = createContext<ShellContextProps>({
setIsServerConnectionFailureDialogOpen: () => {}, setIsServerConnectionFailureDialogOpen: () => {},
peerConnectionTypes: {}, peerConnectionTypes: {},
setPeerConnectionTypes: () => {}, setPeerConnectionTypes: () => {},
audioState: AudioState.STOPPED, audioChannelState: {
setAudioState: () => {}, [AudioChannelName.MICROPHONE]: AudioState.STOPPED,
[AudioChannelName.SCREEN_SHARE]: AudioState.STOPPED,
},
setAudioChannelState: () => {},
videoState: VideoState.STOPPED, videoState: VideoState.STOPPED,
setVideoState: () => {}, setVideoState: () => {},
screenState: ScreenShareState.NOT_SHARING, screenState: ScreenShareState.NOT_SHARING,
setScreenState: () => {}, setScreenState: () => {},
peerAudios: {}, peerAudioChannels: {},
setPeerAudios: () => {}, setPeerAudioChannels: () => {},
customUsername: '', customUsername: '',
setCustomUsername: () => {}, setCustomUsername: () => {},
connectionTestResults: { connectionTestResults: {

View File

@ -2,6 +2,7 @@ import { joinRoom, Room, BaseRoomConfig, DataPayload } from 'trystero'
import { RelayConfig } from 'trystero/torrent' import { RelayConfig } from 'trystero/torrent'
import { sleep } from 'lib/sleep' import { sleep } from 'lib/sleep'
import { StreamType } from 'models/chat'
export enum PeerHookType { export enum PeerHookType {
NEW_PEER = 'NEW_PEER', NEW_PEER = 'NEW_PEER',
@ -171,12 +172,16 @@ export class PeerRoom {
return this.room.makeAction<T>(namespace) return this.room.makeAction<T>(namespace)
} }
addStream = (...args: Parameters<Room['addStream']>) => { addStream = (
stream: Parameters<Room['addStream']>[0],
targetPeers: Parameters<Room['addStream']>[1],
metadata: { type: StreamType }
) => {
// New streams need to be added as a delayed queue to prevent race // New streams need to be added as a delayed queue to prevent race
// conditions on the receiver's end where streams and their metadata get // conditions on the receiver's end where streams and their metadata get
// mixed up. // mixed up.
this.streamQueue.push( this.streamQueue.push(
() => Promise.all(this.room.addStream(...args)), () => Promise.all(this.room.addStream(stream, targetPeers, metadata)),
() => sleep(streamQueueAddDelay) () => sleep(streamQueueAddDelay)
) )

View File

@ -31,9 +31,10 @@ export enum VideoState {
STOPPED = 'STOPPED', STOPPED = 'STOPPED',
} }
export enum VideoStreamType { export enum StreamType {
WEBCAM = 'WEBCAM', WEBCAM = 'WEBCAM',
SCREEN_SHARE = 'SCREEN_SHARE', SCREEN_SHARE = 'SCREEN_SHARE',
MICROPHONE = 'MICROPHONE',
} }
export enum ScreenShareState { export enum ScreenShareState {
@ -47,12 +48,21 @@ export enum PeerVerificationState {
VERIFIED, VERIFIED,
} }
export enum AudioChannelName {
MICROPHONE = 'microphone',
SCREEN_SHARE = 'screen-share',
}
export type AudioChannel = Partial<Record<AudioChannelName, HTMLAudioElement>>
export type PeerAudioChannelState = Record<AudioChannelName, AudioState>
export interface Peer { export interface Peer {
peerId: string peerId: string
userId: string userId: string
publicKey: CryptoKey publicKey: CryptoKey
customUsername: string customUsername: string
audioState: AudioState audioChannelState: PeerAudioChannelState
videoState: VideoState videoState: VideoState
screenShareState: ScreenShareState screenShareState: ScreenShareState
offeredFileId: string | null offeredFileId: string | null