├── public ├── robots.txt ├── favicon.ico ├── favicon.png ├── symbl-logo192.png ├── manifest.json └── index.html ├── docs └── symbl-credentials.png ├── src ├── constants.js ├── setupTests.js ├── theme.js ├── components │ ├── VideoProvider │ │ ├── useHandleOnDisconnect │ │ │ └── useHandleOnDisconnect.js │ │ ├── useHandleRoomDisconnectionErrors │ │ │ └── useHandleRoomDisconnectionErrors.js │ │ ├── useHandleTrackPublicationFailed │ │ │ └── useHandleTrackPublicationFailed.js │ │ ├── useSelectedParticipant │ │ │ └── useSelectedParticipant.js │ │ ├── AttachVisibilityHandler │ │ │ └── AttachVisibilityHandler.js │ │ ├── useLocalTracks │ │ │ └── useLocalTracks.js │ │ ├── index.js │ │ └── useRoom │ │ │ └── useRoom.js │ ├── LocalVideoPreview │ │ └── LocalVideoPreview.js │ ├── ParticipantInfo │ │ ├── PinIcon │ │ │ └── PinIcon.js │ │ ├── ParticipantConnectionIndicator │ │ │ └── ParticipantConnectionIndicator.js │ │ └── ParticipantInfo.js │ ├── MenuBar │ │ ├── DeviceSelector │ │ │ ├── LocalAudioLevelIndicator │ │ │ │ └── LocalAudioLevelIndicator.js │ │ │ ├── DeviceSelector.js │ │ │ ├── deviceHooks │ │ │ │ └── deviceHooks.js │ │ │ ├── AudioOutputList │ │ │ │ └── AudioOutputList.js │ │ │ ├── VideoInputList │ │ │ │ └── VideoInputList.js │ │ │ └── AudioInputList │ │ │ │ └── AudioInputList.js │ │ ├── UserAvatar │ │ │ └── UserAvatar.js │ │ ├── SettingsDialog │ │ │ └── SettingsDialog.js │ │ ├── Menu │ │ │ └── Menu.js │ │ ├── SymblCredentialsDialog │ │ │ └── SymblCredentialsDialog.js │ │ ├── MenuBar.js │ │ ├── CredentialsOptions │ │ │ └── CredentialsOptions.js │ │ └── ConnectionOptions │ │ │ └── ConnectionOptions.js │ ├── Participant │ │ └── Participant.js │ ├── ParticipantTracks │ │ ├── __snapshots__ │ │ │ └── ParticipantTracks.test.tsx.snap │ │ └── ParticipantTracks.js │ ├── AudioTrack │ │ └── AudioTrack.js │ ├── BandwidthWarning │ │ └── BandwidthWarning.js │ ├── PrivateRoute │ │ └── PrivateRoute.js │ ├── ParticipantStrip │ │ ├── __snapshots__ │ │ │ └── ParticipantStrip.test.tsx.snap │ │ └── ParticipantStrip.js │ ├── Publication │ │ └── Publication.js │ ├── NewtorkQualityLevel │ │ ├── NetworkQualityLevel.js │ │ └── __snapshots__ │ │ │ └── NetworkQualityLevel.test.tsx.snap │ ├── Controls │ │ ├── useIsUserActive │ │ │ └── useIsUserActive.js │ │ ├── ToggleVideoButton │ │ │ └── ToggleVideoButton.js │ │ ├── ToggleAudioButton │ │ │ └── ToggleAudioButton.js │ │ ├── EndCallButton │ │ │ └── EndCallButton.js │ │ ├── Controls.js │ │ └── ToogleScreenShareButton │ │ │ └── ToggleScreenShareButton.js │ ├── Room │ │ └── Room.js │ ├── MainParticipant │ │ └── MainParticipant.js │ ├── ReconnectingNotification │ │ └── ReconnectingNotification.js │ ├── VideoTrack │ │ └── VideoTrack.js │ ├── UnsupportedBrowserWarning │ │ ├── __snapshots__ │ │ │ └── UnsupportedBrowserWarning.test.tsx.snap │ │ └── UnsupportedBrowserWarning.js │ ├── MainParticipantInfo │ │ └── MainParticipantInfo.js │ ├── ClosedCaptions │ │ └── ClosedCaptions.js │ ├── SymblProvider │ │ ├── index.js │ │ └── useSymbl │ │ │ └── useSymbl.js │ ├── Transcript │ │ ├── Transcript.js │ │ └── TranscriptItem │ │ │ └── TranscriptItem.js │ └── AudioLevelIndicator │ │ └── AudioLevelIndicator.js ├── hooks │ ├── useVideoContext │ │ └── useVideoContext.js │ ├── useSymblContext │ │ └── useSymblContext.js │ ├── useHeight │ │ └── useHeight.js │ ├── useLocalAudioToggle │ │ └── useLocalAudioToggle.js │ ├── useIsTrackEnabled │ │ └── useIsTrackEnabled.js │ ├── useTrack │ │ └── useTrack.js │ ├── useParticipantIsReconnecting │ │ └── useParticipantIsReconnecting.js │ ├── useRoomState │ │ └── useRoomState.js │ ├── useFullScreenToggle │ │ └── useFullScreenToggle.js │ ├── usePublicationIsTrackEnabled │ │ └── usePublicationIsTrackEnabled.js │ ├── useParticipantNetworkQualityLevel │ │ └── useParticipantNetworkQualityLevel.js │ ├── useMediaStreamTrack │ │ └── useMediaStreamTrack.js │ ├── useIsTrackSwitchedOff │ │ └── useIsTrackSwitchedOff.js │ ├── usePublications │ │ └── usePublications.js │ ├── useMainSpeaker │ │ └── useMainSpeaker.js │ ├── useLocalVideoToggle │ │ └── useLocalVideoToggle.js │ ├── useDominantSpeaker │ │ └── useDominantSpeaker.js │ ├── useScreenShareParticipant │ │ └── useScreenShareParticipant.js │ ├── useScreenShareToggle │ │ └── useScreenShareToggle.js │ └── useParticipants │ │ └── useParticipants.js ├── index.css ├── App.css ├── config.js ├── state │ ├── settings │ │ ├── settingsReducer.js │ │ └── renderDimensions.js │ └── index.js ├── utils │ ├── index.js │ ├── symbl │ │ ├── utils.js │ │ ├── telephony.js │ │ └── SymblTwilioConnector.js │ ├── generateConnectionOptions │ │ └── generateConnectionOptions.js │ └── websocket │ │ └── WebSocket.js ├── index.js ├── logo.svg ├── App.js └── serviceWorker.js ├── .gitignore ├── .github ├── pull_request_template.md └── ISSUE_TEMPLATE │ ├── documentation_update.yaml │ ├── feature-request.yaml │ └── bug_report.yaml ├── .env ├── package.json ├── server.js ├── Contributing.md └── README.md /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/symblai/symbl-twilio-video-react/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/symblai/symbl-twilio-video-react/HEAD/public/favicon.png -------------------------------------------------------------------------------- /public/symbl-logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/symblai/symbl-twilio-video-react/HEAD/public/symbl-logo192.png -------------------------------------------------------------------------------- /docs/symbl-credentials.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/symblai/symbl-twilio-video-react/HEAD/docs/symbl-credentials.png -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const DEFAULT_VIDEO_CONSTRAINTS = { 2 | width: 1280, 3 | height: 720, 4 | frameRate: 24, 5 | }; 6 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | node_modules 4 | 5 | # testing 6 | /coverage 7 | 8 | # production 9 | /build 10 | 11 | # misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Type of Pull Request 2 | > Check all applicable 3 | 4 | - [ ] 🎉 Feature Enhancement 5 | - [ ] 🐛 Bug Fix 6 | - [ ] 📖 Documentation Update 7 | - [ ] ❇️ Other 8 | 9 | ## Description 10 | > Describe the changes made in the PR. 11 | 12 | ## Issue 13 | > Provide the details of the issue / attach the issue link. 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/theme.js: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core'; 2 | 3 | export default createMuiTheme({ 4 | palette: { 5 | type: 'dark', 6 | primary: { 7 | main: '#1A1A1A', 8 | }, 9 | secondary: { 10 | main: '#E3A019' 11 | }, 12 | }, 13 | sidebarWidth: 260, 14 | sidebarMobileHeight: 90, 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/VideoProvider/useHandleOnDisconnect/useHandleOnDisconnect.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export default function useHandleOnDisconnect(room, onDisconnect) { 4 | useEffect(() => { 5 | room.on('disconnected', onDisconnect); 6 | return () => { 7 | room.off('disconnected', onDisconnect); 8 | }; 9 | }, [room, onDisconnect]); 10 | } 11 | -------------------------------------------------------------------------------- /src/hooks/useVideoContext/useVideoContext.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { VideoContext } from '../../components/VideoProvider'; 3 | 4 | export default function useVideoContext() { 5 | const context = useContext(VideoContext); 6 | if (!context) { 7 | throw new Error('useVideoContext must be used within a VideoProvider'); 8 | } 9 | return context; 10 | } 11 | -------------------------------------------------------------------------------- /src/hooks/useSymblContext/useSymblContext.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { SymblContext } from '../../components/SymblProvider'; 3 | 4 | export default function useSymblContext() { 5 | const context = useContext(SymblContext); 6 | if (!context) { 7 | throw new Error('useSymblContext must be used within a IntelligenceProvider'); 8 | } 9 | return context; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/LocalVideoPreview/LocalVideoPreview.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import VideoTrack from '../VideoTrack/VideoTrack'; 3 | import useVideoContext from '../../hooks/useVideoContext/useVideoContext'; 4 | 5 | export default function LocalVideoPreview() { 6 | const { localTracks } = useVideoContext(); 7 | 8 | const videoTrack = localTracks.find(track => track.name.includes('camera')); 9 | 10 | return videoTrack ? : null; 11 | } 12 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Symbl Video App", 3 | "name": "Symbl Video App", 4 | "icons": [ 5 | { 6 | "src": "favicon.png", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "symbl-logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | } 15 | ], 16 | "start_url": ".", 17 | "display": "standalone", 18 | "theme_color": "#1a1a1a", 19 | "background_color": "#fefefe" 20 | } 21 | -------------------------------------------------------------------------------- /src/components/ParticipantInfo/PinIcon/PinIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PinIcon as Pin } from '@primer/octicons-react'; 3 | import Tooltip from '@material-ui/core/Tooltip'; 4 | import SvgIcon from '@material-ui/core/SvgIcon'; 5 | 6 | export default function PinIcon() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/components/VideoProvider/useHandleRoomDisconnectionErrors/useHandleRoomDisconnectionErrors.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export default function useHandleRoomDisconnectionErrors(room, onError) { 4 | useEffect(() => { 5 | const onDisconnected = (room, error) => { 6 | if (error) { 7 | onError(error); 8 | } 9 | }; 10 | 11 | room.on('disconnected', onDisconnected); 12 | return () => { 13 | room.off('disconnected', onDisconnected); 14 | }; 15 | }, [room, onError]); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/VideoProvider/useHandleTrackPublicationFailed/useHandleTrackPublicationFailed.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export default function useHandleTrackPublicationFailed(room, onError) { 4 | const { localParticipant } = room; 5 | useEffect(() => { 6 | if (localParticipant) { 7 | localParticipant.on('trackPublicationFailed', onError); 8 | return () => { 9 | localParticipant.off('trackPublicationFailed', onError); 10 | }; 11 | } 12 | }, [localParticipant, onError]); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/MenuBar/DeviceSelector/LocalAudioLevelIndicator/LocalAudioLevelIndicator.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useVideoContext from '../../../../hooks/useVideoContext/useVideoContext'; 3 | import AudioLevelIndicator from '../../../AudioLevelIndicator/AudioLevelIndicator'; 4 | 5 | export default function LocalAudioLevelIndicator() { 6 | const { localTracks } = useVideoContext(); 7 | const audioTrack = localTracks.find(track => track.kind === 'audio'); 8 | 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Comfortaa:wght@300&display=swap'); 2 | 3 | body { 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 6 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 7 | sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | } 11 | 12 | code { 13 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 14 | monospace; 15 | } 16 | 17 | .message{ 18 | color: #2E3440; 19 | font-family: 'Comfortaa', cursive; 20 | } -------------------------------------------------------------------------------- /src/hooks/useHeight/useHeight.js: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | 3 | export default function useHeight() { 4 | const [height, setHeight] = useState(window.innerHeight * (window.visualViewPort ? window.visualViewPort.scale : 1)); 5 | 6 | useEffect(() => { 7 | const onResize = () => { 8 | setHeight(window.innerHeight * (window.visualViewport?.scale || 1)); 9 | }; 10 | 11 | window.addEventListener('resize', onResize); 12 | return () => { 13 | window.removeEventListener('resize', onResize); 14 | }; 15 | }); 16 | 17 | return height + 'px'; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Participant/Participant.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ParticipantInfo from '../ParticipantInfo/ParticipantInfo'; 3 | import ParticipantTracks from '../ParticipantTracks/ParticipantTracks'; 4 | 5 | export default function Participant({ 6 | participant, 7 | disableAudio, 8 | enableScreenShare, 9 | onClick, 10 | isSelected, 11 | }) { 12 | return ( 13 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/hooks/useLocalAudioToggle/useLocalAudioToggle.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import useIsTrackEnabled from '../useIsTrackEnabled/useIsTrackEnabled'; 3 | import useVideoContext from '../useVideoContext/useVideoContext'; 4 | 5 | export default function useLocalAudioToggle() { 6 | const { localTracks } = useVideoContext(); 7 | const audioTrack = localTracks.find(track => track.kind === 'audio'); 8 | const isEnabled = useIsTrackEnabled(audioTrack); 9 | 10 | const toggleAudioEnabled = useCallback(() => { 11 | if (audioTrack) { 12 | audioTrack.isEnabled ? audioTrack.disable() : audioTrack.enable(); 13 | } 14 | }, [audioTrack]); 15 | 16 | return [isEnabled, toggleAudioEnabled]; 17 | } 18 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/hooks/useIsTrackEnabled/useIsTrackEnabled.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export default function useIsTrackEnabled(track) { 4 | const [isEnabled, setIsEnabled] = useState(track ? track.isEnabled : false); 5 | 6 | useEffect(() => { 7 | setIsEnabled(track ? track.isEnabled : false); 8 | 9 | if (track) { 10 | const setEnabled = () => setIsEnabled(true); 11 | const setDisabled = () => setIsEnabled(false); 12 | track.on('enabled', setEnabled); 13 | track.on('disabled', setDisabled); 14 | return () => { 15 | track.off('enabled', setEnabled); 16 | track.off('disabled', setDisabled); 17 | }; 18 | } 19 | }, [track]); 20 | 21 | return isEnabled; 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/useTrack/useTrack.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export default function useTrack(publication) { 4 | const [track, setTrack] = useState(publication && publication.track); 5 | 6 | useEffect(() => { 7 | // Reset the track when the 'publication' variable changes. 8 | setTrack(publication && publication.track); 9 | 10 | if (publication) { 11 | const removeTrack = () => setTrack(null); 12 | 13 | publication.on('subscribed', setTrack); 14 | publication.on('unsubscribed', removeTrack); 15 | return () => { 16 | publication.off('subscribed', setTrack); 17 | publication.off('unsubscribed', removeTrack); 18 | }; 19 | } 20 | }, [publication]); 21 | 22 | return track; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/ParticipantTracks/__snapshots__/ParticipantTracks.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`the ParticipantTracks component should render an array of publications 1`] = ` 4 | 5 | 17 | 29 | 30 | `; 31 | -------------------------------------------------------------------------------- /src/components/AudioTrack/AudioTrack.js: -------------------------------------------------------------------------------- 1 | import {useEffect, useRef} from 'react'; 2 | import {useAppState} from '../../state'; 3 | 4 | export default function AudioTrack({track}) { 5 | const {activeSinkId} = useAppState(); 6 | const audioEl = useRef(); 7 | 8 | useEffect(() => { 9 | audioEl.current = track.attach(); 10 | audioEl.current.setAttribute('data-cy-audio-track-name', track.name); 11 | document.body.appendChild(audioEl.current); 12 | return () => track.detach().forEach(el => el.remove()); 13 | }, [track]); 14 | 15 | useEffect(() => { 16 | if (audioEl.current && audioEl.current.setSinkId) { 17 | audioEl.current.setSinkId(activeSinkId); 18 | } 19 | }, [activeSinkId]); 20 | 21 | return null; 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/useParticipantIsReconnecting/useParticipantIsReconnecting.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export default function useParticipantIsReconnecting(participant) { 4 | const [isReconnecting, setIsReconnecting] = useState(false); 5 | 6 | useEffect(() => { 7 | if (participant.on) { 8 | const handleReconnecting = () => setIsReconnecting(true); 9 | const handleReconnected = () => setIsReconnecting(false); 10 | 11 | participant.on('reconnecting', handleReconnecting); 12 | participant.on('reconnected', handleReconnected); 13 | return () => { 14 | participant.off('reconnecting', handleReconnecting); 15 | participant.off('reconnected', handleReconnected); 16 | }; 17 | } 18 | }, [participant]); 19 | 20 | return isReconnecting; 21 | } 22 | -------------------------------------------------------------------------------- /src/hooks/useRoomState/useRoomState.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import useVideoContext from '../useVideoContext/useVideoContext'; 3 | 4 | export default function useRoomState() { 5 | const { room } = useVideoContext(); 6 | const [state, setState] = useState('disconnected'); 7 | 8 | useEffect(() => { 9 | const setRoomState = () => setState((room.state || 'disconnected')); 10 | setRoomState(); 11 | room 12 | .on('disconnected', setRoomState) 13 | .on('reconnected', setRoomState) 14 | .on('reconnecting', setRoomState); 15 | return () => { 16 | room 17 | .off('disconnected', setRoomState) 18 | .off('reconnected', setRoomState) 19 | .off('reconnecting', setRoomState); 20 | }; 21 | }, [room]); 22 | return {roomState: state, room}; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/BandwidthWarning/BandwidthWarning.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { styled } from '@material-ui/core'; 3 | 4 | import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline'; 5 | 6 | const BandwidthWarningContainer = styled('div')({ 7 | position: 'absolute', 8 | zIndex: 1, 9 | width: '100%', 10 | display: 'flex', 11 | flexDirection: 'column', 12 | justifyContent: 'center', 13 | alignItems: 'center', 14 | }); 15 | 16 | const Warning = styled('h3')({ 17 | textAlign: 'center', 18 | margin: '0.6em 0', 19 | }); 20 | 21 | export default function BandwidthWarning() { 22 | return ( 23 | 24 |
25 | 26 |
27 | Insufficient Bandwidth 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/PrivateRoute/PrivateRoute.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Redirect, Route } from 'react-router-dom'; 3 | import { useAppState } from '../../state'; 4 | 5 | export default function PrivateRoute({ children, ...rest }) { 6 | const { isAuthReady, user } = useAppState(); 7 | 8 | const renderChildren = user || !process.env.REACT_APP_SET_AUTH; 9 | 10 | if (!renderChildren && !isAuthReady) { 11 | return null; 12 | } 13 | 14 | return ( 15 | 18 | renderChildren ? ( 19 | children 20 | ) : ( 21 | 27 | ) 28 | } 29 | /> 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/hooks/useFullScreenToggle/useFullScreenToggle.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useState, useEffect } from 'react'; 2 | import fscreen from 'fscreen'; 3 | 4 | export default function useFullScreenToggle() { 5 | const [isFullScreen, setIsFullScreen] = useState(!!fscreen.fullscreenElement); 6 | 7 | useEffect(() => { 8 | const onFullScreenChange = () => setIsFullScreen(!!fscreen.fullscreenElement); 9 | fscreen.addEventListener('fullscreenchange', onFullScreenChange); 10 | return () => { 11 | fscreen.removeEventListener('fullscreenchange', onFullScreenChange); 12 | }; 13 | }, []); 14 | 15 | const toggleFullScreen = useCallback(() => { 16 | isFullScreen ? fscreen.exitFullscreen() : fscreen.requestFullscreen(document.documentElement); 17 | }, [isFullScreen]); 18 | 19 | return [isFullScreen, toggleFullScreen]; 20 | } 21 | -------------------------------------------------------------------------------- /src/components/ParticipantStrip/__snapshots__/ParticipantStrip.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`the ParticipantStrip component should correctly render ParticipantInfo components 1`] = ` 4 | 5 | 6 | 11 | 21 | 31 | 32 | 33 | `; 34 | -------------------------------------------------------------------------------- /src/hooks/usePublicationIsTrackEnabled/usePublicationIsTrackEnabled.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export default function usePublicationIsTrackEnabled(publication) { 4 | const [isEnabled, setIsEnabled] = useState(publication ? publication.isTrackEnabled : false); 5 | 6 | useEffect(() => { 7 | setIsEnabled(publication ? publication.isTrackEnabled : false); 8 | 9 | if (publication) { 10 | const setEnabled = () => setIsEnabled(true); 11 | const setDisabled = () => setIsEnabled(false); 12 | publication.on('trackEnabled', setEnabled); 13 | publication.on('trackDisabled', setDisabled); 14 | return () => { 15 | publication.off('trackEnabled', setEnabled); 16 | publication.off('trackDisabled', setDisabled); 17 | }; 18 | } 19 | }, [publication]); 20 | 21 | return isEnabled; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/MenuBar/UserAvatar/UserAvatar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Avatar from '@material-ui/core/Avatar'; 3 | import makeStyles from '@material-ui/styles/makeStyles'; 4 | import Person from '@material-ui/icons/Person'; 5 | 6 | const useStyles = makeStyles({ 7 | red: { 8 | color: 'white', 9 | backgroundColor: '#F22F46', 10 | }, 11 | }); 12 | 13 | export function getInitials(name) { 14 | return name 15 | .split(' ') 16 | .map(text => text[0]) 17 | .join('') 18 | .toUpperCase(); 19 | } 20 | 21 | export default function UserAvatar({ user = {}}) { 22 | const classes = useStyles(); 23 | const { displayName, photoURL } = user; 24 | 25 | return photoURL ? ( 26 | 27 | ) : ( 28 | {displayName ? getInitials(displayName) : } 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/Publication/Publication.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useTrack from '../../hooks/useTrack/useTrack'; 3 | import AudioTrack from '../AudioTrack/AudioTrack'; 4 | import VideoTrack from '../VideoTrack/VideoTrack'; 5 | 6 | export default function Publication({ publication, isLocal, disableAudio, videoPriority, mainParticipant }) { 7 | const track = useTrack(publication); 8 | 9 | if (!track) return null; 10 | 11 | switch (track.kind) { 12 | case 'video': 13 | return ( 14 | 20 | ); 21 | case 'audio': 22 | return disableAudio ? null : ; 23 | default: 24 | return null; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation_update.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation Update 2 | description: Raise a request for the documentation update 3 | title: "[Docs Update]: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to contribute to our documentation. 10 | - type: input 11 | id: contact 12 | attributes: 13 | label: Contact Details 14 | description: How can we get in touch with you if we need more info? 15 | placeholder: ex. email@example.com 16 | validations: 17 | required: false 18 | - type: textarea 19 | attributes: 20 | label: Description 21 | description: | 22 | Brief description about the issue. 23 | 24 | Tip: You can attach images by clicking this area to highlight it and then dragging files in. 25 | validations: 26 | required: false -------------------------------------------------------------------------------- /src/hooks/useParticipantNetworkQualityLevel/useParticipantNetworkQualityLevel.js: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | 3 | export default function useParticipantNetworkQualityLevel(participant = {}) { 4 | const [networkQualityLevel, setNetworkQualityLevel] = useState(participant.networkQualityLevel); 5 | 6 | useEffect(() => { 7 | const handleNetworkQualityLevelChange = (newNetworkQualityLevel) => 8 | setNetworkQualityLevel(newNetworkQualityLevel); 9 | 10 | setNetworkQualityLevel(participant.networkQualityLevel); 11 | if (participant.on) { 12 | participant.on('networkQualityLevelChanged', handleNetworkQualityLevelChange); 13 | return () => { 14 | participant.off('networkQualityLevelChanged', handleNetworkQualityLevelChange); 15 | }; 16 | } 17 | }, [participant]); 18 | 19 | return networkQualityLevel; 20 | } 21 | -------------------------------------------------------------------------------- /src/components/NewtorkQualityLevel/NetworkQualityLevel.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { styled } from '@material-ui/core/styles'; 3 | 4 | const Container = styled('div')({ 5 | display: 'flex', 6 | alignItems: 'flex-end', 7 | '& div': { 8 | width: '2px', 9 | border: '1px solid black', 10 | boxSizing: 'content-box', 11 | '&:not(:last-child)': { 12 | borderRight: 'none', 13 | }, 14 | }, 15 | }); 16 | 17 | const STEP = 3; 18 | 19 | export default function NetworkQualityLevel({ qualityLevel }) { 20 | if (qualityLevel === null) return null; 21 | return ( 22 | 23 | {[0, 1, 2, 3, 4].map(level => ( 24 |
level ? '#0c0' : '#040', 29 | }} 30 | /> 31 | ))} 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Raise a request for a feature that we haven't thought of 3 | title: "[Feature]: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to raise a request for a new feature. 10 | - type: input 11 | id: contact 12 | attributes: 13 | label: Contact Details 14 | description: How can we get in touch with you if we need more info? 15 | placeholder: ex. email@example.com 16 | validations: 17 | required: false 18 | - type: textarea 19 | attributes: 20 | label: Anything else? 21 | description: | 22 | Links? References? Anything that will give us more context about this feature! 23 | 24 | Tip: You can attach images by clicking this area to highlight it and then dragging files in. 25 | validations: 26 | required: false -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | symbl: { 3 | // If set to true, a dialog for entering Symbl App ID and App Secret will pop up. 4 | // Set this to false if you'd like to use token generation for Symbl in the backend server. appId and appSecret settings will be disabled. 5 | enableInAppCredentials: true, 6 | // appId and appSecret will be populated automatically from localstorage after user enters them in Settings Dialog. 7 | // WARNING: You can hard code them here but it's not recommended because it'll expose your credentials in the source in browser. 8 | // If you wish to use common appId and appSecret pair then consider setting up a Token Server. Refer to server.js in root of this project. 9 | appId: localStorage.getItem('symblAppId') || '', 10 | appSecret: localStorage.getItem('symblAppSecret') || '' 11 | }, 12 | appBasePath: "/" // Set this to something else if you want to deploy multiple versions on same server. Always end with / 13 | }; 14 | export default config; -------------------------------------------------------------------------------- /src/hooks/useMediaStreamTrack/useMediaStreamTrack.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | /* 4 | * This hook allows components to reliably use the 'mediaStreamTrack' property of 5 | * an AudioTrack or a VideoTrack. Whenever 'localTrack.restart(...)' is called, it 6 | * will replace the mediaStreamTrack property of the localTrack, but the localTrack 7 | * object will stay the same. Therefore this hook is needed in order for components 8 | * to rerender in response to the mediaStreamTrack changing. 9 | */ 10 | export default function useMediaStreamTrack(track) { 11 | const [mediaStreamTrack, setMediaStreamTrack] = useState(track ? track.mediaStreamTrack : null); 12 | 13 | useEffect(() => { 14 | setMediaStreamTrack(track?.mediaStreamTrack); 15 | 16 | if (track) { 17 | const handleStarted = () => setMediaStreamTrack(track.mediaStreamTrack); 18 | track.on('started', handleStarted); 19 | return () => { 20 | track.off('started', handleStarted); 21 | }; 22 | } 23 | }, [track]); 24 | 25 | return mediaStreamTrack; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/MenuBar/DeviceSelector/DeviceSelector.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import AudioInputList from './AudioInputList/AudioInputList'; 4 | import AudioOutputList from './AudioOutputList/AudioOutputList'; 5 | import { DialogContent } from '@material-ui/core'; 6 | import { makeStyles } from '@material-ui/core/styles'; 7 | import VideoInputList from './VideoInputList/VideoInputList'; 8 | 9 | const useStyles = makeStyles({ 10 | listSection: { 11 | margin: '2em 0', 12 | '&:first-child': { 13 | margin: '1em 0 2em 0', 14 | }, 15 | }, 16 | }); 17 | 18 | export function DeviceSelector({ className, hidden }) { 19 | const classes = useStyles(); 20 | 21 | return ( 22 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/hooks/useIsTrackSwitchedOff/useIsTrackSwitchedOff.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | // The 'switchedOff' event is emitted when there is not enough bandwidth to support 4 | // a track. See: https://www.twilio.com/docs/video/tutorials/using-bandwidth-profile-api#understanding-track-switch-offs 5 | 6 | export default function useIsTrackSwitchedOff(track) { 7 | const [isSwitchedOff, setIsSwitchedOff] = useState(track && track.isSwitchedOff); 8 | 9 | useEffect(() => { 10 | // Reset the value if the 'track' variable changes 11 | setIsSwitchedOff(track && track.isSwitchedOff); 12 | 13 | if (track) { 14 | const handleSwitchedOff = () => setIsSwitchedOff(true); 15 | const handleSwitchedOn = () => setIsSwitchedOff(false); 16 | track.on('switchedOff', handleSwitchedOff); 17 | track.on('switchedOn', handleSwitchedOn); 18 | return () => { 19 | track.off('switchedOff', handleSwitchedOff); 20 | track.off('switchedOn', handleSwitchedOn); 21 | }; 22 | } 23 | }, [track]); 24 | 25 | return !!isSwitchedOff; 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/usePublications/usePublications.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export default function usePublications(participant = {}) { 4 | const [publications, setPublications] = useState([]); 5 | 6 | useEffect(() => { 7 | // Reset the publications when the 'participant' variable changes. 8 | setPublications(Array.from(participant.tracks ? participant.tracks.values() : [])); 9 | 10 | const publicationAdded = (publication) => 11 | setPublications(prevPublications => [...prevPublications, publication]); 12 | const publicationRemoved = (publication) => 13 | setPublications(prevPublications => prevPublications.filter(p => p !== publication)); 14 | if (participant.on) { 15 | participant.on('trackPublished', publicationAdded); 16 | participant.on('trackUnpublished', publicationRemoved); 17 | return () => { 18 | participant.off('trackPublished', publicationAdded); 19 | participant.off('trackUnpublished', publicationRemoved); 20 | }; 21 | } 22 | }, [participant]); 23 | 24 | return publications; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/ParticipantInfo/ParticipantConnectionIndicator/ParticipantConnectionIndicator.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import useParticipantIsReconnecting from '../../../hooks/useParticipantIsReconnecting/useParticipantIsReconnecting'; 5 | import { Tooltip } from '@material-ui/core'; 6 | 7 | const useStyles = makeStyles({ 8 | indicator: { 9 | width: '10px', 10 | height: '10px', 11 | borderRadius: '100%', 12 | background: '#0c0', 13 | display: 'inline-block', 14 | marginRight: '3px', 15 | }, 16 | isReconnecting: { 17 | background: '#ffb100', 18 | }, 19 | }); 20 | 21 | export default function ParticipantConnectionIndicator({ participant }) { 22 | const isReconnecting = useParticipantIsReconnecting(participant); 23 | const classes = useStyles(); 24 | return ( 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/hooks/useMainSpeaker/useMainSpeaker.js: -------------------------------------------------------------------------------- 1 | import useVideoContext from '../useVideoContext/useVideoContext'; 2 | import useDominantSpeaker from '../useDominantSpeaker/useDominantSpeaker'; 3 | import useParticipants from '../useParticipants/useParticipants'; 4 | import useScreenShareParticipant from '../useScreenShareParticipant/useScreenShareParticipant'; 5 | import useSelectedParticipant from '../../components/VideoProvider/useSelectedParticipant/useSelectedParticipant'; 6 | 7 | export default function useMainSpeaker() { 8 | const [selectedParticipant] = useSelectedParticipant(); 9 | const screenShareParticipant = useScreenShareParticipant(); 10 | const dominantSpeaker = useDominantSpeaker(); 11 | const participants = useParticipants(); 12 | const { 13 | room: { localParticipant }, 14 | } = useVideoContext(); 15 | 16 | // The participant that is returned is displayed in the main video area. Changing the order of the following 17 | // variables will change the how the main speaker is determined. 18 | return selectedParticipant || screenShareParticipant || dominantSpeaker || participants[0] || localParticipant; 19 | } 20 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # ---------------- 2 | # PLEASE READ 3 | # ---------------- 4 | # This file is an example of the .env file that is needed in order for certain 5 | # features of this application to function properly. Use this file as a template 6 | # to create your own .env file. The application will not read the .env.example file. 7 | 8 | # The following values must be populated for the local token server to dispense tokens. 9 | # See README.md for instructions on how to find these credentials in the Symbl Console - https://platform.symbl.ai 10 | SYMBL_APP_ID= 11 | SYMBL_APP_SECRET= 12 | # Populate this if you have access to custom Symbl endpoint and would like to use it 13 | # SYMBL_API_BASE_PATH= 14 | 15 | # See README.md for instructions on how to find these credentials in the Twilio Console - https://twilio.com/login 16 | TWILIO_ACCOUNT_SID= 17 | TWILIO_API_KEY_SID= 18 | TWILIO_API_KEY_SECRET= 19 | 20 | # Access Token Endpoints. Change these if you would like to point the Token server hosted on non-local endpoint 21 | REACT_APP_SYMBL_TOKEN_ENDPOINT=http://localhost:8081/symbl-token 22 | REACT_APP_TWILIO_TOKEN_ENDPOINT=http://localhost:8081/twilio-token 23 | -------------------------------------------------------------------------------- /src/components/Controls/useIsUserActive/useIsUserActive.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import throttle from 'lodash.throttle'; 3 | 4 | export default function useIsUserActive() { 5 | const [isUserActive, setIsUserActive] = useState(true); 6 | const timeoutIDRef = useRef(0); 7 | 8 | useEffect(() => { 9 | const handleUserActivity = throttle(() => { 10 | setIsUserActive(true); 11 | clearTimeout(timeoutIDRef.current); 12 | const timeoutID = window.setTimeout(() => setIsUserActive(false), 5000); 13 | timeoutIDRef.current = timeoutID; 14 | }, 500); 15 | 16 | handleUserActivity(); 17 | 18 | window.addEventListener('mousemove', handleUserActivity); 19 | window.addEventListener('click', handleUserActivity); 20 | window.addEventListener('keydown', handleUserActivity); 21 | return () => { 22 | window.removeEventListener('mousemove', handleUserActivity); 23 | window.removeEventListener('click', handleUserActivity); 24 | window.removeEventListener('keydown', handleUserActivity); 25 | clearTimeout(timeoutIDRef.current); 26 | }; 27 | }, []); 28 | 29 | return isUserActive; 30 | } 31 | -------------------------------------------------------------------------------- /src/state/settings/settingsReducer.js: -------------------------------------------------------------------------------- 1 | import { isMobile } from '../../utils'; 2 | 3 | export const initialSettings = { 4 | trackSwitchOffMode: undefined, 5 | dominantSpeakerPriority: 'standard', 6 | bandwidthProfileMode: 'collaboration', 7 | maxTracks: isMobile ? '5' : '10', 8 | maxAudioBitrate: '16000', 9 | renderDimensionLow: 'low', 10 | renderDimensionStandard: '960p', 11 | renderDimensionHigh: 'wide1080p', 12 | symblAppId: localStorage.getItem('symblAppId') || '', 13 | symblAppSecret: localStorage.getItem('symblAppSecret') || '' 14 | }; 15 | 16 | // This inputLabels object is used by ConnectionOptions.js. It is used to populate the id, name, and label props 17 | // of the various input elements. Using a typed object like this (instead of strings) eliminates the possibility 18 | // of there being a typo. 19 | export const inputLabels = (() => { 20 | const target = {}; 21 | for (const setting in initialSettings) { 22 | target[setting] = setting; 23 | } 24 | return target; 25 | })(); 26 | 27 | export function settingsReducer(state, action) { 28 | return { 29 | ...state, 30 | [action.name]: action.value === 'default' ? undefined : action.value, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/components/VideoProvider/useSelectedParticipant/useSelectedParticipant.js: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState, useEffect } from 'react'; 2 | 3 | export const selectedParticipantContext = createContext(null); 4 | 5 | export default function useSelectedParticipant() { 6 | const [selectedParticipant, setSelectedParticipant] = useContext(selectedParticipantContext); 7 | return [selectedParticipant, setSelectedParticipant]; 8 | } 9 | 10 | export function SelectedParticipantProvider({ room, children }) { 11 | const [selectedParticipant, _setSelectedParticipant] = useState(null); 12 | const setSelectedParticipant = (participant) => 13 | _setSelectedParticipant(prevParticipant => (prevParticipant === participant ? null : participant)); 14 | 15 | useEffect(() => { 16 | const onDisconnect = () => _setSelectedParticipant(null); 17 | room.on('disconnected', onDisconnect); 18 | return () => { 19 | room.off('disconnected', onDisconnect); 20 | }; 21 | }, [room]); 22 | 23 | return ( 24 | 25 | {children} 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/MenuBar/DeviceSelector/deviceHooks/deviceHooks.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { ensureMediaPermissions } from '../../../../utils'; 3 | 4 | export function useDevices() { 5 | const [devices, setDevices] = useState([]); 6 | 7 | useEffect(() => { 8 | const getDevices = () => 9 | ensureMediaPermissions().then(() => 10 | navigator.mediaDevices.enumerateDevices().then(devices => setDevices(devices)) 11 | ); 12 | navigator.mediaDevices.addEventListener('devicechange', getDevices); 13 | getDevices(); 14 | 15 | return () => { 16 | navigator.mediaDevices.removeEventListener('devicechange', getDevices); 17 | }; 18 | }, []); 19 | 20 | return devices; 21 | } 22 | 23 | export function useAudioInputDevices() { 24 | const devices = useDevices(); 25 | return devices.filter(device => device.kind === 'audioinput'); 26 | } 27 | 28 | export function useVideoInputDevices() { 29 | const devices = useDevices(); 30 | return devices.filter(device => device.kind === 'videoinput'); 31 | } 32 | 33 | export function useAudioOutputDevices() { 34 | const devices = useDevices(); 35 | return devices.filter(device => device.kind === 'audiooutput'); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/Room/Room.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ParticipantStrip from '../ParticipantStrip/ParticipantStrip'; 3 | import {styled} from '@material-ui/core/styles'; 4 | import MainParticipant from '../MainParticipant/MainParticipant'; 5 | import Transcript from "../Transcript/Transcript"; 6 | import useSymblContext from "../../hooks/useSymblContext/useSymblContext"; 7 | import CircularProgress from "@material-ui/core/CircularProgress"; 8 | 9 | const Container = styled('div')(({ theme }) => ({ 10 | position: 'relative', 11 | height: '100%', 12 | gridTemplateColumns: `${theme.sidebarWidth}px 1fr`, 13 | gridTemplateAreas: '". participantList transcript"', 14 | gridTemplateRows: '100%', 15 | [theme.breakpoints.down('xs')]: { 16 | gridTemplateAreas: '"participantList" "."', 17 | gridTemplateColumns: `auto`, 18 | gridTemplateRows: `calc(100% - ${theme.sidebarMobileHeight + 12}px) ${theme.sidebarMobileHeight + 6}px`, 19 | gridGap: '6px', 20 | }, 21 | })); 22 | 23 | export default function Room() { 24 | const { isStarting } = useSymblContext(); 25 | return ( 26 | 27 | { isStarting ? : undefined} 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/MainParticipant/MainParticipant.js: -------------------------------------------------------------------------------- 1 | import MainParticipantInfo from '../MainParticipantInfo/MainParticipantInfo'; 2 | import ParticipantTracks from '../ParticipantTracks/ParticipantTracks'; 3 | import React from 'react'; 4 | import useMainSpeaker from '../../hooks/useMainSpeaker/useMainSpeaker'; 5 | import useSelectedParticipant from '../VideoProvider/useSelectedParticipant/useSelectedParticipant'; 6 | import useScreenShareParticipant from '../../hooks/useScreenShareParticipant/useScreenShareParticipant'; 7 | import Transcript from "../Transcript/Transcript"; 8 | 9 | export default function MainParticipant() { 10 | const mainParticipant = useMainSpeaker(); 11 | const [selectedParticipant] = useSelectedParticipant(); 12 | const screenShareParticipant = useScreenShareParticipant(); 13 | 14 | const videoPriority = 15 | mainParticipant === selectedParticipant || mainParticipant === screenShareParticipant ? 'high' : null; 16 | 17 | return ( 18 | /* audio is disabled for this participant component because this participant's audio 19 | is already being rendered in the component. */ 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/ReconnectingNotification/ReconnectingNotification.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | 4 | import InfoIcon from '@material-ui/icons/Info'; 5 | import Snackbar from '@material-ui/core/Snackbar'; 6 | import { SnackbarContent } from '@material-ui/core'; 7 | 8 | import useRoomState from '../../hooks/useRoomState/useRoomState'; 9 | import useSymblContext from "../../hooks/useSymblContext/useSymblContext"; 10 | 11 | const useStyles = makeStyles({ 12 | snackbar: { 13 | backgroundColor: '#6db1ff', 14 | }, 15 | message: { 16 | display: 'flex', 17 | alignItems: 'center', 18 | }, 19 | icon: { 20 | marginRight: '0.8em', 21 | }, 22 | }); 23 | 24 | export default function ReconnectingNotification() { 25 | const classes = useStyles(); 26 | const roomState = useRoomState(); 27 | 28 | let content = undefined; 29 | 30 | if (roomState === 'reconnecting') { 31 | content = "Reconnecting" 32 | } 33 | 34 | return ( 35 | 36 | 40 | 41 | {content}… 42 | 43 | } 44 | /> 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Symbl Video App 28 | 29 | 30 | 31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /src/components/MenuBar/DeviceSelector/AudioOutputList/AudioOutputList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormControl, MenuItem, Typography, Select } from '@material-ui/core'; 3 | import { useAppState } from '../../../../state'; 4 | import { useAudioOutputDevices } from '../deviceHooks/deviceHooks'; 5 | 6 | export default function AudioOutputList() { 7 | const audioOutputDevices = useAudioOutputDevices(); 8 | const { activeSinkId, setActiveSinkId } = useAppState(); 9 | const activeOutputDevice = audioOutputDevices.find(device => device.deviceId === activeSinkId); 10 | const activeOutputLabel = activeOutputDevice? activeOutputDevice.label : undefined; 11 | 12 | return ( 13 |
14 | {audioOutputDevices.length > 1 ? ( 15 | 16 | Audio Output: 17 | 24 | 25 | ) : ( 26 | <> 27 | Audio Output: 28 | {activeOutputLabel || 'System Default Audio Output'} 29 | 30 | )} 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | import isPlainObject from 'is-plain-object'; 2 | 3 | export const isMobile = (() => { 4 | if (typeof navigator === 'undefined' || typeof navigator.userAgent !== 'string') { 5 | return false; 6 | } 7 | return /Mobile/.test(navigator.userAgent); 8 | })(); 9 | 10 | // This function ensures that the user has granted the browser permission to use audio and video 11 | // devices. If permission has not been granted, it will cause the browser to ask for permission 12 | // for audio and video at the same time (as opposed to separate requests). 13 | export function ensureMediaPermissions() { 14 | return navigator.mediaDevices 15 | .enumerateDevices() 16 | .then(devices => devices.every(device => !(device.deviceId && device.label))) 17 | .then(shouldAskForMediaPermissions => { 18 | if (shouldAskForMediaPermissions) { 19 | return navigator.mediaDevices 20 | .getUserMedia({ audio: true, video: true }) 21 | .then(mediaStream => mediaStream.getTracks().forEach(track => track.stop())); 22 | } 23 | }); 24 | } 25 | 26 | // Recursively removes any object keys with a value of undefined 27 | export function removeUndefineds(obj) { 28 | if (!isPlainObject(obj)) return obj; 29 | 30 | const target = {}; 31 | 32 | for (const key in obj) { 33 | const val = obj[key]; 34 | if (typeof val !== 'undefined') { 35 | target[key] = removeUndefineds(val); 36 | } 37 | } 38 | 39 | return target; 40 | } 41 | -------------------------------------------------------------------------------- /src/components/Controls/ToggleVideoButton/ToggleVideoButton.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef } from 'react'; 2 | import { createStyles, makeStyles } from '@material-ui/core/styles'; 3 | 4 | import Fab from '@material-ui/core/Fab'; 5 | import Tooltip from '@material-ui/core/Tooltip'; 6 | import Videocam from '@material-ui/icons/Videocam'; 7 | import VideocamOff from '@material-ui/icons/VideocamOff'; 8 | 9 | import useLocalVideoToggle from '../../../hooks/useLocalVideoToggle/useLocalVideoToggle'; 10 | 11 | const useStyles = makeStyles((theme) => 12 | createStyles({ 13 | fab: { 14 | margin: theme.spacing(1), 15 | }, 16 | }) 17 | ); 18 | 19 | export default function ToggleVideoButton(props) { 20 | const classes = useStyles(); 21 | const [isVideoEnabled, toggleVideoEnabled] = useLocalVideoToggle(); 22 | const lastClickTimeRef = useRef(0); 23 | 24 | const toggleVideo = useCallback(() => { 25 | if (Date.now() - lastClickTimeRef.current > 200) { 26 | lastClickTimeRef.current = Date.now(); 27 | toggleVideoEnabled(); 28 | } 29 | }, [toggleVideoEnabled]); 30 | 31 | return ( 32 | 37 | 38 | {isVideoEnabled ? : } 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/Controls/ToggleAudioButton/ToggleAudioButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {createStyles, makeStyles} from '@material-ui/core/styles'; 3 | 4 | import Fab from '@material-ui/core/Fab'; 5 | import Mic from '@material-ui/icons/Mic'; 6 | import MicOff from '@material-ui/icons/MicOff'; 7 | import Tooltip from '@material-ui/core/Tooltip'; 8 | 9 | import useLocalAudioToggle from '../../../hooks/useLocalAudioToggle/useLocalAudioToggle'; 10 | import useSymblContext from "../../../hooks/useSymblContext/useSymblContext"; 11 | 12 | const useStyles = makeStyles((theme) => 13 | createStyles({ 14 | fab: { 15 | margin: theme.spacing(1), 16 | }, 17 | }) 18 | ); 19 | 20 | export default function ToggleAudioButton(props) { 21 | const classes = useStyles(); 22 | const [isAudioEnabled, toggleAudioEnabled] = useLocalAudioToggle(); 23 | 24 | const {muteSymbl, unMuteSymbl, isMute} = useSymblContext(); 25 | 26 | return ( 27 | 32 | { 33 | toggleAudioEnabled(); 34 | if (isMute) { 35 | unMuteSymbl(); 36 | } else { 37 | muteSymbl(); 38 | } 39 | }} disabled={props.disabled} data-cy-audio-toggle> 40 | {isAudioEnabled ? : } 41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/components/VideoTrack/VideoTrack.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useRef} from 'react'; 2 | import {styled} from '@material-ui/core/styles'; 3 | import useMediaStreamTrack from '../../hooks/useMediaStreamTrack/useMediaStreamTrack'; 4 | 5 | const Video = styled('video')({ 6 | height: '100%', 7 | width: '100%', 8 | }); 9 | 10 | export default function VideoTrack({ track, isLocal, priority, mainParticipant }) { 11 | const videoElementRef = useRef(null); 12 | const mediaStreamTrack = useMediaStreamTrack(track); 13 | 14 | useEffect(() => { 15 | const el = videoElementRef.current; 16 | el.muted = true; 17 | if (track.setPriority && priority) { 18 | track.setPriority(priority); 19 | } 20 | track.attach(el); 21 | return () => { 22 | track.detach(el); 23 | if (track.setPriority && priority) { 24 | // Passing `null` to setPriority will set the track's priority to that which it was published with. 25 | track.setPriority(null); 26 | } 27 | }; 28 | }, [track, priority]); 29 | 30 | // The local video track is mirrored if it is not facing the environment. 31 | const isFrontFacing = mediaStreamTrack?.getSettings().facingMode !== 'environment'; 32 | const style = isLocal && isFrontFacing ? { transform: 'rotateY(180deg)' } : {}; 33 | 34 | // console.log(priority) 35 | // const mainParticipantStyle = isLocal && isFrontFacing && priority === 'high' ? { height: '100%', objectFit: 'cover' } : {} 36 | 37 | return