feat: [closes #20] Video support (#64)

* 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:
Jeremy Kahn 2022-11-06 13:36:15 -06:00 committed by GitHub
parent d4e565815c
commit 828e3c12b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 606 additions and 61 deletions

View 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>
)
}

View File

@ -13,6 +13,8 @@ import { ChatTranscript } from 'components/ChatTranscript'
import { useRoom } from './useRoom'
import { RoomAudioControls } from './RoomAudioControls'
import { RoomVideoControls } from './RoomVideoControls'
import { RoomVideoDisplay } from './RoomVideoDisplay'
export interface RoomProps {
appId?: string
@ -29,7 +31,13 @@ export function Room({
password,
userId,
}: RoomProps) {
const { messageLog, peerRoom, sendMessage, isMessageSending } = useRoom(
const {
isMessageSending,
messageLog,
peerRoom,
sendMessage,
showVideoDisplay,
} = useRoom(
{
appId,
trackerUrls,
@ -53,38 +61,49 @@ export function Room({
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
flexGrow: '1',
overflow: 'auto',
}}
>
<Accordion>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="panel1a-content"
id="panel1a-header"
></AccordionSummary>
<AccordionDetails>
<Box
sx={{
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
<RoomAudioControls peerRoom={peerRoom} />
</Box>
</AccordionDetails>
</Accordion>
<ChatTranscript
messageLog={messageLog}
userId={userId}
className="grow overflow-auto px-4"
/>
<Divider />
<MessageForm
onMessageSubmit={handleMessageSubmit}
isMessageSending={isMessageSending}
/>
{showVideoDisplay && <RoomVideoDisplay userId={userId} />}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: '1',
overflow: 'auto',
}}
>
<Accordion>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="panel1a-content"
id="panel1a-header"
></AccordionSummary>
<AccordionDetails>
<Box
sx={{
alignItems: 'flex-start',
display: 'flex',
justifyContent: 'center',
}}
>
<RoomAudioControls peerRoom={peerRoom} />
<RoomVideoControls peerRoom={peerRoom} />
</Box>
</AccordionDetails>
</Accordion>
<ChatTranscript
messageLog={messageLog}
userId={userId}
className="grow overflow-auto px-4"
/>
<Divider />
<MessageForm
onMessageSubmit={handleMessageSubmit}
isMessageSending={isMessageSending}
/>
</Box>
</Box>
)
}

View File

@ -8,6 +8,7 @@ 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'
@ -53,30 +54,35 @@ export function RoomAudioControls({ peerRoom }: RoomAudioControlsProps) {
}
return (
<>
<Fab
variant="extended"
color={isSpeakingToRoom ? 'error' : 'success'}
aria-label="call"
onClick={handleVoiceCallClick}
<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'
}
>
{isSpeakingToRoom ? (
<>
<VoiceOverOff sx={{ mr: 1 }} />
Stop speaking to room
</>
) : (
<>
<RecordVoiceOver sx={{ mr: 1 }} />
Start speaking to room
</>
)}
</Fab>
<Fab
color={isSpeakingToRoom ? 'error' : 'success'}
aria-label="call"
onClick={handleVoiceCallClick}
>
{isSpeakingToRoom ? <VoiceOverOff /> : <RecordVoiceOver />}
</Fab>
</Tooltip>
{audioDevices.length > 0 && (
<Box sx={{ mt: 1 }}>
<List
component="nav"
aria-label="Audio device selection"
aria-label="Microphone selection"
sx={{ bgcolor: 'background.paper' }}
>
<ListItem
@ -84,12 +90,12 @@ export function RoomAudioControls({ peerRoom }: RoomAudioControlsProps) {
id="audio-input-select-button"
aria-haspopup="listbox"
aria-controls="audio-input-select-menu"
aria-label="Audio input device to use"
aria-label="Microphone to use"
aria-expanded={isAudioDeviceSelectOpen ? 'true' : undefined}
onClick={handleAudioDeviceListItemClick}
>
<ListItemText
primary="Selected audio input device"
primary="Selected microphone"
secondary={audioDevices[selectedAudioDeviceIdx]?.label}
/>
</ListItem>
@ -116,6 +122,6 @@ export function RoomAudioControls({ peerRoom }: RoomAudioControlsProps) {
</Menu>
</Box>
)}
</>
</Box>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -11,6 +11,7 @@ import {
Message,
ReceivedMessage,
UnsentMessage,
VideoState,
isMessageReceived,
} from 'models/chat'
import { funAnimalName } from 'fun-animal-names'
@ -39,8 +40,6 @@ export function useRoom(
() => new PeerRoom({ password: password ?? roomId, ...roomConfig }, roomId)
)
peerRoom.flush()
const [numberOfPeers, setNumberOfPeers] = useState(1) // Includes this peer
const shellContext = useContext(ShellContext)
const settingsContext = useContext(SettingsContext)
@ -110,7 +109,12 @@ export function useRoom(
if (peerIndex === -1) {
shellContext.setPeerList([
...shellContext.peerList,
{ peerId: peerId, userId: userId, audioState: AudioState.STOPPED },
{
peerId,
userId,
audioState: AudioState.STOPPED,
videoState: VideoState.STOPPED,
},
])
} else {
const newPeerList = [...shellContext.peerList]
@ -195,10 +199,15 @@ export function useRoom(
}
})
const showVideoDisplay =
shellContext.selfVideoStream ||
Object.values(shellContext.peerVideoStreams).length > 0
return {
peerRoom,
messageLog,
sendMessage,
isMessageSending,
messageLog,
peerRoom,
sendMessage,
showVideoDisplay,
}
}

View File

@ -58,6 +58,10 @@ export function useRoomAudio({ peerRoom }: UseRoomAudioConfig) {
})
peerRoom.onPeerStream(PeerStreamType.AUDIO, (stream, peerId) => {
const audioTracks = stream.getAudioTracks()
if (audioTracks.length === 0) return
const audio = new Audio()
audio.srcObject = stream
audio.autoplay = true

View 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,
}
}

View File

@ -15,7 +15,7 @@ import { AlertColor } from '@mui/material/Alert'
import { ShellContext } from 'contexts/ShellContext'
import { SettingsContext } from 'contexts/SettingsContext'
import { AlertOptions } from 'models/shell'
import { AudioState, Peer } from 'models/chat'
import { AudioState, VideoState, Peer } from 'models/chat'
import { ErrorBoundary } from 'components/ErrorBoundary'
import { Drawer } from './Drawer'
@ -45,6 +45,13 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
const [peerList, setPeerList] = useState<Peer[]>([]) // except you
const [tabHasFocus, setTabHasFocus] = useState(true)
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<
(message: string, options?: AlertOptions) => void
@ -69,6 +76,12 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
setPeerList,
audioState,
setAudioState,
videoState,
setVideoState,
selfVideoStream,
setSelfVideoStream,
peerVideoStreams,
setPeerVideoStreams,
}),
[
isPeerListOpen,
@ -82,6 +95,12 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
showAlert,
audioState,
setAudioState,
videoState,
setVideoState,
selfVideoStream,
setSelfVideoStream,
peerVideoStreams,
setPeerVideoStreams,
]
)

View File

@ -1,7 +1,7 @@
import { createContext, Dispatch, SetStateAction } from 'react'
import { AlertOptions } from 'models/shell'
import { AudioState, Peer } from 'models/chat'
import { AudioState, VideoState, Peer } from 'models/chat'
interface ShellContextProps {
numberOfPeers: number
@ -16,6 +16,12 @@ interface ShellContextProps {
setPeerList: Dispatch<SetStateAction<Peer[]>>
audioState: 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>({
@ -31,4 +37,10 @@ export const ShellContext = createContext<ShellContextProps>({
setPeerList: () => {},
audioState: AudioState.STOPPED,
setAudioState: () => {},
videoState: VideoState.STOPPED,
setVideoState: () => {},
selfVideoStream: null,
setSelfVideoStream: () => {},
peerVideoStreams: {},
setPeerVideoStreams: () => {},
})

View File

@ -10,10 +10,16 @@ export enum AudioState {
STOPPED = 'STOPPED',
}
export enum VideoState {
PLAYING = 'PLAYING',
STOPPED = 'STOPPED',
}
export interface Peer {
peerId: string
userId: string
audioState: AudioState
videoState: VideoState
}
export interface ReceivedMessage extends UnsentMessage {

View File

@ -4,4 +4,5 @@ export enum PeerActions {
MESSAGE_TRANSCRIPT = 'MSG_XSCRIPT',
PEER_NAME = 'PEER_NAME',
AUDIO_CHANGE = 'AUDIO_CHANGE',
VIDEO_CHANGE = 'VIDEO_CHANGE',
}

View File

@ -4,10 +4,12 @@ import { TorrentRoomConfig } from 'trystero/torrent'
export enum PeerHookType {
NEW_PEER = 'NEW_PEER',
AUDIO = 'AUDIO',
VIDEO = 'VIDEO',
}
export enum PeerStreamType {
AUDIO = 'AUDIO',
VIDEO = 'VIDEO',
}
export class PeerRoom {