forked from Shiloh/remnantchat
* refactor: nest ChatTranscript * feat: set up video controls * feat: show self video * feat: show peer video * feat: improve audio/video controls display * feat: flip self video * feat: improve device selection labels
This commit is contained in:
parent
d4e565815c
commit
828e3c12b9
52
src/components/Room/PeerVideo.tsx
Normal file
52
src/components/Room/PeerVideo.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import Paper from '@mui/material/Paper'
|
||||||
|
|
||||||
|
import { PeerNameDisplay } from 'components/PeerNameDisplay'
|
||||||
|
|
||||||
|
interface PeerVideoProps {
|
||||||
|
isSelf?: boolean
|
||||||
|
userId: string
|
||||||
|
videoStream: MediaStream
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PeerVideo = ({ isSelf, userId, videoStream }: PeerVideoProps) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { current: video } = videoRef
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
video.autoplay = true
|
||||||
|
video.srcObject = videoStream
|
||||||
|
}, [videoRef, videoStream])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
py: 2,
|
||||||
|
margin: '0.5em',
|
||||||
|
flexShrink: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
elevation={10}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
playsInline
|
||||||
|
ref={videoRef}
|
||||||
|
style={{
|
||||||
|
borderRadius: '1.25em',
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '1em',
|
||||||
|
...(isSelf && {
|
||||||
|
transform: 'rotateY(180deg)',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PeerNameDisplay sx={{ textAlign: 'center', display: 'block' }}>
|
||||||
|
{userId}
|
||||||
|
</PeerNameDisplay>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
@ -13,6 +13,8 @@ import { ChatTranscript } from 'components/ChatTranscript'
|
|||||||
|
|
||||||
import { useRoom } from './useRoom'
|
import { useRoom } from './useRoom'
|
||||||
import { RoomAudioControls } from './RoomAudioControls'
|
import { RoomAudioControls } from './RoomAudioControls'
|
||||||
|
import { RoomVideoControls } from './RoomVideoControls'
|
||||||
|
import { RoomVideoDisplay } from './RoomVideoDisplay'
|
||||||
|
|
||||||
export interface RoomProps {
|
export interface RoomProps {
|
||||||
appId?: string
|
appId?: string
|
||||||
@ -29,7 +31,13 @@ export function Room({
|
|||||||
password,
|
password,
|
||||||
userId,
|
userId,
|
||||||
}: RoomProps) {
|
}: RoomProps) {
|
||||||
const { messageLog, peerRoom, sendMessage, isMessageSending } = useRoom(
|
const {
|
||||||
|
isMessageSending,
|
||||||
|
messageLog,
|
||||||
|
peerRoom,
|
||||||
|
sendMessage,
|
||||||
|
showVideoDisplay,
|
||||||
|
} = useRoom(
|
||||||
{
|
{
|
||||||
appId,
|
appId,
|
||||||
trackerUrls,
|
trackerUrls,
|
||||||
@ -52,8 +60,18 @@ export function Room({
|
|||||||
className="Room"
|
className="Room"
|
||||||
sx={{
|
sx={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexGrow: '1',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showVideoDisplay && <RoomVideoDisplay userId={userId} />}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
flexGrow: '1',
|
||||||
|
overflow: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Accordion>
|
<Accordion>
|
||||||
@ -65,13 +83,13 @@ export function Room({
|
|||||||
<AccordionDetails>
|
<AccordionDetails>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
alignItems: 'center',
|
alignItems: 'flex-start',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RoomAudioControls peerRoom={peerRoom} />
|
<RoomAudioControls peerRoom={peerRoom} />
|
||||||
|
<RoomVideoControls peerRoom={peerRoom} />
|
||||||
</Box>
|
</Box>
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
@ -86,5 +104,6 @@ export function Room({
|
|||||||
isMessageSending={isMessageSending}
|
isMessageSending={isMessageSending}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ import ListItemText from '@mui/material/ListItemText'
|
|||||||
import Menu from '@mui/material/Menu'
|
import Menu from '@mui/material/Menu'
|
||||||
import MenuItem from '@mui/material/MenuItem'
|
import MenuItem from '@mui/material/MenuItem'
|
||||||
import Fab from '@mui/material/Fab'
|
import Fab from '@mui/material/Fab'
|
||||||
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
|
|
||||||
import { PeerRoom } from 'services/PeerRoom/PeerRoom'
|
import { PeerRoom } from 'services/PeerRoom/PeerRoom'
|
||||||
|
|
||||||
@ -53,30 +54,35 @@ export function RoomAudioControls({ peerRoom }: RoomAudioControlsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Box
|
||||||
|
sx={{
|
||||||
|
alignItems: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
px: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
isSpeakingToRoom
|
||||||
|
? 'Turn off microphone'
|
||||||
|
: 'Turn on microphone and speak to room'
|
||||||
|
}
|
||||||
|
>
|
||||||
<Fab
|
<Fab
|
||||||
variant="extended"
|
|
||||||
color={isSpeakingToRoom ? 'error' : 'success'}
|
color={isSpeakingToRoom ? 'error' : 'success'}
|
||||||
aria-label="call"
|
aria-label="call"
|
||||||
onClick={handleVoiceCallClick}
|
onClick={handleVoiceCallClick}
|
||||||
>
|
>
|
||||||
{isSpeakingToRoom ? (
|
{isSpeakingToRoom ? <VoiceOverOff /> : <RecordVoiceOver />}
|
||||||
<>
|
|
||||||
<VoiceOverOff sx={{ mr: 1 }} />
|
|
||||||
Stop speaking to room
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RecordVoiceOver sx={{ mr: 1 }} />
|
|
||||||
Start speaking to room
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Fab>
|
</Fab>
|
||||||
|
</Tooltip>
|
||||||
{audioDevices.length > 0 && (
|
{audioDevices.length > 0 && (
|
||||||
<Box sx={{ mt: 1 }}>
|
<Box sx={{ mt: 1 }}>
|
||||||
<List
|
<List
|
||||||
component="nav"
|
component="nav"
|
||||||
aria-label="Audio device selection"
|
aria-label="Microphone selection"
|
||||||
sx={{ bgcolor: 'background.paper' }}
|
sx={{ bgcolor: 'background.paper' }}
|
||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
@ -84,12 +90,12 @@ export function RoomAudioControls({ peerRoom }: RoomAudioControlsProps) {
|
|||||||
id="audio-input-select-button"
|
id="audio-input-select-button"
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-controls="audio-input-select-menu"
|
aria-controls="audio-input-select-menu"
|
||||||
aria-label="Audio input device to use"
|
aria-label="Microphone to use"
|
||||||
aria-expanded={isAudioDeviceSelectOpen ? 'true' : undefined}
|
aria-expanded={isAudioDeviceSelectOpen ? 'true' : undefined}
|
||||||
onClick={handleAudioDeviceListItemClick}
|
onClick={handleAudioDeviceListItemClick}
|
||||||
>
|
>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary="Selected audio input device"
|
primary="Selected microphone"
|
||||||
secondary={audioDevices[selectedAudioDeviceIdx]?.label}
|
secondary={audioDevices[selectedAudioDeviceIdx]?.label}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@ -116,6 +122,6 @@ export function RoomAudioControls({ peerRoom }: RoomAudioControlsProps) {
|
|||||||
</Menu>
|
</Menu>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
121
src/components/Room/RoomVideoControls.tsx
Normal file
121
src/components/Room/RoomVideoControls.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Videocam from '@mui/icons-material/Videocam'
|
||||||
|
import VideocamOff from '@mui/icons-material/VideocamOff'
|
||||||
|
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 Tooltip from '@mui/material/Tooltip'
|
||||||
|
|
||||||
|
import { PeerRoom } from 'services/PeerRoom/PeerRoom'
|
||||||
|
|
||||||
|
import { useRoomVideo } from './useRoomVideo'
|
||||||
|
|
||||||
|
export interface RoomVideoControlsProps {
|
||||||
|
peerRoom: PeerRoom
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomVideoControls({ peerRoom }: RoomVideoControlsProps) {
|
||||||
|
const {
|
||||||
|
videoDevices,
|
||||||
|
isCameraEnabled,
|
||||||
|
setIsCameraEnabled,
|
||||||
|
handleVideoDeviceSelect,
|
||||||
|
} = useRoomVideo({ peerRoom })
|
||||||
|
|
||||||
|
const [videoAnchorEl, setVideoAnchorEl] = useState<null | HTMLElement>(null)
|
||||||
|
const isVideoDeviceSelectOpen = Boolean(videoAnchorEl)
|
||||||
|
const [selectedVideoDeviceIdx, setSelectedVideoDeviceIdx] = useState(0)
|
||||||
|
|
||||||
|
const handleEnableCameraClick = () => {
|
||||||
|
setIsCameraEnabled(!isCameraEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVideoDeviceListItemClick = (
|
||||||
|
event: React.MouseEvent<HTMLElement>
|
||||||
|
) => {
|
||||||
|
setVideoAnchorEl(event.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVideoDeviceMenuItemClick = (
|
||||||
|
_event: React.MouseEvent<HTMLElement>,
|
||||||
|
idx: number
|
||||||
|
) => {
|
||||||
|
setSelectedVideoDeviceIdx(idx)
|
||||||
|
handleVideoDeviceSelect(videoDevices[idx])
|
||||||
|
setVideoAnchorEl(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVideoInputSelectMenuClose = () => {
|
||||||
|
setVideoAnchorEl(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
alignItems: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
px: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title={isCameraEnabled ? 'Turn off camera' : 'Turn on camera'}>
|
||||||
|
<Fab
|
||||||
|
color={isCameraEnabled ? 'error' : 'success'}
|
||||||
|
aria-label="call"
|
||||||
|
onClick={handleEnableCameraClick}
|
||||||
|
>
|
||||||
|
{isCameraEnabled ? <VideocamOff /> : <Videocam />}
|
||||||
|
</Fab>
|
||||||
|
</Tooltip>
|
||||||
|
{videoDevices.length > 0 && (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<List
|
||||||
|
component="nav"
|
||||||
|
aria-label="Camera selection"
|
||||||
|
sx={{ bgcolor: 'background.paper' }}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
button
|
||||||
|
id="video-input-select-button"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-controls="video-input-select-menu"
|
||||||
|
aria-label="Camera to use"
|
||||||
|
aria-expanded={isVideoDeviceSelectOpen ? 'true' : undefined}
|
||||||
|
onClick={handleVideoDeviceListItemClick}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary="Selected camera"
|
||||||
|
secondary={videoDevices[selectedVideoDeviceIdx]?.label}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
<Menu
|
||||||
|
id="video-input-select-menu"
|
||||||
|
anchorEl={videoAnchorEl}
|
||||||
|
open={isVideoDeviceSelectOpen}
|
||||||
|
onClose={handleVideoInputSelectMenuClose}
|
||||||
|
MenuListProps={{
|
||||||
|
'aria-labelledby': 'video-input-select-button',
|
||||||
|
role: 'listbox',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{videoDevices.map((videoDevice, idx) => (
|
||||||
|
<MenuItem
|
||||||
|
key={videoDevice.deviceId}
|
||||||
|
selected={idx === selectedVideoDeviceIdx}
|
||||||
|
onClick={event => handleVideoDeviceMenuItemClick(event, idx)}
|
||||||
|
>
|
||||||
|
{videoDevice.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
64
src/components/Room/RoomVideoDisplay.tsx
Normal file
64
src/components/Room/RoomVideoDisplay.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { useContext } from 'react'
|
||||||
|
import Paper from '@mui/material/Paper'
|
||||||
|
|
||||||
|
import { Peer } from 'models/chat'
|
||||||
|
import { ShellContext } from 'contexts/ShellContext'
|
||||||
|
|
||||||
|
import { PeerVideo } from './PeerVideo'
|
||||||
|
|
||||||
|
type PeerWithVideo = { peer: Peer; videoStream: MediaStream }
|
||||||
|
|
||||||
|
export interface RoomVideoDisplayProps {
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RoomVideoDisplay = ({ userId }: RoomVideoDisplayProps) => {
|
||||||
|
const shellContext = useContext(ShellContext)
|
||||||
|
|
||||||
|
const peersWithVideo: PeerWithVideo[] = shellContext.peerList.reduce(
|
||||||
|
(acc: PeerWithVideo[], peer: Peer) => {
|
||||||
|
const videoStream = shellContext.peerVideoStreams[peer.peerId]
|
||||||
|
if (videoStream) {
|
||||||
|
acc.push({
|
||||||
|
peer,
|
||||||
|
videoStream,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
className="RoomVideoDisplay"
|
||||||
|
elevation={3}
|
||||||
|
square
|
||||||
|
sx={{
|
||||||
|
alignItems: 'stretch',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'auto',
|
||||||
|
width: '75%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shellContext.selfVideoStream && (
|
||||||
|
<PeerVideo
|
||||||
|
isSelf
|
||||||
|
userId={userId}
|
||||||
|
videoStream={shellContext.selfVideoStream}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{peersWithVideo.map(peerWithVideo => (
|
||||||
|
<PeerVideo
|
||||||
|
key={peerWithVideo.peer.peerId}
|
||||||
|
userId={peerWithVideo.peer.userId}
|
||||||
|
videoStream={peerWithVideo.videoStream}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
@ -11,6 +11,7 @@ import {
|
|||||||
Message,
|
Message,
|
||||||
ReceivedMessage,
|
ReceivedMessage,
|
||||||
UnsentMessage,
|
UnsentMessage,
|
||||||
|
VideoState,
|
||||||
isMessageReceived,
|
isMessageReceived,
|
||||||
} from 'models/chat'
|
} from 'models/chat'
|
||||||
import { funAnimalName } from 'fun-animal-names'
|
import { funAnimalName } from 'fun-animal-names'
|
||||||
@ -39,8 +40,6 @@ export function useRoom(
|
|||||||
() => new PeerRoom({ password: password ?? roomId, ...roomConfig }, roomId)
|
() => new PeerRoom({ password: password ?? roomId, ...roomConfig }, roomId)
|
||||||
)
|
)
|
||||||
|
|
||||||
peerRoom.flush()
|
|
||||||
|
|
||||||
const [numberOfPeers, setNumberOfPeers] = useState(1) // Includes this peer
|
const [numberOfPeers, setNumberOfPeers] = useState(1) // Includes this peer
|
||||||
const shellContext = useContext(ShellContext)
|
const shellContext = useContext(ShellContext)
|
||||||
const settingsContext = useContext(SettingsContext)
|
const settingsContext = useContext(SettingsContext)
|
||||||
@ -110,7 +109,12 @@ export function useRoom(
|
|||||||
if (peerIndex === -1) {
|
if (peerIndex === -1) {
|
||||||
shellContext.setPeerList([
|
shellContext.setPeerList([
|
||||||
...shellContext.peerList,
|
...shellContext.peerList,
|
||||||
{ peerId: peerId, userId: userId, audioState: AudioState.STOPPED },
|
{
|
||||||
|
peerId,
|
||||||
|
userId,
|
||||||
|
audioState: AudioState.STOPPED,
|
||||||
|
videoState: VideoState.STOPPED,
|
||||||
|
},
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
const newPeerList = [...shellContext.peerList]
|
const newPeerList = [...shellContext.peerList]
|
||||||
@ -195,10 +199,15 @@ export function useRoom(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showVideoDisplay =
|
||||||
|
shellContext.selfVideoStream ||
|
||||||
|
Object.values(shellContext.peerVideoStreams).length > 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
peerRoom,
|
|
||||||
messageLog,
|
|
||||||
sendMessage,
|
|
||||||
isMessageSending,
|
isMessageSending,
|
||||||
|
messageLog,
|
||||||
|
peerRoom,
|
||||||
|
sendMessage,
|
||||||
|
showVideoDisplay,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -58,6 +58,10 @@ export function useRoomAudio({ peerRoom }: UseRoomAudioConfig) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
peerRoom.onPeerStream(PeerStreamType.AUDIO, (stream, peerId) => {
|
peerRoom.onPeerStream(PeerStreamType.AUDIO, (stream, peerId) => {
|
||||||
|
const audioTracks = stream.getAudioTracks()
|
||||||
|
|
||||||
|
if (audioTracks.length === 0) return
|
||||||
|
|
||||||
const audio = new Audio()
|
const audio = new Audio()
|
||||||
audio.srcObject = stream
|
audio.srcObject = stream
|
||||||
audio.autoplay = true
|
audio.autoplay = true
|
||||||
|
230
src/components/Room/useRoomVideo.ts
Normal file
230
src/components/Room/useRoomVideo.ts
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import { useContext, useEffect, useCallback, useState } from 'react'
|
||||||
|
|
||||||
|
import { ShellContext } from 'contexts/ShellContext'
|
||||||
|
import { PeerActions } from 'models/network'
|
||||||
|
import { VideoState, Peer } from 'models/chat'
|
||||||
|
import { PeerRoom, PeerHookType, PeerStreamType } from 'services/PeerRoom'
|
||||||
|
|
||||||
|
import { usePeerRoomAction } from './usePeerRoomAction'
|
||||||
|
|
||||||
|
interface UseRoomVideoConfig {
|
||||||
|
peerRoom: PeerRoom
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoomVideo({ peerRoom }: UseRoomVideoConfig) {
|
||||||
|
const shellContext = useContext(ShellContext)
|
||||||
|
const [isCameraEnabled, setIsCameraEnabled] = useState(false)
|
||||||
|
const [videoDevices, setVideoDevices] = useState<MediaDeviceInfo[]>([])
|
||||||
|
const [selectedVideoDeviceId, setSelectedVideoDeviceId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
peerList,
|
||||||
|
peerVideoStreams,
|
||||||
|
selfVideoStream,
|
||||||
|
setPeerList,
|
||||||
|
setPeerVideoStreams,
|
||||||
|
setSelfVideoStream,
|
||||||
|
setVideoState,
|
||||||
|
} = shellContext
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
if (!selfVideoStream) return
|
||||||
|
|
||||||
|
const devices = await window.navigator.mediaDevices.enumerateDevices()
|
||||||
|
const rawVideoDevices = devices.filter(
|
||||||
|
({ kind }) => kind === 'videoinput'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sometimes duplicate devices are provided by enumerateDevices, so
|
||||||
|
// dedupe them by ID.
|
||||||
|
const newVideoDevices = [
|
||||||
|
...rawVideoDevices
|
||||||
|
.reduce((acc, videoDevice) => {
|
||||||
|
return acc.set(videoDevice.deviceId, videoDevice)
|
||||||
|
}, new Map<string, MediaDeviceInfo>())
|
||||||
|
.values(),
|
||||||
|
]
|
||||||
|
|
||||||
|
setVideoDevices(newVideoDevices)
|
||||||
|
|
||||||
|
if (newVideoDevices.length > 0 && !selfVideoStream) {
|
||||||
|
const [firstVideoDevice] = newVideoDevices
|
||||||
|
const newSelfStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: {
|
||||||
|
deviceId: firstVideoDevice.deviceId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
peerRoom.addStream(newSelfStream)
|
||||||
|
setSelfVideoStream(newSelfStream)
|
||||||
|
|
||||||
|
setSelfVideoStream(newSelfStream)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [peerRoom, selfVideoStream, setSelfVideoStream])
|
||||||
|
|
||||||
|
const [sendVideoChange, receiveVideoChange] = usePeerRoomAction<VideoState>(
|
||||||
|
peerRoom,
|
||||||
|
PeerActions.VIDEO_CHANGE
|
||||||
|
)
|
||||||
|
|
||||||
|
receiveVideoChange((videoState, peerId) => {
|
||||||
|
const newPeerList = peerList.map(peer => {
|
||||||
|
const newPeer: Peer = { ...peer }
|
||||||
|
|
||||||
|
if (peer.peerId === peerId) {
|
||||||
|
newPeer.videoState = videoState
|
||||||
|
|
||||||
|
if (videoState === VideoState.STOPPED) {
|
||||||
|
deletePeerVideo(peerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPeer
|
||||||
|
})
|
||||||
|
|
||||||
|
setPeerList(newPeerList)
|
||||||
|
})
|
||||||
|
|
||||||
|
peerRoom.onPeerStream(PeerStreamType.VIDEO, (stream, peerId) => {
|
||||||
|
const videoTracks = stream.getVideoTracks()
|
||||||
|
|
||||||
|
if (videoTracks.length === 0) return
|
||||||
|
|
||||||
|
setPeerVideoStreams({
|
||||||
|
...peerVideoStreams,
|
||||||
|
[peerId]: stream,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const cleanupVideo = useCallback(() => {
|
||||||
|
if (!selfVideoStream) return
|
||||||
|
|
||||||
|
for (const videoTrack of selfVideoStream.getTracks()) {
|
||||||
|
videoTrack.stop()
|
||||||
|
selfVideoStream.removeTrack(videoTrack)
|
||||||
|
}
|
||||||
|
}, [selfVideoStream])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
if (isCameraEnabled) {
|
||||||
|
if (!selfVideoStream) {
|
||||||
|
const newSelfStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: selectedVideoDeviceId
|
||||||
|
? { deviceId: selectedVideoDeviceId }
|
||||||
|
: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
peerRoom.addStream(newSelfStream)
|
||||||
|
sendVideoChange(VideoState.PLAYING)
|
||||||
|
setVideoState(VideoState.PLAYING)
|
||||||
|
setSelfVideoStream(newSelfStream)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (selfVideoStream) {
|
||||||
|
cleanupVideo()
|
||||||
|
|
||||||
|
peerRoom.removeStream(selfVideoStream, peerRoom.getPeers())
|
||||||
|
sendVideoChange(VideoState.STOPPED)
|
||||||
|
setVideoState(VideoState.STOPPED)
|
||||||
|
setSelfVideoStream(null)
|
||||||
|
setSelfVideoStream(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [
|
||||||
|
isCameraEnabled,
|
||||||
|
peerRoom,
|
||||||
|
selfVideoStream,
|
||||||
|
selectedVideoDeviceId,
|
||||||
|
sendVideoChange,
|
||||||
|
cleanupVideo,
|
||||||
|
setSelfVideoStream,
|
||||||
|
setVideoState,
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cleanupVideo()
|
||||||
|
}
|
||||||
|
}, [cleanupVideo])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (selfVideoStream) {
|
||||||
|
setSelfVideoStream(null)
|
||||||
|
setVideoState(VideoState.STOPPED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selfVideoStream, setSelfVideoStream, setVideoState])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setPeerVideoStreams({})
|
||||||
|
}
|
||||||
|
}, [setPeerVideoStreams])
|
||||||
|
|
||||||
|
const handleVideoDeviceSelect = async (videoDevice: MediaDeviceInfo) => {
|
||||||
|
const { deviceId } = videoDevice
|
||||||
|
setSelectedVideoDeviceId(deviceId)
|
||||||
|
|
||||||
|
if (!selfVideoStream) return
|
||||||
|
|
||||||
|
for (const videoTrack of selfVideoStream.getTracks()) {
|
||||||
|
videoTrack.stop()
|
||||||
|
selfVideoStream.removeTrack(videoTrack)
|
||||||
|
}
|
||||||
|
|
||||||
|
peerRoom.removeStream(selfVideoStream, peerRoom.getPeers())
|
||||||
|
|
||||||
|
const newSelfStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: {
|
||||||
|
deviceId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
peerRoom.addStream(newSelfStream)
|
||||||
|
setSelfVideoStream(newSelfStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePeerVideo = (peerId: string) => {
|
||||||
|
const newPeerVideos = { ...peerVideoStreams }
|
||||||
|
delete newPeerVideos[peerId]
|
||||||
|
setPeerVideoStreams(newPeerVideos)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVideoForNewPeer = (peerId: string) => {
|
||||||
|
if (selfVideoStream) {
|
||||||
|
peerRoom.addStream(selfVideoStream, peerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVideoForLeavingPeer = (peerId: string) => {
|
||||||
|
if (selfVideoStream) {
|
||||||
|
peerRoom.removeStream(selfVideoStream, peerId)
|
||||||
|
deletePeerVideo(peerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peerRoom.onPeerJoin(PeerHookType.VIDEO, (peerId: string) => {
|
||||||
|
handleVideoForNewPeer(peerId)
|
||||||
|
})
|
||||||
|
|
||||||
|
peerRoom.onPeerLeave(PeerHookType.VIDEO, (peerId: string) => {
|
||||||
|
handleVideoForLeavingPeer(peerId)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoDevices,
|
||||||
|
isCameraEnabled,
|
||||||
|
setIsCameraEnabled,
|
||||||
|
handleVideoDeviceSelect,
|
||||||
|
}
|
||||||
|
}
|
@ -15,7 +15,7 @@ import { AlertColor } from '@mui/material/Alert'
|
|||||||
import { ShellContext } from 'contexts/ShellContext'
|
import { ShellContext } from 'contexts/ShellContext'
|
||||||
import { SettingsContext } from 'contexts/SettingsContext'
|
import { SettingsContext } from 'contexts/SettingsContext'
|
||||||
import { AlertOptions } from 'models/shell'
|
import { AlertOptions } from 'models/shell'
|
||||||
import { AudioState, Peer } from 'models/chat'
|
import { AudioState, VideoState, Peer } from 'models/chat'
|
||||||
import { ErrorBoundary } from 'components/ErrorBoundary'
|
import { ErrorBoundary } from 'components/ErrorBoundary'
|
||||||
|
|
||||||
import { Drawer } from './Drawer'
|
import { Drawer } from './Drawer'
|
||||||
@ -45,6 +45,13 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
|
|||||||
const [peerList, setPeerList] = useState<Peer[]>([]) // except you
|
const [peerList, setPeerList] = useState<Peer[]>([]) // except you
|
||||||
const [tabHasFocus, setTabHasFocus] = useState(true)
|
const [tabHasFocus, setTabHasFocus] = useState(true)
|
||||||
const [audioState, setAudioState] = useState<AudioState>(AudioState.STOPPED)
|
const [audioState, setAudioState] = useState<AudioState>(AudioState.STOPPED)
|
||||||
|
const [videoState, setVideoState] = useState<VideoState>(VideoState.STOPPED)
|
||||||
|
const [selfVideoStream, setSelfVideoStream] = useState<MediaStream | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
const [peerVideoStreams, setPeerVideoStreams] = useState<
|
||||||
|
Record<string, MediaStream>
|
||||||
|
>({})
|
||||||
|
|
||||||
const showAlert = useCallback<
|
const showAlert = useCallback<
|
||||||
(message: string, options?: AlertOptions) => void
|
(message: string, options?: AlertOptions) => void
|
||||||
@ -69,6 +76,12 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
|
|||||||
setPeerList,
|
setPeerList,
|
||||||
audioState,
|
audioState,
|
||||||
setAudioState,
|
setAudioState,
|
||||||
|
videoState,
|
||||||
|
setVideoState,
|
||||||
|
selfVideoStream,
|
||||||
|
setSelfVideoStream,
|
||||||
|
peerVideoStreams,
|
||||||
|
setPeerVideoStreams,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
isPeerListOpen,
|
isPeerListOpen,
|
||||||
@ -82,6 +95,12 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
|
|||||||
showAlert,
|
showAlert,
|
||||||
audioState,
|
audioState,
|
||||||
setAudioState,
|
setAudioState,
|
||||||
|
videoState,
|
||||||
|
setVideoState,
|
||||||
|
selfVideoStream,
|
||||||
|
setSelfVideoStream,
|
||||||
|
peerVideoStreams,
|
||||||
|
setPeerVideoStreams,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { createContext, Dispatch, SetStateAction } from 'react'
|
import { createContext, Dispatch, SetStateAction } from 'react'
|
||||||
|
|
||||||
import { AlertOptions } from 'models/shell'
|
import { AlertOptions } from 'models/shell'
|
||||||
import { AudioState, Peer } from 'models/chat'
|
import { AudioState, VideoState, Peer } from 'models/chat'
|
||||||
|
|
||||||
interface ShellContextProps {
|
interface ShellContextProps {
|
||||||
numberOfPeers: number
|
numberOfPeers: number
|
||||||
@ -16,6 +16,12 @@ interface ShellContextProps {
|
|||||||
setPeerList: Dispatch<SetStateAction<Peer[]>>
|
setPeerList: Dispatch<SetStateAction<Peer[]>>
|
||||||
audioState: AudioState
|
audioState: AudioState
|
||||||
setAudioState: Dispatch<SetStateAction<AudioState>>
|
setAudioState: Dispatch<SetStateAction<AudioState>>
|
||||||
|
videoState: VideoState
|
||||||
|
setVideoState: Dispatch<SetStateAction<VideoState>>
|
||||||
|
selfVideoStream: MediaStream | null
|
||||||
|
setSelfVideoStream: Dispatch<SetStateAction<MediaStream | null>>
|
||||||
|
peerVideoStreams: Record<string, MediaStream>
|
||||||
|
setPeerVideoStreams: Dispatch<SetStateAction<Record<string, MediaStream>>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShellContext = createContext<ShellContextProps>({
|
export const ShellContext = createContext<ShellContextProps>({
|
||||||
@ -31,4 +37,10 @@ export const ShellContext = createContext<ShellContextProps>({
|
|||||||
setPeerList: () => {},
|
setPeerList: () => {},
|
||||||
audioState: AudioState.STOPPED,
|
audioState: AudioState.STOPPED,
|
||||||
setAudioState: () => {},
|
setAudioState: () => {},
|
||||||
|
videoState: VideoState.STOPPED,
|
||||||
|
setVideoState: () => {},
|
||||||
|
selfVideoStream: null,
|
||||||
|
setSelfVideoStream: () => {},
|
||||||
|
peerVideoStreams: {},
|
||||||
|
setPeerVideoStreams: () => {},
|
||||||
})
|
})
|
||||||
|
@ -10,10 +10,16 @@ export enum AudioState {
|
|||||||
STOPPED = 'STOPPED',
|
STOPPED = 'STOPPED',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum VideoState {
|
||||||
|
PLAYING = 'PLAYING',
|
||||||
|
STOPPED = 'STOPPED',
|
||||||
|
}
|
||||||
|
|
||||||
export interface Peer {
|
export interface Peer {
|
||||||
peerId: string
|
peerId: string
|
||||||
userId: string
|
userId: string
|
||||||
audioState: AudioState
|
audioState: AudioState
|
||||||
|
videoState: VideoState
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReceivedMessage extends UnsentMessage {
|
export interface ReceivedMessage extends UnsentMessage {
|
||||||
|
@ -4,4 +4,5 @@ export enum PeerActions {
|
|||||||
MESSAGE_TRANSCRIPT = 'MSG_XSCRIPT',
|
MESSAGE_TRANSCRIPT = 'MSG_XSCRIPT',
|
||||||
PEER_NAME = 'PEER_NAME',
|
PEER_NAME = 'PEER_NAME',
|
||||||
AUDIO_CHANGE = 'AUDIO_CHANGE',
|
AUDIO_CHANGE = 'AUDIO_CHANGE',
|
||||||
|
VIDEO_CHANGE = 'VIDEO_CHANGE',
|
||||||
}
|
}
|
||||||
|
@ -4,10 +4,12 @@ import { TorrentRoomConfig } from 'trystero/torrent'
|
|||||||
export enum PeerHookType {
|
export enum PeerHookType {
|
||||||
NEW_PEER = 'NEW_PEER',
|
NEW_PEER = 'NEW_PEER',
|
||||||
AUDIO = 'AUDIO',
|
AUDIO = 'AUDIO',
|
||||||
|
VIDEO = 'VIDEO',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PeerStreamType {
|
export enum PeerStreamType {
|
||||||
AUDIO = 'AUDIO',
|
AUDIO = 'AUDIO',
|
||||||
|
VIDEO = 'VIDEO',
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PeerRoom {
|
export class PeerRoom {
|
||||||
|
Loading…
Reference in New Issue
Block a user