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 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
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 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/src/utils/generateConnectionOptions/generateConnectionOptions.js:
--------------------------------------------------------------------------------
1 | import {isMobile, removeUndefineds} from '..';
2 | import {getResolution} from '../../state/settings/renderDimensions';
3 |
4 | export default function generateConnectionOptions(settings) {
5 | // See: https://media.twiliocdn.com/sdk/js/video/releases/2.0.0/docs/global.html#ConnectOptions
6 | // for available connection options.
7 | const connectionOptions = {
8 | // Bandwidth Profile, Dominant Speaker, and Network Quality
9 | // features are only available in Small Group or Group Rooms.
10 | // Please set "Room Type" to "Group" or "Small Group" in your
11 | // Twilio Console: https://www.twilio.com/console/video/configure
12 | bandwidthProfile: {
13 | video: {
14 | mode: settings.bandwidthProfileMode,
15 | dominantSpeakerPriority: settings.dominantSpeakerPriority,
16 | renderDimensions: {
17 | low: getResolution(settings.renderDimensionLow),
18 | standard: getResolution(settings.renderDimensionStandard),
19 | high: getResolution(settings.renderDimensionHigh),
20 | },
21 | maxTracks: Number(settings.maxTracks),
22 | },
23 | },
24 | dominantSpeaker: true,
25 | networkQuality: {local: 1, remote: 1},
26 |
27 | // Comment this line if you are playing music.
28 | maxAudioBitrate: Number(settings.maxAudioBitrate),
29 |
30 | // VP8 simulcast enables the media server in a Small Group or Group Room
31 | // to adapt your encoded video quality for each RemoteParticipant based on
32 | // their individual bandwidth constraints. This has no effect if you are
33 | // using Peer-to-Peer Rooms.
34 | preferredVideoCodecs: [{codec: 'VP8', simulcast: true}],
35 | };
36 |
37 | // For mobile browsers, limit the maximum incoming video bitrate to 2.5 Mbps.
38 | if (isMobile && connectionOptions && connectionOptions.bandwidthProfile && connectionOptions.bandwidthProfile.video) {
39 | connectionOptions.bandwidthProfile.video.maxSubscriptionBitrate = 2500000;
40 | }
41 |
42 | // Here we remove any 'undefined' values. The twilio-video SDK will only use defaults
43 | // when no value is passed for an option. It will throw an error when 'undefined' is passed.
44 | return removeUndefineds(connectionOptions);
45 | }
46 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const app = express();
3 | const fetch = require('node-fetch');
4 | const path = require('path');
5 | const AccessToken = require('twilio').jwt.AccessToken;
6 | const VideoGrant = AccessToken.VideoGrant;
7 | require('dotenv').config();
8 |
9 | const symblAppId = process.env.SYMBL_APP_ID;
10 | const symblAppSecret = process.env.SYMBL_APP_SECRET;
11 | const symblApiBasePath = process.env.SYMBL_API_BASE_PATH || 'https://api.symbl.ai';
12 |
13 | const MAX_ALLOWED_SESSION_DURATION = 14400;
14 | const twilioAccountSid = process.env.TWILIO_ACCOUNT_SID;
15 | const twilioApiKeySID = process.env.TWILIO_API_KEY_SID;
16 | const twilioApiKeySecret = process.env.TWILIO_API_KEY_SECRET;
17 |
18 | app.use(express.static(path.join(__dirname, 'build')));
19 |
20 | app.use((req, res, next) => {
21 | res.header("Access-Control-Allow-Origin", "*");
22 | res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, X-API-KEY");
23 | next();
24 | });
25 |
26 |
27 | app.get('/symbl-token', async (req, res) => {
28 | try {
29 | const response = await fetch(`${symblApiBasePath}/oauth2/token:generate`, {
30 | method: 'POST',
31 | mode: 'cors',
32 | cache: 'no-cache',
33 | credentials: 'same-origin',
34 | headers: {
35 | 'Content-Type': 'application/json',
36 | },
37 | redirect: 'follow',
38 | referrerPolicy: 'no-referrer',
39 | body: JSON.stringify({
40 | type: 'application',
41 | appId: symblAppId,
42 | appSecret: symblAppSecret
43 | })
44 | });
45 | res.json(await response.json()).end();
46 | } catch (e) {
47 | console.error('Error while issuing Symbl Token.', e);
48 | res.status(401)
49 | .json({
50 | message: e.toString()
51 | }).end()
52 | }
53 | });
54 |
55 | app.get('/twilio-token', (req, res) => {
56 | const { identity, roomName } = req.query;
57 | const token = new AccessToken(twilioAccountSid, twilioApiKeySID, twilioApiKeySecret, {
58 | ttl: MAX_ALLOWED_SESSION_DURATION,
59 | });
60 | token.identity = identity;
61 | const videoGrant = new VideoGrant({ room: roomName });
62 | token.addGrant(videoGrant);
63 | res.send(token.toJwt());
64 | console.log(`issued token for ${identity} in room ${roomName}`);
65 | });
66 |
67 | app.get('*', (_, res) => res.sendFile(path.join(__dirname, 'build/index.html')));
68 |
69 | app.listen(8081, () => console.log('token server running on 8081'));
70 |
--------------------------------------------------------------------------------
/src/utils/symbl/telephony.js:
--------------------------------------------------------------------------------
1 | import "symbl-node/build/client.sdk.min";
2 | const clientSDK = new window.ClientSDK();
3 |
4 | export const startEndpoint = async (roomName, options = {}, callback) => {
5 | try {
6 | await clientSDK.init({
7 | appId: process.env.SYMBL_APP_ID || '343975686f32655930584c377a594c5159745a467a5a3863357530316f494438',
8 | appSecret: process.env.SYMBL_APP_SECRET || '4836554e4e377849744e6b314958726d3174715772674435695874545f78674e522d34536c454349716f3256745334486d6869516e4477553378587a597a5044',
9 | basePath: process.env.SYMBL_API_BASE_PATH || 'https://api.symbl.ai'
10 | });
11 |
12 | const connection = await clientSDK.startEndpoint({
13 | endpoint: {
14 | type: 'sip',
15 | uri: `sip:${roomName}@${process.env.TWILIO_SIP_URI || 'symbl.sip.twilio.com'}`
16 | },
17 | actions: [{
18 | "invokeOn": "stop",
19 | "name": "sendSummaryEmail",
20 | "parameters": {
21 | "emails": [ // Add any email addresses to which the email should be sent
22 | "toshish@symbl.ai"
23 | ]
24 | }
25 | }],
26 | data: {
27 | session: {
28 | name: `Live Intent Detection Demo - ${phoneNumber}` // Title of the Meeting, this will be reflected in summary email if configured.
29 | }
30 | }
31 | }, callback);
32 |
33 | const connectionId = connection.connectionId;
34 | console.log('Call established for connectionId: ' + connectionId);
35 | return connectionId;
36 | } catch (e) {
37 | console.error('Error in establishing startEndpoint call with SDK', e);
38 | throw e;
39 | }
40 | };
41 |
42 | export const stopEndpoint = async (connectionId) => {
43 | console.log('Stopping connection for ' + connectionId);
44 |
45 | try {
46 | const connection = await clientSDK.stopEndpoint({
47 | connectionId
48 | });
49 |
50 | console.log('Summary Info:', connection.summaryInfo);
51 | console.log('Conversation ID:', connection.conversationId);
52 |
53 | return {
54 | summaryInfo: connection.summaryInfo,
55 | conversationId: connection.conversationId
56 | };
57 | } catch (e) {
58 | console.error('Error while stopping the connection.', e);
59 | throw e;
60 | }
61 | };
62 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import {BrowserRouter as Router, Redirect, Switch} from 'react-router-dom';
4 | import './index.css';
5 | import App from './App';
6 | import AppStateProvider, {useAppState} from './state';
7 | import * as serviceWorker from './serviceWorker';
8 | import {VideoProvider} from "./components/VideoProvider";
9 | import UnsupportedBrowserWarning from "./components/UnsupportedBrowserWarning/UnsupportedBrowserWarning";
10 | import generateConnectionOptions from "./utils/generateConnectionOptions/generateConnectionOptions";
11 | import CssBaseline from "@material-ui/core/CssBaseline";
12 | import {MuiThemeProvider} from '@material-ui/core/styles';
13 | import theme from "./theme";
14 | import PrivateRoute from "./components/PrivateRoute/PrivateRoute";
15 | import config from './config';
16 |
17 | const basePath = config.appBasePath || "/";
18 |
19 | const VideoApp = () => {
20 | const {setError, settings} = useAppState();
21 | const connectionOptions = generateConnectionOptions(settings);
22 |
23 | return (
24 |
25 |
26 | {/* setError(null)} error={error} />*/}
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | ReactDOM.render(
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | ,
53 | document.getElementById('root')
54 | );
55 |
56 | // If you want your app to work offline and load faster, you can change
57 | // unregister() to register() below. Note this comes with some pitfalls.
58 | // Learn more about service workers: https://bit.ly/CRA-PWA
59 | serviceWorker.unregister();
60 |
--------------------------------------------------------------------------------
/src/state/index.js:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext, useReducer, useState } from 'react';
2 | import { settingsReducer, initialSettings } from './settings/settingsReducer';
3 |
4 | export const StateContext = createContext(null);
5 |
6 | /*
7 | The 'react-hooks/rules-of-hooks' linting rules prevent React Hooks fron being called
8 | inside of if() statements. This is because hooks must always be called in the same order
9 | every time a component is rendered. The 'react-hooks/rules-of-hooks' rule is disabled below
10 | because the "if (process.env.REACT_APP_SET_AUTH === 'firebase')" statements are evaluated
11 | at build time (not runtime). If the statement evaluates to false, then the code is not
12 | included in the bundle that is produced (due to tree-shaking). Thus, in this instance, it
13 | is ok to call hooks inside if() statements.
14 | */
15 | export default function AppStateProvider(props) {
16 | const [error, setError] = useState(null);
17 | const [isFetching, setIsFetching] = useState(false);
18 | const [activeSinkId, setActiveSinkId] = useState('default');
19 | const [settings, dispatchSetting] = useReducer(settingsReducer, initialSettings);
20 |
21 | let contextValue = {
22 | error,
23 | setError,
24 | isFetching,
25 | activeSinkId,
26 | setActiveSinkId,
27 | settings,
28 | dispatchSetting,
29 | };
30 |
31 | contextValue = {
32 | ...contextValue,
33 | getToken: async (identity, roomName) => {
34 | const headers = new window.Headers();
35 | const endpoint = process.env.REACT_APP_TWILIO_TOKEN_ENDPOINT || '/twilio-token';
36 | const params = new window.URLSearchParams({ identity, roomName });
37 |
38 | return fetch(`${endpoint}?${params}`, { headers, mode: 'cors'}).then(res => res.text());
39 | },
40 | };
41 |
42 | const getToken = (name, room) => {
43 | setIsFetching(true);
44 | return contextValue
45 | .getToken(name, room)
46 | .then(res => {
47 | setIsFetching(false);
48 | return res;
49 | })
50 | .catch(err => {
51 | setError(err);
52 | setIsFetching(false);
53 | return Promise.reject(err);
54 | });
55 | };
56 |
57 | return {props.children};
58 | }
59 |
60 | export function useAppState() {
61 | const context = useContext(StateContext);
62 | if (!context) {
63 | throw new Error('useAppState must be used within the AppStateProvider');
64 | }
65 | return context;
66 | }
67 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yaml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: File a bug report
3 | title: "[Bug]: "
4 | labels: ["bug"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for taking the time to fill out this bug report!
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: checkboxes
19 | attributes:
20 | label: Is there an existing issue for this?
21 | description: Please search to see if an issue already exists for the bug you encountered.
22 | options:
23 | - label: I have searched the existing issues
24 | required: true
25 | - type: textarea
26 | attributes:
27 | label: Current Behavior
28 | description: A concise description of what you're experiencing.
29 | validations:
30 | required: false
31 | - type: textarea
32 | attributes:
33 | label: Expected Behavior
34 | description: A concise description of what you expected to happen.
35 | validations:
36 | required: false
37 | - type: textarea
38 | attributes:
39 | label: Steps To Reproduce
40 | description: Steps to reproduce the behavior.
41 | placeholder: |
42 | 1. In this environment...
43 | 2. With this config...
44 | 3. Run '...'
45 | 4. See error...
46 | validations:
47 | required: false
48 | - type: textarea
49 | attributes:
50 | label: Environment
51 | description: |
52 | examples:
53 | - **OS**: Ubuntu 20.04
54 | - **Node**: 13.14.0
55 | - **npm**: 7.6.3
56 | value: |
57 | - OS:
58 | - Node:
59 | - npm:
60 | render: markdown
61 | validations:
62 | required: false
63 | - type: dropdown
64 | id: browsers
65 | attributes:
66 | label: What browsers are you seeing the problem on?
67 | multiple: true
68 | options:
69 | - Firefox
70 | - Chrome
71 | - Safari
72 | - Microsoft Edge
73 | validations:
74 | required: false
75 | - type: textarea
76 | attributes:
77 | label: Anything else?
78 | description: |
79 | Links? References? Relevant log output? Anything that will give us more context about the issue you are encountering!
80 |
81 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
82 | validations:
83 | required: false
--------------------------------------------------------------------------------
/src/components/VideoProvider/useLocalTracks/useLocalTracks.js:
--------------------------------------------------------------------------------
1 | import { DEFAULT_VIDEO_CONSTRAINTS } from '../../../constants';
2 | import { useCallback, useEffect, useState } from 'react';
3 | import Video from 'twilio-video';
4 |
5 | export default function useLocalTracks() {
6 | const [audioTrack, setAudioTrack] = useState();
7 | const [videoTrack, setVideoTrack] = useState();
8 | const [isAcquiringLocalTracks, setIsAcquiringLocalTracks] = useState(false);
9 |
10 | const getLocalAudioTrack = useCallback((deviceId) => {
11 | const options= {};
12 |
13 | if (deviceId) {
14 | options.deviceId = { exact: deviceId };
15 | }
16 |
17 | return Video.createLocalAudioTrack(options).then(newTrack => {
18 | setAudioTrack(newTrack);
19 | return newTrack;
20 | });
21 | }, []);
22 |
23 | const getLocalVideoTrack = useCallback((newOptions) => {
24 | // In the DeviceSelector and FlipCameraButton components, a new video track is created,
25 | // then the old track is unpublished and the new track is published. Unpublishing the old
26 | // track and publishing the new track at the same time sometimes causes a conflict when the
27 | // track name is 'camera', so here we append a timestamp to the track name to avoid the
28 | // conflict.
29 | const options = {
30 | ...(DEFAULT_VIDEO_CONSTRAINTS),
31 | name: `camera-${Date.now()}`,
32 | ...newOptions,
33 | };
34 |
35 | return Video.createLocalVideoTrack(options).then(newTrack => {
36 | setVideoTrack(newTrack);
37 | return newTrack;
38 | });
39 | }, []);
40 |
41 | const removeLocalVideoTrack = useCallback(() => {
42 | if (videoTrack) {
43 | videoTrack.stop();
44 | setVideoTrack(undefined);
45 | }
46 | }, [videoTrack]);
47 |
48 | useEffect(() => {
49 | setIsAcquiringLocalTracks(true);
50 | Video.createLocalTracks({
51 | video: {
52 | ...(DEFAULT_VIDEO_CONSTRAINTS),
53 | name: `camera-${Date.now()}`,
54 | },
55 | audio: true,
56 | })
57 | .then(tracks => {
58 | const videoTrack = tracks.find(track => track.kind === 'video');
59 | const audioTrack = tracks.find(track => track.kind === 'audio');
60 | if (videoTrack) {
61 | setVideoTrack(videoTrack);
62 | }
63 | if (audioTrack) {
64 | setAudioTrack(audioTrack);
65 | }
66 | })
67 | .finally(() => setIsAcquiringLocalTracks(false));
68 | }, []);
69 |
70 | const localTracks = [audioTrack, videoTrack].filter(track => track !== undefined);
71 |
72 | return { localTracks, getLocalVideoTrack, getLocalAudioTrack, isAcquiringLocalTracks, removeLocalVideoTrack };
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/VideoProvider/index.js:
--------------------------------------------------------------------------------
1 | import React, { createContext } from 'react';
2 | import { SelectedParticipantProvider } from './useSelectedParticipant/useSelectedParticipant';
3 |
4 | import AttachVisibilityHandler from './AttachVisibilityHandler/AttachVisibilityHandler';
5 | import useHandleRoomDisconnectionErrors from './useHandleRoomDisconnectionErrors/useHandleRoomDisconnectionErrors';
6 | import useHandleOnDisconnect from './useHandleOnDisconnect/useHandleOnDisconnect';
7 | import useHandleTrackPublicationFailed from './useHandleTrackPublicationFailed/useHandleTrackPublicationFailed';
8 | import useLocalTracks from './useLocalTracks/useLocalTracks';
9 | import useRoom from './useRoom/useRoom';
10 |
11 | /*
12 | * The hooks used by the VideoProvider component are different than the hooks found in the 'hooks/' directory. The hooks
13 | * in the 'hooks/' directory can be used anywhere in a video application, and they can be used any number of times.
14 | * the hooks in the 'VideoProvider/' directory are intended to be used by the VideoProvider component only. Using these hooks
15 | * elsewhere in the application may cause problems as these hooks should not be used more than once in an application.
16 | */
17 |
18 | export const VideoContext = createContext(null);
19 |
20 | export function VideoProvider({ options, children, onError = () => {}, onDisconnect = () => {} }) {
21 | const onErrorCallback = (error) => {
22 | console.log(`ERROR: ${error.message}`, error);
23 | onError(error);
24 | };
25 |
26 | const {
27 | localTracks,
28 | getLocalVideoTrack,
29 | getLocalAudioTrack,
30 | isAcquiringLocalTracks,
31 | removeLocalVideoTrack,
32 | } = useLocalTracks();
33 | const { room, isConnecting, connect } = useRoom(localTracks, onErrorCallback, options);
34 |
35 | // Register onError and onDisconnect callback functions.
36 | useHandleRoomDisconnectionErrors(room, onError);
37 | useHandleTrackPublicationFailed(room, onError);
38 | useHandleOnDisconnect(room, onDisconnect);
39 |
40 | return (
41 |
55 | {children}
56 | {/*
57 | The AttachVisibilityHandler component is using the useLocalVideoToggle hook
58 | which must be used within the VideoContext Provider.
59 | */}
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useState} from 'react';
2 | import {styled} from '@material-ui/core/styles';
3 | import useHeight from './hooks/useHeight/useHeight';
4 | import {useParams} from 'react-router-dom';
5 | import './App.css';
6 | import LocalVideoPreview from "./components/LocalVideoPreview/LocalVideoPreview";
7 | import Room from "./components/Room/Room";
8 | import useRoomState from "./hooks/useRoomState/useRoomState";
9 | import {useAppState} from "./state";
10 | import useVideoContext from "./hooks/useVideoContext/useVideoContext";
11 | import MenuBar from "./components/MenuBar/MenuBar";
12 | import ClosedCaptions from "./components/ClosedCaptions/ClosedCaptions";
13 | import {SymblProvider} from "./components/SymblProvider";
14 | import Controls from "./components/Controls/Controls";
15 |
16 | const Container = styled('div')({
17 | display: 'grid',
18 | gridTemplateRows: 'auto 1fr',
19 | });
20 |
21 | const Main = styled('main')({
22 | overflow: 'hidden',
23 | });
24 |
25 | function App() {
26 | const {roomState, room} = useRoomState();
27 | const height = useHeight();
28 | let {URLRoomName, UserName} = useParams();
29 | const [roomName, setRoomName] = useState(URLRoomName);
30 | const [userName, setUserName] = useState(UserName);
31 | const {getToken} = useAppState();
32 | const {connect} = useVideoContext();
33 |
34 | const [hasStarted, setHasStarted] = useState(false);
35 | const [isStarting, setIsStarting] = useState(false);
36 |
37 | useEffect(() => {
38 | if (roomState === 'disconnected' && !hasStarted && !isStarting) {
39 | if (!(roomName && userName) && (room && room.name && room.localParticipant && room.localParticipant.identity)) {
40 | !roomName && setRoomName(room.name);
41 | !userName && setUserName(room.localParticipant.identity);
42 | }
43 | if (roomName && userName) {
44 | setIsStarting(true)
45 | getToken(userName, roomName).then(token => {
46 | connect(token)
47 | setIsStarting(false);
48 | setHasStarted(true);
49 | });
50 | }
51 | }
52 | }, [roomName, userName, room]);
53 |
54 |
55 | return (
56 |
57 |
58 |
59 | {roomState === 'disconnected' ? : (
60 |
61 |
62 |
63 |
64 |
65 | )}
66 |
67 |
68 | );
69 | }
70 |
71 | export default App;
72 |
--------------------------------------------------------------------------------
/src/components/Controls/ToogleScreenShareButton/ToggleScreenShareButton.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 ScreenShare from '@material-ui/icons/ScreenShare';
6 | import StopScreenShare from '@material-ui/icons/StopScreenShare';
7 | import Tooltip from '@material-ui/core/Tooltip';
8 |
9 | import useScreenShareToggle from '../../../hooks/useScreenShareToggle/useScreenShareToggle';
10 | import useScreenShareParticipant from '../../../hooks/useScreenShareParticipant/useScreenShareParticipant';
11 | import useVideoContext from '../../../hooks/useVideoContext/useVideoContext';
12 |
13 | export const SCREEN_SHARE_TEXT = 'Share Screen';
14 | export const STOP_SCREEN_SHARE_TEXT = 'Stop Sharing Screen';
15 | export const SHARE_IN_PROGRESS_TEXT = 'Cannot share screen when another user is sharing';
16 | export const SHARE_NOT_SUPPORTED_TEXT = 'Screen sharing is not supported with this browser';
17 |
18 | const useStyles = makeStyles((theme) =>
19 | createStyles({
20 | fab: {
21 | margin: theme.spacing(1),
22 | '&[disabled]': {
23 | color: 'rgba(225, 225, 225, 0.8)',
24 | backgroundColor: 'rgba(175, 175, 175, 0.6);',
25 | },
26 | },
27 | })
28 | );
29 |
30 | export default function ToggleScreenShareButton(props) {
31 | const classes = useStyles();
32 | const [isScreenShared, toggleScreenShare] = useScreenShareToggle();
33 | const screenShareParticipant = useScreenShareParticipant();
34 | const { room } = useVideoContext();
35 | const disableScreenShareButton = screenShareParticipant && screenShareParticipant !== room.localParticipant;
36 | const isScreenShareSupported = navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia;
37 | const isDisabled = props.disabled || disableScreenShareButton || !isScreenShareSupported;
38 |
39 | let tooltipMessage = SCREEN_SHARE_TEXT;
40 |
41 | if (isScreenShared) {
42 | tooltipMessage = STOP_SCREEN_SHARE_TEXT;
43 | }
44 |
45 | if (disableScreenShareButton) {
46 | tooltipMessage = SHARE_IN_PROGRESS_TEXT;
47 | }
48 |
49 | if (!isScreenShareSupported) {
50 | tooltipMessage = SHARE_NOT_SUPPORTED_TEXT;
51 | }
52 |
53 | return (
54 |
60 |
61 | {/* The div element is needed because a disabled button will not emit hover events and we want to display
62 | a tooltip when screen sharing is disabled */}
63 |
64 | {isScreenShared ? : }
65 |
66 |