diff --git a/.gitignore b/.gitignore index 92b3fa43..bf415adc 100644 --- a/.gitignore +++ b/.gitignore @@ -44,18 +44,11 @@ jspm_packages # Misc .DS_Store +package-lock.json # The generated index.js -quickstart/public/index.js -examples/bandwidthconstraints/public/index.js -examples/codecpreferences/public/index.js -examples/localvideofilter/public/index.js -examples/localvideosnapshot/public/index.js -examples/mediadevices/public/index.js +**/public/index.js # The generated helpers.js -examples/bandwidthconstraints/public/helpers.js -examples/codecpreferences/public/helpers.js -examples/localvideofilter/public/helpers.js -examples/localvideosnapshot/public/helpers.js -examples/mediadevices/public/helpers.js +**/public/helpers.js +**/public/helpers*.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ea0e841c..eaad623b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ + # 1.0.0 ======= - +* Updated Media Device Selection app to Connect to Room. +* Added an example app to demonstrate Network quality API. +* Added an example app to demonstrate Room State Changes. +* Added an example app to demonstrate Dominant Speaker API. +* Updated twilio-video to 2.0.0. * Added an example app to demonstrate Codec Preferences API. * Added an example app to demonstrate Bandwidth Constraints API. * Added an example app to demonstrate Local Video Filter. diff --git a/README.md b/README.md index 8ffbdf36..3da5213d 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,34 @@ # Twilio Video Quickstart for JavaScript -[![OS X/Linus Build Status](https://secure.travis-ci.org/twilio/video-quickstart-js.png?branch=master)](http://travis-ci.org/twilio/video-quickstart-js) [![Windows Build status](https://ci.appveyor.com/api/projects/status/3u69uy9c0lsap3dr?svg=true -)](https://ci.appveyor.com/project/markandrus/video-quickstart-js) +[![OS X/Linus Build Status](https://secure.travis-ci.org/twilio/video-quickstart-js.png?branch=master)](http://travis-ci.org/twilio/video-quickstart-js) [![Windows Build status](https://ci.appveyor.com/api/projects/status/3u69uy9c0lsap3dr?svg=true)](https://ci.appveyor.com/project/markandrus/video-quickstart-js) + +_For Twilio Video 1.x Quickstart, go [here](https://github.com/twilio/video-quickstart-js/tree/1.x)._ + +## Overview This application should give you a ready-made starting point for writing your -own video apps with Twilio Video. Before we begin, we need to collect -all the config values we need to run the application: +own video apps with Twilio Video. + +![screenshot of chat app](quickstart/public/quickstart.png) + +## Setup Requirements -* Account SID: Your primary Twilio account identifier - find this [in the console here](https://www.twilio.com/console). -* API Key: Used to authenticate - [generate one here](https://www.twilio.com/console/runtime/api-keys). -* API Secret: Used to authenticate - [just like the above, you'll get one here](https://www.twilio.com/console/runtime/api-keys). +Before we begin, we need to collect all the config values we need to run the application: -## A Note on API Keys +- Account SID: Your primary Twilio account identifier - find this [in the console here](https://www.twilio.com/console). +- API Key SID: Used to authenticate - [generate one here](https://www.twilio.com/console/runtime/api-keys). +- API Key Secret: Used to authenticate - [just like the above, you'll get one here](https://www.twilio.com/console/runtime/api-keys). -When you generate an API key pair at the URLs above, your API Secret will only -be shown once - make sure to save this in a secure location, +### A Note on API Keys + +When you generate an API key pair at the URLs above, your API Key Secret will only +be shown once - make sure to save this in a secure location, or possibly your `~/.bash_profile`. ## Setting Up The Application Create a configuration file for your application: + ```bash cp .env.template .env ``` @@ -27,21 +36,43 @@ cp .env.template .env Edit `.env` with the configuration parameters we gathered from above. Next, we need to install our dependencies from npm: + ```bash npm install ``` +## Running The Application + Now we should be all set! Run the application: + ```bash npm start ``` -Your application should now be running at [http://localhost:3000](http://localhost:3000). Just enter -the name of the room you want to join and click on 'Join Room'. Then, -open another tab and join the same room. Now, you should see your own -video in both the tabs! +Your application should now be running at [http://localhost:3000](http://localhost:3000). You will +be prompted to test and choose your microphone and camera. On desktop browsers, your choices will +be saved. _On mobile browsers, you will be asked to test and choose your microphone and camera every +time you load the application in order to make sure they are not reserved by another application_. + +After choosing your input devices, you will be prompted to enter your Room name and user name, following +which you will join the Room. Now, all you have to do is open another tab and join the same Room in order +to see and hear yourself on both tabs! + +[joinroom.js](quickstart/src/joinroom.js) demonstrates how to use the SDK APIs to build a multi-party +video sesssion. You can start building your own application by incorporating this code into your own +application, and build your user interface around it. + +## Running On Multiple Devices + +You can use [ngrok](https://ngrok.com/) to try your application +on different devices by creating a secure tunnel to your application server: + +```bash +ngrok http 3000 +``` -![screenshot of chat app](https://s3.amazonaws.com/com.twilio.prod.twilio-docs/images/video2.original.png) +You will get a URL of the form `https://a1b2c3d4.ngrok.io` which can be loaded on a browser from a device +different than the one where your application server is running. ## Examples diff --git a/examples/autorenderhint/public/index.css b/examples/autorenderhint/public/index.css new file mode 100644 index 00000000..e021fc00 --- /dev/null +++ b/examples/autorenderhint/public/index.css @@ -0,0 +1,124 @@ +@import url('https://fonts.googleapis.com/css?family=Roboto+Mono:300'); + +html { + height: 100%; +} + +body { + height: 100%; +} + +[data-toggle="collapse"].collapsed .if-not-collapsed { + display: none; +} + +[data-toggle="collapse"]:not(.collapsed) .if-collapsed { + display: none; +} + +.align { + align-content: flex-start; +} + +div.container-fluid { + height: 100%; +} + +div.row { + height: 100%; +} + +div.row.thin-gutters { + margin: 0 2px 0 2px; +} + +div.row.thin-gutters > .col, +div.row.thin-gutters > [class*="col-"] { + padding: 8px 8px; +} + +div.card { + border: none; + overflow-y: auto; +} + +div.card-block { + margin: 5px; +} + +div.col-sm-6, div.col-sm-6 { + max-height: fit-content; +} + + +pre.language-javascript { + font-family: 'Roboto Mono', monospace; + font-size: 13px; +} + +pre.language-javascript a { + color: aquamarine; + text-decoration: underline; +} + +pre.language-javascript a:hover { + text-decoration: none; +} + +div#media-container { + position: relative; + display: grid; + grid-template-areas: 'content'; + max-width: 100%; +} + +span#trackIsSwitchedOff { + position: absolute; + z-index: 3; + width: 67px; + font-size: .9em; + right: 1%; + top: 1%; + align-items: center; + max-width: max-content; +} + +video { + width: 100% !important; + height: auto !important; + border: none; +} + +div#bg-img { + width: 100% !important; + height: auto !important; + background-color: lightgrey !important; + background-image: url('https://static0.twilio.com/marketing/bundles/archetype/img/logo-wordmark.svg'); + background-position: 50%; + background-repeat: no-repeat; + box-sizing: content-box; +} + +div.bitrategraph { + margin-top: 5px; + text-align: center; +} + +@media (max-width: 900px) { + div.col-sm-8, div.col-sm-4 { + max-width: 100%; + flex: 100%; + } + + div.col-sm-8 { + height: 40%; + } + + div.col-sm-4 { + height: 60%; + } + + pre.language-javascript { + font-size: 12px; + } +} diff --git a/examples/autorenderhint/public/index.html b/examples/autorenderhint/public/index.html new file mode 100644 index 00000000..10e7041a --- /dev/null +++ b/examples/autorenderhint/public/index.html @@ -0,0 +1,73 @@ + + + + + + Video Track Automatic Controls + + + + + + +
+
+
+
+
+

+ Video Track Automatic Controls +

+ +
+

+            
+
+
+
+
+
+
+

Remote Video Controls

+
+
+ Toggle Visibility: + + +
+
+ Video Size: + +
+
+
+

Remote Video Track

+
+ + + +
+
+

Video Bitrate

+
+ +
+
+
+
+
+
+ + + + + + diff --git a/examples/autorenderhint/public/prism.css b/examples/autorenderhint/public/prism.css new file mode 100644 index 00000000..8d846b2b --- /dev/null +++ b/examples/autorenderhint/public/prism.css @@ -0,0 +1,123 @@ +/* http://prismjs.com/download.html?themes=prism-okaidia&languages=markup+css+clike+javascript */ +/** + * okaidia theme for JavaScript, CSS and HTML + * Loosely based on Monokai textmate theme by http://www.monokai.nl/ + * @author ocodia + */ + + code[class*="language-"], + pre[class*="language-"] { + color: #f8f8f2; + background: none; + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; + } + + /* Code blocks */ + pre[class*="language-"] { + padding: 1em; + margin: .5em 0; + overflow: auto; + border-radius: 0.3em; + } + + :not(pre) > code[class*="language-"], + pre[class*="language-"] { + background: #272822; + } + + /* Inline code */ + :not(pre) > code[class*="language-"] { + padding: .1em; + border-radius: .3em; + white-space: normal; + } + + .token.comment, + .token.prolog, + .token.doctype, + .token.cdata { + color: #87ceeb; + } + + .token.operator, + .token.punctuation { + color: #ff5555; + } + + .namespace { + opacity: .7; + } + + .token.property, + .token.tag, + .token.constant, + .token.symbol, + .token.deleted { + color: #f92672; + } + + .token.boolean { + color: #55ff55; + } + + .token.number { + color: #cd5c5c; + } + + .token.selector, + .token.attr-name, + .token.string, + .token.char, + .token.builtin, + .token.inserted { + color: #ff55ff; + } + + .token.entity, + .token.url, + .language-css .token.string, + .style .token.string, + .token.variable { + color: #ff55ff; + } + + .token.function { + color: #ccc; + } + + .token.keyword { + color: #55ff55; + } + + .token.regex, + .token.important { + color: #fd971f; + } + + .token.important, + .token.bold { + font-weight: bold; + } + .token.italic { + font-style: italic; + } + + .token.entity { + cursor: help; + } diff --git a/examples/autorenderhint/src/helpers.js b/examples/autorenderhint/src/helpers.js new file mode 100644 index 00000000..3599a515 --- /dev/null +++ b/examples/autorenderhint/src/helpers.js @@ -0,0 +1,22 @@ +'use strict'; + +var Video = require('twilio-video'); + +/** + * Connect to a Room with 'auto' mode. This is the default mode. + * @param {string} token - AccessToken for joining the Room + * @returns {Room} + */ +function joinRoom(token) { + return Video.connect(token, { + name: 'my-cool-room', + bandwidthProfile: { + video: { + contentPreferencesMode: 'auto', + clientTrackSwitchOffControl: 'auto' + } + } + }); +} + +module.exports.joinRoom = joinRoom; diff --git a/examples/autorenderhint/src/index.js b/examples/autorenderhint/src/index.js new file mode 100644 index 00000000..bb4d517e --- /dev/null +++ b/examples/autorenderhint/src/index.js @@ -0,0 +1,122 @@ +'use strict' + +const Prism = require('prismjs'); +const Video = require('twilio-video'); +const getSnippet = require('../../util/getsnippet'); +const getRoomCredentials = require('../../util/getroomcredentials'); +const setupBitrateGraph = require('../../util/setupbitrategraph'); +const helpers = require('./helpers'); +const joinRoom = helpers.joinRoom; + +const bgImg = document.querySelector('div#bg-img'); +const mediaContainer = document.querySelector('div#media-container'); +const renderDimensionsOption = document.querySelector('select#renderDimensionsOption'); +const videoEl = document.querySelector('video#remotevideo'); +const showVideo = document.querySelector('button#showVideo'); +const hideVideo = document.querySelector('button#hideVideo'); +const trackIsSwitchedOffIndicator = document.querySelector('span#trackIsSwitchedOff'); +let roomP1 = null; +let stopVideoBitrateGraph = null; + +const handleIsSwitchedOff = (isTrackSwitchedOff) => { + if(isTrackSwitchedOff) { + trackIsSwitchedOffIndicator.textContent = 'Off'; + trackIsSwitchedOffIndicator.classList.remove('badge-success'); + trackIsSwitchedOffIndicator.classList.add('badge-danger'); + } else { + trackIsSwitchedOffIndicator.textContent = 'On'; + trackIsSwitchedOffIndicator.classList.remove('badge-danger'); + trackIsSwitchedOffIndicator.classList.add('badge-success'); + } +} + +(async function(){ + // Load the code snippet. + const snippet = await getSnippet('./helpers.js'); + const pre = document.querySelector('pre.language-javascript'); + + pre.innerHTML = Prism.highlight(snippet, Prism.languages.javascript); + + const logger = Video.Logger.getLogger('twilio-video'); + logger.setLevel('silent'); + + // Get the credentials to connect to the Room. + const credsP1 = await getRoomCredentials(); + const credsP2 = await getRoomCredentials(); + + // Create room instance and name for participants to join. + roomP1 = await joinRoom(credsP1.token); + + // Create the video track for the Remote Participant. + const videoTrack = await Video.createLocalVideoTrack(); + + // Connecting remote participant. + const roomP2 = await Video.connect(credsP2.token, { + name: 'my-cool-room', + bandwidthProfile: { + video: { + contentPreferencesMode: 'auto', + clientTrackSwitchOffControl: 'auto' + } + }, + tracks: [ videoTrack ] + }); + + // Set video bitrate graph. + let startVideoBitrateGraph = setupBitrateGraph('video', 'videobitrategraph', 'videobitratecanvas'); + + // Attach RemoteVideoTrack + roomP1.on('trackSubscribed', track => { + if(track.kind === 'video') { + track.attach(videoEl); + handleIsSwitchedOff(track.isSwitchedOff); + stopVideoBitrateGraph = startVideoBitrateGraph(roomP1, 1000); + + showVideo.classList.remove('disabled'); + hideVideo.classList.remove('disabled'); + renderDimensionsOption.classList.remove('disabled'); + + track.on('switchedOff', track => { + handleIsSwitchedOff(track.isSwitchedOff); + }); + track.on('switchedOn', track => { + handleIsSwitchedOff(track.isSwitchedOff); + }); + } + }); + + // Show RemoteVideoTrack + showVideo.onclick = event => { + videoEl.hidden = false; + bgImg.hidden = true; + } + + // Hide RemoteVideoTrack + hideVideo.onclick = event => { + videoEl.hidden = true; + bgImg.hidden = false; + } + + const renderDimensionsObj = { + qHD: { width: 960, height: 540 }, + VGA: { width: 640, height: 480 }, + QCIF: { width: 176, height: 144} + } + + // Adjust Remote Video element size. + renderDimensionsOption.addEventListener('change', () => { + const renderDimensions = renderDimensionsObj[renderDimensionsOption.value]; + mediaContainer.style.height = `${renderDimensions.height}px`; + mediaContainer.style.width = `${renderDimensions.width}px`; + }); + + // Disconnect from the Room + window.onbeforeunload = () => { + if (stopVideoBitrateGraph) { + stopVideoBitrateGraph(); + stopVideoBitrateGraph = null; + } + roomP1.disconnect(); + roomP2.disconnect(); + } +}()); diff --git a/examples/bandwidthconstraints/public/index.css b/examples/bandwidthconstraints/public/index.css index 85420721..bc2addd4 100644 --- a/examples/bandwidthconstraints/public/index.css +++ b/examples/bandwidthconstraints/public/index.css @@ -8,6 +8,18 @@ body { height: 100%; } +[data-toggle="collapse"].collapsed .if-not-collapsed { + display: none; +} + +[data-toggle="collapse"]:not(.collapsed) .if-collapsed { + display: none; +} + +.align { + align-content: flex-start; +} + div.container-fluid { height: 100%; } @@ -22,11 +34,11 @@ div.row.thin-gutters { div.row.thin-gutters > .col, div.row.thin-gutters > [class*="col-"] { - padding: 0 2px; + padding: 8px 8px; } -div.col-sm-8, div.col-sm-4 { - height: 100%; +div.col-sm-6, div.col-sm-6 { + height: fit-content; } pre.language-javascript { @@ -52,22 +64,8 @@ div.input-group > select { width: 100%; } -div.col-sm-8 > .card { - height: 100%; -} - -div.col-sm-4 > .card:first-child { - height: 30%; -} - -div.col-sm-4 > .card:last-child { - height: 70%; -} - div#audiowaveform { position: absolute; - left: 20px; - top: 58px; width: 20%; height: 20%; background-color: darkgrey !important; diff --git a/examples/bandwidthconstraints/public/index.html b/examples/bandwidthconstraints/public/index.html index a51701d8..d5a6ad95 100644 --- a/examples/bandwidthconstraints/public/index.html +++ b/examples/bandwidthconstraints/public/index.html @@ -4,30 +4,36 @@ Bandwidth Constraints - +
-
-
+
+

Bandwidth Constraints

-

+            
+            
+

+            
-
+

Participant's Media

- +
@@ -67,5 +73,8 @@

Select Bandwidth Constraints

+ + + diff --git a/examples/bandwidthconstraints/src/index.js b/examples/bandwidthconstraints/src/index.js index d679fd2d..b35ca557 100644 --- a/examples/bandwidthconstraints/src/index.js +++ b/examples/bandwidthconstraints/src/index.js @@ -1,12 +1,11 @@ 'use strict'; -const DataSeries = require('../../util/timelinegraph').DataSeries; -const GraphView = require('../../util/timelinegraph').GraphView; const Prism = require('prismjs'); const Video = require('twilio-video'); const getRoomCredentials = require('../../util/getroomcredentials'); const getSnippet = require('../../util/getsnippet'); const helpers = require('./helpers'); +const setupBitrateGraph = require('../../util/setupbitrategraph'); const waveform = require('../../util/waveform'); const connectWithBandwidthConstraints = helpers.connectWithBandwidthConstraints; const updateBandwidthConstraints = helpers.updateBandwidthConstraints; @@ -54,6 +53,7 @@ function attachTrack(audioElement, videoElement, starAudioBitrateGraph, startVid */ function detachAudioTrack(track, audioElement) { track.detach(audioElement); + audioElement.srcObject = null; waveform.unsetStream(); const canvas = waveformContainer.querySelector('canvas'); if (canvas) { @@ -71,6 +71,7 @@ function detachTrack(audioElement, videoElement, track) { return; } track.detach(videoElement); + videoElement.srcObject = null; stopVideoBitrateGraph(); } @@ -114,42 +115,14 @@ function disconnectFromRoom() { } /** - * Set up the bitrate graph for audio or video media. + * Get the Tracks of the given Participant. */ -function setupBitrateGraph(kind, containerId, canvasId) { - const bitrateSeries = new DataSeries(); - const bitrateGraph = new GraphView(containerId, canvasId); - - bitrateGraph.graphDiv_.style.display = 'none'; - return function startBitrateGraph(room, intervalMs) { - let bytesReceivedPrev = 0; - let timestampPrev = Date.now(); - const interval = setInterval(async function() { - if (!room) { - clearInterval(interval); - return; - } - const stats = await room.getStats(); - const remoteTrackStats = kind === 'audio' - ? stats[0].remoteAudioTrackStats[0] - : stats[0].remoteVideoTrackStats[0] - const bytesReceived = remoteTrackStats.bytesReceived; - const timestamp = remoteTrackStats.timestamp; - const bitrate = Math.round((bytesReceivedPrev - bytesReceived) * 8 / (timestampPrev - timestamp)); - - bitrateSeries.addPoint(timestamp, bitrate); - bitrateGraph.setDataSeries([bitrateSeries]); - bitrateGraph.updateEndDate(); - bytesReceivedPrev = bytesReceived; - timestampPrev = timestamp; - }, intervalMs); - - bitrateGraph.graphDiv_.style.display = ''; - return function stop() { - clearInterval(interval); - bitrateGraph.graphDiv_.style.display = 'none'; - }; - }; +function getTracks(participant) { + return Array.from(participant.tracks.values()).filter(function(publication) { + return publication.track; + }).map(function(publication) { + return publication.track; + }); } /** @@ -206,23 +179,23 @@ function updateBandwidthParametersInRoom() { // media should join. roomName = someRoom.name; - // Attach the newly added Track to the DOM and start the bitrate graph. - someRoom.on('trackAdded', attachTrack.bind( + // Attach the newly subscribed Track to the DOM and start the bitrate graph. + someRoom.on('trackSubscribed', attachTrack.bind( null, audioPreview, videoPreview, startAudioBitrateGraph.bind(null, someRoom), startVideoBitrateGraph.bind(null, someRoom))); - // Detach the removed Track from the DOM and stop the bitrate graph. - someRoom.on('trackRemoved', detachTrack.bind( + // Detach the unsubscribed Track from the DOM and stop the bitrate graph. + someRoom.on('trackUnsubscribed', detachTrack.bind( null, audioPreview, videoPreview)); // Detach Participant's Tracks and stop the bitrate graphs upon disconnect. someRoom.on('participantDisconnected', function(participant) { - participant.tracks.forEach(detachTrack.bind( + getTracks(participant).forEach(detachTrack.bind( null, audioPreview, videoPreview)); diff --git a/examples/codecpreferences/public/index.css b/examples/codecpreferences/public/index.css index 52429f9c..4ff57822 100644 --- a/examples/codecpreferences/public/index.css +++ b/examples/codecpreferences/public/index.css @@ -8,6 +8,18 @@ body { height: 100%; } +[data-toggle="collapse"].collapsed .if-not-collapsed { + display: none; +} + +[data-toggle="collapse"]:not(.collapsed) .if-collapsed { + display: none; +} + +.align { + align-content: flex-start; +} + div.container-fluid { height: 100%; } @@ -22,11 +34,11 @@ div.row.thin-gutters { div.row.thin-gutters > .col, div.row.thin-gutters > [class*="col-"] { - padding: 0 2px; + padding: 8px 8px; } -div.col-sm-8, div.col-sm-4 { - height: 100%; +div.col-sm-6, div.col-sm-6 { + max-height: fit-content; } pre.language-javascript { @@ -52,18 +64,6 @@ div.input-group > select { width: 100%; } -div.col-sm-8 > .card { - height: 100%; -} - -div.col-sm-4 > .card:first-child { - height: 30%; -} - -div.col-sm-4 > .card:last-child { - height: 70%; -} - div#audiowaveform { position: absolute; left: 20px; diff --git a/examples/codecpreferences/public/index.html b/examples/codecpreferences/public/index.html index 217e4b3d..3871c952 100644 --- a/examples/codecpreferences/public/index.html +++ b/examples/codecpreferences/public/index.html @@ -3,31 +3,37 @@ - Bandwidth Constraints - + Codec Preferences +
-
-
+
+

Codec Preferences

-

+            
+            
+

+            
-
+

Participant's Media

- +
@@ -69,5 +75,8 @@

Select Codec Preferences

+ + + diff --git a/examples/codecpreferences/src/index.js b/examples/codecpreferences/src/index.js index e27e1122..d31ba2a5 100644 --- a/examples/codecpreferences/src/index.js +++ b/examples/codecpreferences/src/index.js @@ -48,6 +48,7 @@ function attachTrack(audioElement, videoElement, showAppliedCodec, track) { */ function detachAudioTrack(track, audioElement) { track.detach(audioElement); + audioElement.srcObject = null; waveform.unsetStream(); const canvas = waveformContainer.querySelector('canvas'); if (canvas) { @@ -65,6 +66,7 @@ function detachTrack(audioElement, videoElement, track) { return; } track.detach(videoElement); + videoElement.srcObject = null; } /** @@ -106,6 +108,17 @@ function disconnectFromRoom() { return; } +/** + * Get the Tracks of the given Participant. + */ +function getTracks(participant) { + return Array.from(participant.tracks.values()).filter(function(publication) { + return publication.track; + }).map(function(publication) { + return publication.track; + }); +} + /** * Hide the codec used to encode the media of a particular kind in a Room. */ @@ -174,22 +187,22 @@ function wait(ms) { // media should join. roomName = someRoom.name; - // Attach the newly added Track to the DOM. - someRoom.on('trackAdded', attachTrack.bind( + // Attach the newly subscribed Track to the DOM. + someRoom.on('trackSubscribed', attachTrack.bind( null, audioPreview, videoPreview, showAppliedCodec.bind(null, someRoom))); - // Detach the removed Track from the DOM. - someRoom.on('trackRemoved', detachTrack.bind( + // Detach the unsubscribed Track from the DOM. + someRoom.on('trackUnsubscribed', detachTrack.bind( null, audioPreview, videoPreview)); // Detach Participant's Tracks upon disconnect. someRoom.on('participantDisconnected', function(participant) { - participant.tracks.forEach(detachTrack.bind( + getTracks(participant).forEach(detachTrack.bind( null, audioPreview, videoPreview)); diff --git a/examples/datatracks/public/index.css b/examples/datatracks/public/index.css new file mode 100644 index 00000000..c1f8ed3b --- /dev/null +++ b/examples/datatracks/public/index.css @@ -0,0 +1,147 @@ +@import url('https://fonts.googleapis.com/css?family=Roboto+Mono:300'); + +html { + height: 100%; + scroll-behavior: smooth; +} + +body { + height: 100%; +} + +[data-toggle='collapse'].collapsed .if-not-collapsed { + display: none; +} + +[data-toggle='collapse']:not(.collapsed) .if-collapsed { + display: none; +} + +.card { + border: none; + max-height: fit-content; + overflow-y: auto; +} + +.btn:focus, +.btn:active { + outline: none !important; + box-shadow: none !important; +} + +.align { + align-content: flex-start; +} + +.card-body { + width: 640px; + max-width: fit-content; +} + +.card-title { + margin-bottom: 0px; +} + +.chat { + width: 100%; + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} + +div#p1-chat-log, +div#p2-chat-log { + border: 1px solid rgb(59, 59, 59); + border-radius: 5px 5px 5px 5px; + height: 120px; + overflow-y: scroll; + overflow-x: hidden; + width: 100%; + margin-top: 10px; + margin-bottom: 10px; +} + +p { + margin: unset; +} + +.P1 { + background-color: grey; +} + +.P2 { + background-color: lightgrey; +} + +#p1-usermsg, +#p2-usermsg { + width: 100%; +} + +.btn { + display: block; + margin-top: 10px; + margin-bottom: 10px; +} + +button#P1-msg-submit, +button#P2-msg-submit { + width: 100%; +} + +.col-sm-12 { + padding-left: 0%; + padding-right: 0%; +} + +form { + display: flex; + flex-wrap: wrap; +} + +div.container-fluid { + height: 100%; +} + +div.row { + height: 100%; +} + +div.row.thin-gutters { + margin: 0 2px 0 2px; +} + +div.row.thin-gutters > .col, +div.row.thin-gutters > [class*='col-'] { + padding: 8px 8px; +} + +div.col-sm-6, +div.col-sm-6 { + max-height: fit-content; +} + +pre.language-javascript { + font-family: 'Roboto Mono', monospace; + font-size: 13px; +} + +pre.language-javascript a { + color: aquamarine; + text-decoration: underline; +} + +pre.language-javascript a:hover { + text-decoration: none; +} + +#chat-log { + width: 25em; + height: 15em; + min-height: 100%; + max-height: 100%; + margin-top: 3.125em; + text-align: left; + padding: 1.5em; + overflow-y: scroll; +} diff --git a/examples/datatracks/public/index.html b/examples/datatracks/public/index.html new file mode 100644 index 00000000..3813bf41 --- /dev/null +++ b/examples/datatracks/public/index.html @@ -0,0 +1,70 @@ + + + + + + Data Tracks + + + + + +
+
+
+
+
+

+ Data Tracks +

+ +
+

+            
+
+
+
+
+
+
+
+
+

P1

+ +
+ +
+
+ + +
+
+
+
+
+
+
+

P2

+ +
+ +
+
+ + +
+
+
+
+
+
+
+ + + + + + diff --git a/examples/datatracks/public/prism.css b/examples/datatracks/public/prism.css new file mode 100644 index 00000000..8d846b2b --- /dev/null +++ b/examples/datatracks/public/prism.css @@ -0,0 +1,123 @@ +/* http://prismjs.com/download.html?themes=prism-okaidia&languages=markup+css+clike+javascript */ +/** + * okaidia theme for JavaScript, CSS and HTML + * Loosely based on Monokai textmate theme by http://www.monokai.nl/ + * @author ocodia + */ + + code[class*="language-"], + pre[class*="language-"] { + color: #f8f8f2; + background: none; + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; + } + + /* Code blocks */ + pre[class*="language-"] { + padding: 1em; + margin: .5em 0; + overflow: auto; + border-radius: 0.3em; + } + + :not(pre) > code[class*="language-"], + pre[class*="language-"] { + background: #272822; + } + + /* Inline code */ + :not(pre) > code[class*="language-"] { + padding: .1em; + border-radius: .3em; + white-space: normal; + } + + .token.comment, + .token.prolog, + .token.doctype, + .token.cdata { + color: #87ceeb; + } + + .token.operator, + .token.punctuation { + color: #ff5555; + } + + .namespace { + opacity: .7; + } + + .token.property, + .token.tag, + .token.constant, + .token.symbol, + .token.deleted { + color: #f92672; + } + + .token.boolean { + color: #55ff55; + } + + .token.number { + color: #cd5c5c; + } + + .token.selector, + .token.attr-name, + .token.string, + .token.char, + .token.builtin, + .token.inserted { + color: #ff55ff; + } + + .token.entity, + .token.url, + .language-css .token.string, + .style .token.string, + .token.variable { + color: #ff55ff; + } + + .token.function { + color: #ccc; + } + + .token.keyword { + color: #55ff55; + } + + .token.regex, + .token.important { + color: #fd971f; + } + + .token.important, + .token.bold { + font-weight: bold; + } + .token.italic { + font-style: italic; + } + + .token.entity { + cursor: help; + } diff --git a/examples/datatracks/src/helpers.js b/examples/datatracks/src/helpers.js new file mode 100644 index 00000000..e65adfd2 --- /dev/null +++ b/examples/datatracks/src/helpers.js @@ -0,0 +1,59 @@ +'use strict'; + +var Video = require('twilio-video'); + +/** + * Connect to the given Room with a LocalDataTrack. + * @param {string} token - AccessToken for joining the Room + * @returns {CancelablePromise} + */ +async function connectToRoomWithDataTrack(token, roomName) { + const localDataTrack = new Video.LocalDataTrack({ + name: 'chat', + }); + + const room = await Video.connect(token, { + name: roomName, + tracks: [localDataTrack], + }); + + return room; +} + +/** + * Send a chat message using the given LocalDataTrack. + * @param {LocalDataTrack} dataTrack - The {@link LocalDataTrack} to send a message on + * @param {string} message - The message to be sent + */ +function sendChatMessage(dataTrack, message) { + dataTrack.send(message); +} + +/** + * Receive chat messages from RemoteParticipants in the given Room. + * @param {Room} room - The Room you are currently in + * @param {Function} onMessageReceived - Updates UI when a message is received + */ +function receiveChatMessages(room, onMessageReceived) { + room.participants.forEach(function(participant) { + participant.dataTracks.forEach(function(publication) { + if (publication.isSubscribed && publication.trackName === 'chat') { + publication.track.on('message', function(msg) { + onMessageReceived(msg, participant); + }); + } + }); + }); + + room.on('trackSubscribed', function(track, publication, participant) { + if (track.kind === 'data' && track.name === 'chat') { + track.on('message', function(msg) { + onMessageReceived(msg, participant); + }); + } + }); +} + +exports.connectToRoomWithDataTrack = connectToRoomWithDataTrack; +exports.sendChatMessage = sendChatMessage; +exports.receiveChatMessages = receiveChatMessages; diff --git a/examples/datatracks/src/index.js b/examples/datatracks/src/index.js new file mode 100644 index 00000000..fb3b9fea --- /dev/null +++ b/examples/datatracks/src/index.js @@ -0,0 +1,221 @@ +'use strict'; + +const Prism = require('prismjs'); +const Video = require('twilio-video'); +const getSnippet = require('../../util/getsnippet'); +const getRoomCredentials = require('../../util/getroomcredentials'); + +const { + sendChatMessage, + receiveChatMessages, + connectToRoomWithDataTrack, +} = require('./helpers'); + +const P1Connect = document.querySelector('input#p1-connectordisconnect'); +const P2Connect = document.querySelector('input#p2-connectordisconnect'); +const p1ChatLog = document.getElementById('p1-chat-log'); +const p2ChatLog = document.getElementById('p2-chat-log'); +const p1MsgText = document.getElementById('p1-usermsg'); +const p2MsgText = document.getElementById('p2-usermsg'); +const p1Form = document.getElementById('p1-form'); +const p2Form = document.getElementById('p2-form'); +const P1Submit = document.getElementById('P1-msg-submit'); +const P2Submit = document.getElementById('P2-msg-submit'); + +let roomName = 'Chat1'; +let roomP1 = null; +let roomP2 = null; + +/* + * Connect to or disconnect the Participant with media from the Room. + */ +async function connectToOrDisconnectFromRoom(event, id, room, roomName) { + event.preventDefault(); + return room + ? disconnectFromRoom(id, room) + : await connectToRoom(id, roomName); +} + +/** + * Connect the Participant with localVideoDiv to the Room. + */ +async function connectToRoom(id, roomName) { + const creds = await getRoomCredentials(); + const room = await connectToRoomWithDataTrack(creds.token, roomName); + id.value = 'Disconnect from Room'; + return room; +} + +/** + * Disconnect the Participant with media from the Room. + */ +function disconnectFromRoom(id, room) { + room.disconnect(); + id.value = 'Connect to Room'; + room = null; + return room; +} + +/** + * Creates messages for the chat log + */ +function createMessages(fromName, message) { + const pElement = document.createElement('p'); + pElement.className = 'text'; + pElement.classList.add(`${fromName}`); + pElement.innerText = `${fromName}: ${message}`; + return pElement; +} + +(async function() { + // Load the code snippet. + const snippet = await getSnippet('./helpers.js'); + const pre = document.querySelector('pre.language-javascript'); + + pre.innerHTML = Prism.highlight(snippet, Prism.languages.javascript); + + // Disabling Submit buttons until after a Participant connects to a room with published data tracks + P1Submit.disabled = true; + P2Submit.disabled = true; + + let P1localDataTrack = null; + + // P1 Submit Click Handler + function P1SubmitHandler(event) { + event.preventDefault(); + const msg = p1MsgText.value; + p1Form.reset(); + p1ChatLog.appendChild(createMessages('P1', msg)); + sendChatMessage(P1localDataTrack, msg); + p1ChatLog.scrollTop = p1ChatLog.scrollHeight; + } + + // P1 sends a text message over the Data Track + P1Submit.addEventListener('click', P1SubmitHandler); + + // Connect P1 + P1Connect.addEventListener('click', async event => { + event.preventDefault(); + + // Appends text to DOM + function appendText(text) { + p1ChatLog.appendChild(createMessages('P2', text)); + p1ChatLog.scrollTop = p1ChatLog.scrollHeight; + } + + // Connect P1 to Room + let room = await connectToOrDisconnectFromRoom( + event, + P1Connect, + roomP1, + roomName + ); + + if (room) { + roomP1 = room; + } else { + roomP1 = null; + P1localDataTrack = null; + P1Submit.disabled = true; + } + + if (roomP1) { + // Once the Data Track has been published, set the P1localDataTrack for use + roomP1.localParticipant.on('trackPublished', publication => { + if (publication.track.kind === 'data') { + P1localDataTrack = publication.track; + P1Submit.disabled = false; + } + }); + + // P1 to announce connected RemoteParticipants + roomP1.on('participantConnected', participant => { + appendText('has connected'); + }); + + // P1 Subscribe to tracks published by remoteParticipants and append them + receiveChatMessages(roomP1, appendText); + + // P1 to announce disconnected RemoteParticipants. + roomP1.on('participantDisconnected', participant => { + appendText('has disconnected'); + }); + } + }); + + let P2localDataTrack = null; + + // P2 Submit Click Handler + function P2SubmitHandler(event) { + event.preventDefault(); + const msg = p2MsgText.value; + p2Form.reset(); + p2ChatLog.appendChild(createMessages('P2', msg)); + sendChatMessage(P2localDataTrack, msg); + p2ChatLog.scrollTop = p2ChatLog.scrollHeight; + } + + // P2 sends a text message over the Data Track + P2Submit.addEventListener('click', P2SubmitHandler); + + // Connect P2 + P2Connect.addEventListener('click', async event => { + event.preventDefault(); + + // Appends text to DOM + function appendText(text) { + p2ChatLog.appendChild(createMessages('P1', text)); + p2ChatLog.scrollTop = p2ChatLog.scrollHeight; + } + + let room = await connectToOrDisconnectFromRoom( + event, + P2Connect, + roomP2, + roomName + ); + + if (room) { + roomP2 = room; + } else { + roomP2 = null; + P2localDataTrack = null; + P2Submit.disabled = true; + } + + if (roomP2) { + // Once the Data Track has been published, set the P2localDataTrack for use + roomP2.localParticipant.on('trackPublished', publication => { + if (publication.track.kind === 'data') { + P2localDataTrack = publication.track; + P2Submit.disabled = false; + } + }); + + // P2 to announce connected RemoteParticipants + roomP2.on('participantConnected', participant => { + appendText('has connected'); + }); + + // P2 Subscribe to tracks published by remoteParticipants and append them + receiveChatMessages(roomP2, appendText); + + // P2 to handle disconnected RemoteParticipants. + roomP2.on('participantDisconnected', participant => { + appendText('has disconnected'); + }); + } + }); + + // Disconnect from the Room on page unload. + window.onbeforeunload = function() { + if (roomP1) { + roomP1.disconnect(); + roomP1 = null; + } + if (roomP2) { + roomP2.disconnect(); + roomP2 = null; + } + }; +})(); diff --git a/examples/dominantspeaker/public/index.css b/examples/dominantspeaker/public/index.css new file mode 100644 index 00000000..98841773 --- /dev/null +++ b/examples/dominantspeaker/public/index.css @@ -0,0 +1,151 @@ +@import url('https://fonts.googleapis.com/css?family=Roboto+Mono:300'); + +html { + height: 100%; +} + +body { + height: 100%; +} + +[data-toggle="collapse"].collapsed .if-not-collapsed { + display: none; +} + +[data-toggle="collapse"]:not(.collapsed) .if-collapsed { + display: none; +} + +.align { + align-content: flex-start; +} + +div.container-fluid { + height: 100%; +} + +div.row { + height: 100%; +} + +div.row.thin-gutters { + margin: 0 2px 0 2px; +} + +div.row.thin-gutters > .col, +div.row.thin-gutters > [class*="col-"] { + padding: 8px 8px; +} + +div.col-sm-6, div.col-sm-6 { + max-height: fit-content; +} + +div#remote-media { + display: flex; + justify-content: flex-start; + flex-wrap: wrap; + height: auto; + width: 100%; + background-color: #fff; + text-align: center; + margin: auto; +} + +div.mediadiv { + margin: 10px; + width: 200px; +} + +div#remote-media h6 { + position: absolute; + font-size: 14px; + background-color: rgba(0, 0, 0, 0.4); + color: white; + max-width: calc((100% - 16px) - 0.5em); + margin: 0.25em; + border-radius: 4px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +#roomName { + padding: 2px; + border: 1px solid black; + margin: 2px; +} + +div#remote-media video { + max-width: 100% !important; + max-height: 80% !important; + background-color: #272726; + background-repeat: no-repeat; +} + +div#user-controls { + display: flex; + flex-direction: column; + flex-wrap: wrap; + height: 40%; + width: 100%; + margin: 10px; + padding: 10px; +} + +div.usercontrol { + margin: 5px; + border: 1px solid black; + padding: 10px; +} + +div.dominant_speaker { + border: 4px solid red; + background: aqua; +} + +pre.language-javascript { + font-family: 'Roboto Mono', monospace; + font-size: 13px; +} + +pre.language-javascript a { + color: aquamarine; + text-decoration: underline; +} + +pre.language-javascript a:hover { + text-decoration: none; +} + +div.card { + border: none; + overflow-y: auto; +} + +div.input-group > select { + width: 100%; +} + +div.col-sm-6, div.col-sm-6 { + max-height: fit-content; +} + +@media (max-width: 900px) { + div.col-sm-8, div.col-sm-4 { + max-width: 100%; + flex: 100%; + } + + div.col-sm-8 { + height: 40%; + } + + div.col-sm-4 { + height: 60%; + } + + pre.language-javascript { + font-size: 12px; + } +} diff --git a/examples/dominantspeaker/public/index.html b/examples/dominantspeaker/public/index.html new file mode 100644 index 00000000..a3d43304 --- /dev/null +++ b/examples/dominantspeaker/public/index.html @@ -0,0 +1,48 @@ + + + + + + Dominant Speaker + + + + + +
+
+
+
+
+

+ Dominant Speaker Detection +

+ +
+

+            
+
+
+
+
+ +
+
+
+
+
+ + + + + + diff --git a/examples/dominantspeaker/public/prism.css b/examples/dominantspeaker/public/prism.css new file mode 100644 index 00000000..7e2569ab --- /dev/null +++ b/examples/dominantspeaker/public/prism.css @@ -0,0 +1,123 @@ +/* http://prismjs.com/download.html?themes=prism-okaidia&languages=markup+css+clike+javascript */ +/** + * okaidia theme for JavaScript, CSS and HTML + * Loosely based on Monokai textmate theme by http://www.monokai.nl/ + * @author ocodia + */ + +code[class*="language-"], +pre[class*="language-"] { + color: #f8f8f2; + background: none; + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: .5em 0; + overflow: auto; + border-radius: 0.3em; +} + +:not(pre) > code[class*="language-"], +pre[class*="language-"] { + background: #272822; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: .1em; + border-radius: .3em; + white-space: normal; +} + +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: #87ceeb; +} + +.token.operator, +.token.punctuation { + color: #ff5555; +} + +.namespace { + opacity: .7; +} + +.token.property, +.token.tag, +.token.constant, +.token.symbol, +.token.deleted { + color: #f92672; +} + +.token.boolean { + color: #55ff55; +} + +.token.number { + color: #cd5c5c; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #ff55ff; +} + +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string, +.token.variable { + color: #ff55ff; +} + +.token.function { + color: #ccc; +} + +.token.keyword { + color: #55ff55; +} + +.token.regex, +.token.important { + color: #fd971f; +} + +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} diff --git a/examples/dominantspeaker/src/helpers.js b/examples/dominantspeaker/src/helpers.js new file mode 100644 index 00000000..ec1ef1c5 --- /dev/null +++ b/examples/dominantspeaker/src/helpers.js @@ -0,0 +1,31 @@ +'use strict'; + +var Video = require('twilio-video'); + +/** + * Connect to a Room with the Dominant Speaker API enabled. + * This API is available only in Small Group or Group Rooms. + * @param {string} token - Token for joining the Room + * @returns {CancelablePromise} + */ +function connectToRoomWithDominantSpeaker(token) { + return Video.connect(token, { + dominantSpeaker: true + }); +} + +/** + * Listen to changes in the dominant speaker and update your application. + * @param {Room} room - The Room you just joined + * @param {function} updateDominantSpeaker - Updates the app UI with the new dominant speaker + * @returns {void} + */ +function setupDominantSpeakerUpdates(room, updateDominantSpeaker) { + room.on('dominantSpeakerChanged', function(participant) { + console.log('A new RemoteParticipant is now the dominant speaker:', participant); + updateDominantSpeaker(participant); + }); +} + +exports.connectToRoomWithDominantSpeaker = connectToRoomWithDominantSpeaker; +exports.setupDominantSpeakerUpdates = setupDominantSpeakerUpdates; diff --git a/examples/dominantspeaker/src/index.js b/examples/dominantspeaker/src/index.js new file mode 100644 index 00000000..68dcdd70 --- /dev/null +++ b/examples/dominantspeaker/src/index.js @@ -0,0 +1,181 @@ +'use strict'; + +const Prism = require('prismjs'); +const Video = require('twilio-video'); +const getRoomCredentials = require('../../util/getroomcredentials'); +const getSnippet = require('../../util/getsnippet'); +const helpers = require('./helpers'); +const connectToRoomWithDominantSpeaker = helpers.connectToRoomWithDominantSpeaker; +const setupDominantSpeakerUpdates = helpers.setupDominantSpeakerUpdates; + +const joinRoomBlock = document.querySelector('#joinRoom'); +const roomNameText = document.querySelector('#roomName'); +const mediaContainer = document.getElementById('remote-media'); +const userControls = document.getElementById('user-controls'); +let roomName = null; +const SAMPLE_USER_COUNT = 4; + +/** + * creates a button and adds to given container. + */ +function createButton(text, container) { + const btn = document.createElement('button'); + btn.innerHTML = text; + btn.classList.add('btn', 'btn-outline-primary', 'btn-sm'); + container.appendChild(btn); + return btn; +} + +/** + * + * creates controls for user to mute/unmute and disconnect + * from the room. + */ +async function createUserControls(userIdentity) { + const creds = await getRoomCredentials(userIdentity); + let room = null; + + const currentUserControls = document.createElement('div'); + currentUserControls.classList.add('usercontrol'); + + const title = document.createElement('h6'); + title.appendChild(document.createTextNode(creds.identity)); + currentUserControls.appendChild(title); + + // connect button + const connectDisconnect = createButton('Connect', currentUserControls); + connectDisconnect.onclick = async function(event) { + connectDisconnect.disabled = true; + const connected = room !== null; + if (connected) { + room.disconnect(); + room = null; + muteBtn.innerHTML = 'Mute'; + } else { + room = await connectToRoom(creds); + } + connectDisconnect.innerHTML = connected ? 'Connect' : 'Disconnect'; + muteBtn.style.display = connected ? 'none' : 'inline'; + connectDisconnect.disabled = false; + } + + // mute button. + const muteBtn = createButton('Mute', currentUserControls); + muteBtn.onclick = function() { + const mute = muteBtn.innerHTML == 'Mute'; + const localUser = room.localParticipant; + getTracks(localUser).forEach(function(track) { + if (track.kind === 'audio') { + if (mute) { + track.disable(); + } else { + track.enable(); + } + } + }); + muteBtn.innerHTML = mute ? 'Unmute' : 'Mute'; + } + muteBtn.style.display = 'none'; + userControls.appendChild(currentUserControls); +} + +/** + * Connect the Participant with media to the Room. + */ +async function connectToRoom(creds) { + const room = await Video.connect( creds.token, { + name: roomName + }); + + return room; +} + +/** + * Get the Tracks of the given Participant. + */ +function getTracks(participant) { + return Array.from(participant.tracks.values()).filter(function(publication) { + return publication.track; + }).map(function(publication) { + return publication.track; + }); +} + +/** + * add/removes css attribute per dominant speaker change. + * @param {?Participant} speaker - Participant + * @returns {void} + */ +function updateDominantSpeaker(speaker) { + const dominantSpeakerDiv = document.querySelector('div.dominant_speaker'); + if (dominantSpeakerDiv) { + dominantSpeakerDiv.classList.remove('dominant_speaker'); + } + if (speaker) { + const newDominantSpeakerDiv = document.getElementById(speaker.sid); + if (newDominantSpeakerDiv) { + newDominantSpeakerDiv.classList.add('dominant_speaker'); + } + } +} + +(async function() { + // Load the code snippet. + const snippet = await getSnippet('./helpers.js'); + const pre = document.querySelector('pre.language-javascript'); + + pre.innerHTML = Prism.highlight(snippet, Prism.languages.javascript); + + // Get the credentials to connect to the Room. + const creds = await getRoomCredentials(); + + // Connect to a random Room with no media. This Participant will + // display the media of the other Participants that will enter + // the Room and watch for dominant speaker updates. + const someRoom = await connectToRoomWithDominantSpeaker(creds.token); + + setupDominantSpeakerUpdates(someRoom, updateDominantSpeaker); + + // Set the name of the Room to which the Participant that shares + // media should join. + joinRoomBlock.style.display = 'block'; + roomName = someRoom.name; + roomNameText.appendChild(document.createTextNode(roomName)); + + // create controls to connect few users + ['Alice', 'Bob', 'Charlie', 'Mak'].forEach(createUserControls); + + someRoom.on('participantConnected', function(participant) { + const participantdiv = document.createElement('div'); + participantdiv.id = participant.sid; + const mediaDiv = document.createElement('div'); + mediaDiv.classList.add("mediadiv"); + + const title = document.createElement('h6'); + title.appendChild(document.createTextNode(participant.identity)); + mediaDiv.appendChild(title); + + participant.on('trackSubscribed', function(track) { + mediaDiv.appendChild(track.attach()); + }); + participantdiv.appendChild(mediaDiv); + mediaContainer.appendChild(participantdiv); + }); + + someRoom.on('participantDisconnected', function(participant) { + getTracks(participant).forEach(function(track) { + track.detach().forEach(function(element) { + element.srcObject = null; + element.remove(); + }); + }); + const participantDiv = document.getElementById(participant.sid); + participantDiv.parentNode.removeChild(participantDiv); + }); + + // Disconnect from the Room on page unload. + window.onbeforeunload = function() { + someRoom.disconnect(); + someRoom = null; + }; +}()); diff --git a/examples/index.html b/examples/index.html index 8818dcb4..e57dc281 100644 --- a/examples/index.html +++ b/examples/index.html @@ -13,73 +13,220 @@

Examples

These example apps demonstrate special use-cases of the Twilio Video JS SDK.

-
+

To try the quickstart, click here

+
- -

- Bandwidth Constraints -

+
+ +

+ Bandwidth Constraints +

+

This app demonstrates the Bandwidth Constraints API, which can be used to control the send-side bandwidth of the media that is published to the Room.

- - -

- Local Video Filter -

+
+
+ +

+ Local Video Filter +

+

This app demonstrates a way to apply filters to the LocalVideoTrack. The filtered LocalVideoTrack can then be used to connect to a Room.

- +
- -

- Local Video Snapshot -

+
+ +

+ Local Video Snapshot +

+

This app demonstrates a way to capture a snapshot of the LocalVideoTrack using the HTMLCanvasElement.

- - -

- Media Device Selection -

+
+
+ +

+ Media Device Selection +

+

This app demonstrates a way to select the media devices to be used for capturing local media.

- +
+
+
+
+
+
+ +

+ Codec Preferences +

+
+
+

+ This app demonstrates the Codec Preferences API, + which can be used to specify the send-side audio and video codec preferences while connecting to a Room. +

+
+
+
+ +

+ Share Your Screen +

+
+
+

+ This app demonstrates how to capture your screen + so that you can share it with other Participants in the Room. This example requires that the browser support the + getDisplayMedia API. +

+
+
-
+
+
+
+ +

+ Dominant Speaker +

+
+
+

+ This app demonstrates the Dominant Speaker API for Group or Small Group Rooms. + In order to run this example, please go to the Programmable Video Settings in the + Twilio Console and select the Room Type as Group or Group-Small. +

+
+
+
+ +

+ Reconnection States and Events +

+
+
+

+ This app demonstrates the Room's Reconnection States and Events + which can be used to notify the user when Twilio Video SDK is reconnecting + to the Room, probably due to network interruptions or handoffs. +

+
+
+
+
+
- -

- Codec Preferences -

+
+ +

+ Network Quality +

+
+
+

+ This app demonstrates the Network Quality API for Group or Small Group Rooms. + In order to run this example, please go to the Programmable Video Settings in the + Twilio Console and select the Room Type as Group or Group-Small. +

+
+
+
+ +

+ Enabling and Disabling Tracks +

+
+
+

+ This app demonstrates how to enable/disable your LocalTracks and handle the events on the remote side. +

+
+
+
+
+
+
+
+ +

+ RemoteParticipant Reconnection States and Events +

+
+
+

+ This app demonstrates the RemoteParticipant Reconnection States and Events which can be used to update your application when somebody else is reconnecting to the Room. +

+
+
+
+ +

+ DataTracks +

+
+
+

+ This app demonstrates the DataTracks API which can be used to send low latency messages to subscribers. It can also be used to create "whiteboarding" sessions as demonstrated in Draw With Twilio. +

+
+
+
+
+
+
+
+ +

+ Video Track Manual Controls +

+
+
+

+ This app demonstrates the manual modes of the clientTrackSwitchOffControl and + contentPreferencesMode features. + Which can be used to efficiently utilize CPU and bandwidth. +

+
+
+
+ +

+ Video Track Automatic Controls +

+

- This app demonstrates the Codec Preferences API, which can be used to specify the send-side - audio and video codec preferences while connecting to a Room. + This app demonstrates the default (automatic) modes of the clientTrackSwitchOffControl and + contentPreferencesMode features. + Which can be used to efficiently utilize CPU and bandwidth.

- +
diff --git a/examples/localmediacontrols/public/index.css b/examples/localmediacontrols/public/index.css new file mode 100644 index 00000000..62322a63 --- /dev/null +++ b/examples/localmediacontrols/public/index.css @@ -0,0 +1,132 @@ +@import url('https://fonts.googleapis.com/css?family=Roboto+Mono:300'); + +html { + height: 100%; +} + +body { + height: 100%; +} + +[data-toggle="collapse"].collapsed .if-not-collapsed { + display: none; +} + +[data-toggle="collapse"]:not(.collapsed) .if-collapsed { + display: none; +} + +.align { + align-content: flex-start; +} + +div.container-fluid { + height: 100%; +} + +div.row { + height: 100%; +} + +div.row.thin-gutters { + margin: 0 2px 0 2px; +} + +div.row.thin-gutters > .col, +div.row.thin-gutters > [class*="col-"] { + padding: 8px 8px; +} + +div.card { + border: none; + overflow-y: auto; +} + +div.col-sm-6, div.col-sm-6 { + max-height: fit-content; +} + + +pre.language-javascript { + font-family: 'Roboto Mono', monospace; + font-size: 13px; +} + +pre.language-javascript a { + color: aquamarine; + text-decoration: underline; +} + +pre.language-javascript a:hover { + text-decoration: none; +} + +div#userControls { + display: flex; + flex-direction: row; + justify-content: center; + padding: 5px; +} + +div#userControls > button { + align-items: center; + margin:5px; + border-radius: 8px; + transition-duration: 0.4s; +} + +div#media-container { + display: grid; + grid-template-areas: 'content'; + width: 100% +} + +div#videopreview { + grid-area: content; + width: 100%; +} + +div#audiopreview { + grid-area: content; + z-index: 1; + background-color: transparent; + padding: 2px 2px; + width: 100%; +} + + +div#audiopreview > span > i#activeIcon { + display: flex; + font-size: 50px; +} + +div#audiopreview > span > i#inactiveIcon { + display:none; +} + +div#videopreview > video { + width: 100%; + background-color: lightgrey !important; + background-image: url('https://static0.twilio.com/marketing/bundles/archetype/img/logo-wordmark.svg'); + background-position: 50%; + background-repeat: no-repeat; +} + +@media (max-width: 900px) { + div.col-sm-8, div.col-sm-4 { + max-width: 100%; + flex: 100%; + } + + div.col-sm-8 { + height: 40%; + } + + div.col-sm-4 { + height: 60%; + } + + pre.language-javascript { + font-size: 12px; + } +} \ No newline at end of file diff --git a/examples/localmediacontrols/public/index.html b/examples/localmediacontrols/public/index.html new file mode 100644 index 00000000..353791db --- /dev/null +++ b/examples/localmediacontrols/public/index.html @@ -0,0 +1,67 @@ + + + + + + Enabling and Disabling Tracks + + + + + + +
+
+
+
+
+

+ Enabling and Disabling Tracks +

+ +
+

+            
+
+
+
+
+
+
+

Local Media Controls

+
+ + +
+
+
+
+
+

RemoteParticipant View

+
+
+
+
+ + + + + + +
+
+
+
+
+
+
+
+ + + + + + diff --git a/examples/localmediacontrols/public/prism.css b/examples/localmediacontrols/public/prism.css new file mode 100644 index 00000000..8d846b2b --- /dev/null +++ b/examples/localmediacontrols/public/prism.css @@ -0,0 +1,123 @@ +/* http://prismjs.com/download.html?themes=prism-okaidia&languages=markup+css+clike+javascript */ +/** + * okaidia theme for JavaScript, CSS and HTML + * Loosely based on Monokai textmate theme by http://www.monokai.nl/ + * @author ocodia + */ + + code[class*="language-"], + pre[class*="language-"] { + color: #f8f8f2; + background: none; + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; + } + + /* Code blocks */ + pre[class*="language-"] { + padding: 1em; + margin: .5em 0; + overflow: auto; + border-radius: 0.3em; + } + + :not(pre) > code[class*="language-"], + pre[class*="language-"] { + background: #272822; + } + + /* Inline code */ + :not(pre) > code[class*="language-"] { + padding: .1em; + border-radius: .3em; + white-space: normal; + } + + .token.comment, + .token.prolog, + .token.doctype, + .token.cdata { + color: #87ceeb; + } + + .token.operator, + .token.punctuation { + color: #ff5555; + } + + .namespace { + opacity: .7; + } + + .token.property, + .token.tag, + .token.constant, + .token.symbol, + .token.deleted { + color: #f92672; + } + + .token.boolean { + color: #55ff55; + } + + .token.number { + color: #cd5c5c; + } + + .token.selector, + .token.attr-name, + .token.string, + .token.char, + .token.builtin, + .token.inserted { + color: #ff55ff; + } + + .token.entity, + .token.url, + .language-css .token.string, + .style .token.string, + .token.variable { + color: #ff55ff; + } + + .token.function { + color: #ccc; + } + + .token.keyword { + color: #55ff55; + } + + .token.regex, + .token.important { + color: #fd971f; + } + + .token.important, + .token.bold { + font-weight: bold; + } + .token.italic { + font-style: italic; + } + + .token.entity { + cursor: help; + } diff --git a/examples/localmediacontrols/src/helpers.js b/examples/localmediacontrols/src/helpers.js new file mode 100644 index 00000000..a96d49a6 --- /dev/null +++ b/examples/localmediacontrols/src/helpers.js @@ -0,0 +1,81 @@ +'use strict'; + +/** + * Mute/unmute your media in a Room. + * @param {Room} room - The Room you have joined + * @param {'audio'|'video'} kind - The type of media you want to mute/unmute + * @param {'mute'|'unmute'} action - Whether you want to mute/unmute + */ +function muteOrUnmuteYourMedia(room, kind, action) { + const publications = kind === 'audio' + ? room.localParticipant.audioTracks + : room.localParticipant.videoTracks; + + publications.forEach(function(publication) { + if (action === 'mute') { + publication.track.disable(); + } else { + publication.track.enable(); + } + }); +} + +/** + * Mute your audio in a Room. + * @param {Room} room - The Room you have joined + * @returns {void} + */ +function muteYourAudio(room) { + muteOrUnmuteYourMedia(room, 'audio', 'mute'); +} + +/** + * Mute your video in a Room. + * @param {Room} room - The Room you have joined + * @returns {void} + */ +function muteYourVideo(room) { + muteOrUnmuteYourMedia(room, 'video', 'mute'); +} + +/** + * Unmute your audio in a Room. + * @param {Room} room - The Room you have joined + * @returns {void} + */ +function unmuteYourAudio(room) { + muteOrUnmuteYourMedia(room, 'audio', 'unmute'); +} + +/** + * Unmute your video in a Room. + * @param {Room} room - The Room you have joined + * @returns {void} + */ +function unmuteYourVideo(room) { + muteOrUnmuteYourMedia(room, 'video', 'unmute'); +} + +/** + * A RemoteParticipant muted or unmuted its media. + * @param {Room} room - The Room you have joined + * @param {function} onMutedMedia - Called when a RemoteParticipant muted its media + * @param {function} onUnmutedMedia - Called when a RemoteParticipant unmuted its media + * @returns {void} + */ +function participantMutedOrUnmutedMedia(room, onMutedMedia, onUnmutedMedia) { + room.on('trackSubscribed', function(track, publication, participant) { + track.on('disabled', function() { + return onMutedMedia(track, participant); + }); + track.on('enabled', function() { + return onUnmutedMedia(track, participant); + }); + }); +} + +exports.muteYourAudio = muteYourAudio; +exports.muteYourVideo = muteYourVideo; +exports.unmuteYourAudio = unmuteYourAudio; +exports.unmuteYourVideo = unmuteYourVideo; +exports.participantMutedOrUnmutedMedia = participantMutedOrUnmutedMedia; diff --git a/examples/localmediacontrols/src/index.js b/examples/localmediacontrols/src/index.js new file mode 100644 index 00000000..3b6d2da4 --- /dev/null +++ b/examples/localmediacontrols/src/index.js @@ -0,0 +1,108 @@ +'use strict'; + +const Prism = require('prismjs'); +const Video = require('twilio-video'); +const getSnippet = require('../../util/getsnippet'); +const getRoomCredentials = require('../../util/getroomcredentials'); +const helpers = require('./helpers'); +const muteYourAudio = helpers.muteYourAudio; +const muteYourVideo = helpers.muteYourVideo; +const unmuteYourAudio = helpers.unmuteYourAudio; +const unmuteYourVideo = helpers.unmuteYourVideo; +const participantMutedOrUnmutedMedia = helpers.participantMutedOrUnmutedMedia; + +const audioPreview = document.getElementById('audiopreview'); +const videoPreview = document.getElementById('videopreview'); +let roomName = null; + +(async function(){ + // Load the code snippet. + const snippet = await getSnippet('./helpers.js'); + const pre = document.querySelector('pre.language-javascript'); + + pre.innerHTML = Prism.highlight(snippet, Prism.languages.javascript); + + // Get the credentials to connect to the Room. + const credsP1 = await getRoomCredentials(); + const credsP2 = await getRoomCredentials(); + + // Create room instance and name for participants to join. + const roomP1 = await Video.connect(credsP1.token); + + // Set room name for participant 2 to join. + roomName = roomP1.name; + + // Connecting remote participants. + const roomP2 = await Video.connect(credsP2.token, { + name: roomName, + tracks: [] + }); + + // Muting audio track and video tracks click handlers + muteAudioBtn.onclick = () => { + const mute = !muteAudioBtn.classList.contains('muted'); + const activeIcon = document.getElementById('activeIcon'); + const inactiveIcon = document.getElementById('inactiveIcon'); + + if(mute) { + muteYourAudio(roomP1); + muteAudioBtn.classList.add('muted'); + muteAudioBtn.innerText = 'Enable Audio'; + activeIcon.id = 'inactiveIcon'; + inactiveIcon.id = 'activeIcon'; + + } else { + unmuteYourAudio(roomP1); + muteAudioBtn.classList.remove('muted'); + muteAudioBtn.innerText = 'Disable Audio'; + activeIcon.id = 'inactiveIcon'; + inactiveIcon.id = 'activeIcon'; + } + } + + muteVideoBtn.onclick = () => { + const mute = !muteVideoBtn.classList.contains('muted'); + + if(mute) { + muteYourVideo(roomP1); + muteVideoBtn.classList.add('muted'); + muteVideoBtn.innerText = 'Enable Video'; + } else { + unmuteYourVideo(roomP1); + muteVideoBtn.classList.remove('muted'); + muteVideoBtn.innerText = 'Disable Video'; + } + } + + // Starts video upon P2 joining room + roomP2.on('trackSubscribed', track => { + if (track.isEnabled) { + if (track.kind === 'audio') { + audioPreview.appendChild(track.attach()); + } else{ + videoPreview.appendChild(track.attach()); + } + } + }); + + participantMutedOrUnmutedMedia(roomP2, track => { + track.detach().forEach(element => { + element.srcObject = null; + element.remove(); + }); + }, track => { + if (track.kind === 'audio') { + audioPreview.appendChild(track.attach()); + } + if (track.kind === 'video') { + videoPreview.appendChild(track.attach()); + } + }); + + // Disconnect from the Room + window.onbeforeunload = () => { + roomP1.disconnect(); + roomP2.disconnect(); + roomName = null; + } +}()); diff --git a/examples/localvideofilter/public/index.css b/examples/localvideofilter/public/index.css index b3555eda..a3edb028 100755 --- a/examples/localvideofilter/public/index.css +++ b/examples/localvideofilter/public/index.css @@ -8,6 +8,18 @@ body { height: 100%; } +[data-toggle="collapse"].collapsed .if-not-collapsed { + display: none; +} + +[data-toggle="collapse"]:not(.collapsed) .if-collapsed { + display: none; +} + +.align { + align-content: flex-start; +} + div.container-fluid { height: 100%; } @@ -22,7 +34,7 @@ div.row.thin-gutters { div.row.thin-gutters > .col, div.row.thin-gutters > [class*="col-"] { - padding: 0 2px; + padding: 8px 8px; } div.col-sm-8, div.col-sm-4 { @@ -48,12 +60,8 @@ div.card { overflow-y: auto; } -div.col-sm-8 > .card { - height: 100%; -} - -div.col-sm-4 > .card { - height: 50%; +div.col-sm-6, div.col-sm-6 { + max-height: fit-content; } video#videoinputfiltered, diff --git a/examples/localvideofilter/public/index.html b/examples/localvideofilter/public/index.html index 5c6dfeb8..d742dca0 100755 --- a/examples/localvideofilter/public/index.html +++ b/examples/localvideofilter/public/index.html @@ -5,24 +5,30 @@ Local Video Filter - +
-
-
+
+

Local Video Filter

-

+            
+            
+

+            
-
+

Local Video (raw)

@@ -45,5 +51,8 @@

Local Video (with filter)

+ + + diff --git a/examples/localvideofilter/src/filters.js b/examples/localvideofilter/src/filters.js index a740fbc5..f70e75f1 100755 --- a/examples/localvideofilter/src/filters.js +++ b/examples/localvideofilter/src/filters.js @@ -26,7 +26,7 @@ function grayscale(imageData) { var r = data[i]; var g = data[i + 1]; var b = data[i + 2]; - var gs = ((0.2126 * r) + (0.7152 * g) + (0.0722 * b)) / 3; + var gs = (r + g + b) / 3; data[i] = data[i + 1] = data[i + 2] = gs; } return imageData; diff --git a/examples/localvideofilter/src/helpers-video-processor.js b/examples/localvideofilter/src/helpers-video-processor.js new file mode 100644 index 00000000..5187fa96 --- /dev/null +++ b/examples/localvideofilter/src/helpers-video-processor.js @@ -0,0 +1,76 @@ +'use strict'; + +var Video = require('twilio-video'); + +/** + * Apply a filter to the frames of a LocalVideoTrack. + * @param {number} width - Width in pixels of the video frames + * @param {number} height - Height in pixels of the video frames + * @param {string} filterCSS - Filter CSS string + * @constructor + */ +function FilterVideoProcessor(width, height, filterCSS) { + this._outputFrame = new OffscreenCanvas(width, height); + this._outputContext = this._outputFrame.getContext('2d'); + this._outputContext.filter = filterCSS; +} + +/** + * Process a frame of the LocalVideoTrack. + * @param {OffscreenCanvas} inputFrame + * @returns {OffscreenCanvas} + */ +FilterVideoProcessor.prototype.processFrame = function processFrame(inputFrame) { + this._outputContext.drawImage(inputFrame, 0, 0); + return this._outputFrame; +}; + +/** + * Display local video in the given HTMLVideoElement. + * @param {HTMLVideoElement} video + * @returns {Promise} + */ +function displayLocalVideo(video) { + return Video.createLocalVideoTrack({ + width: 640, + height: 360 + }).then(function(localTrack) { + localTrack.attach(video); + }); +} + +/** + * The filtered LocalVideoTrack; + */ +var filteredLocalTrack; + +/** + * Apply the specified filter to the local video. + * @param {HTMLVideoElement} video - Raw video + * @param {HTMLVideoElement} filtered - Filtered video + * @param {'none' | 'blur' | 'grayscale' | 'sepia'} name - Filter name + */ +function filterLocalVideo(video, filtered, name) { + if (!filteredLocalTrack) { + var mediaStreamTrack = video.srcObject.getVideoTracks()[0]; + filteredLocalTrack = new Video.LocalVideoTrack(mediaStreamTrack); + filteredLocalTrack.attach(filtered); + } + + var processor = filteredLocalTrack.processor; + if (processor) { + filteredLocalTrack.removeProcessor(processor); + } + + if (name !== 'none') { + var filterCSS = name + '(' + (name === 'blur' ? '5px' : '100%') + ')'; + var settings = filteredLocalTrack.mediaStreamTrack.getSettings(); + var height = settings.height; + var width = settings.width; + processor = new FilterVideoProcessor(width, height, filterCSS); + filteredLocalTrack.addProcessor(processor); + } +} + +module.exports.displayLocalVideo = displayLocalVideo; +module.exports.filterLocalVideo = filterLocalVideo; diff --git a/examples/localvideofilter/src/helpers.js b/examples/localvideofilter/src/helpers.js index 8be5c39b..2e6dc97b 100755 --- a/examples/localvideofilter/src/helpers.js +++ b/examples/localvideofilter/src/helpers.js @@ -3,20 +3,26 @@ var Video = require('twilio-video'); var filters = require('./filters'); -var VIDEO_WIDTH = 320; -var VIDEO_HEIGHT = 240; - /** * Display local video in the given HTMLVideoElement. * @param {HTMLVideoElement} video * @returns {Promise} */ function displayLocalVideo(video) { - return Video.createLocalVideoTrack().then(function(localTrack) { + return Video.createLocalVideoTrack({ + width: 640, + height: 360 + }).then(function(localTrack) { localTrack.attach(video); }); } +/** + * The timeout for filtering a video frame. + * @type {number} + */ +var filterTimeout; + /** * Apply the specified filter to the local video. * @param {HTMLVideoElement} video - Raw video @@ -25,20 +31,34 @@ function displayLocalVideo(video) { */ function filterLocalVideo(video, filtered, name) { var canvas = document.createElement('canvas'); + var mediaStreamTrack = video.srcObject.getVideoTracks()[0]; + var settings = mediaStreamTrack.getSettings(); + canvas.width = settings.width; + canvas.height = settings.height; + var context = canvas.getContext('2d'); - canvas.width = VIDEO_WIDTH; - canvas.height = VIDEO_HEIGHT; + var filterValue = name === 'blur' ? '5px' : '100%'; + context.filter = name === 'none' ? '' : name + '(' + filterValue + ')'; + clearTimeout(filterTimeout); + + var isSafari = /Safari/.test(navigator.userAgent) + && !/Chrome/.test(navigator.userAgent); function filterVideoFrame() { - context.drawImage(video, 0, 0, canvas.width, canvas.height); - var imageData = context.getImageData(0, 0, canvas.width, canvas.height); - context.putImageData(filters[name](imageData), 0, 0); - requestAnimationFrame(filterVideoFrame); + var renderStartTime = Date.now(); + context.drawImage(video, 0, 0); + if (isSafari) { + var imageData = context.getImageData(0, 0, canvas.width, canvas.height); + context.putImageData(filters[name](imageData), 0, 0); + } + var renderDelay = Date.now() - renderStartTime; + var interFrameDelay = Math.round(1000 / settings.frameRate); + filterTimeout = setTimeout(filterVideoFrame, interFrameDelay - renderDelay); } - var stream = canvas.captureStream(30); + var stream = canvas.captureStream(settings.frameRate); filtered.srcObject = stream; - requestAnimationFrame(filterVideoFrame); + filterTimeout = setTimeout(filterVideoFrame); } module.exports.displayLocalVideo = displayLocalVideo; diff --git a/examples/localvideofilter/src/index.js b/examples/localvideofilter/src/index.js index 5d910f16..0ebd9522 100755 --- a/examples/localvideofilter/src/index.js +++ b/examples/localvideofilter/src/index.js @@ -2,7 +2,11 @@ var Prism = require('prismjs'); var getSnippet = require('../../util/getsnippet'); -var helpers = require('./helpers'); + +var helpers = typeof OffscreenCanvas === 'undefined' + ? require('./helpers') + : require('./helpers-video-processor'); + var displayLocalVideo = helpers.displayLocalVideo; var filterLocalVideo = helpers.filterLocalVideo; @@ -11,7 +15,11 @@ var video = document.querySelector('video#videoinputpreview'); var filtered = document.querySelector('video#videoinputfiltered'); // Load the code snippet. -getSnippet('./helpers.js').then(function(snippet) { +getSnippet( + typeof OffscreenCanvas === 'undefined' + ? './helpers.js' + : './helpers-video-processor.js' +).then(function(snippet) { var pre = document.querySelector('pre.language-javascript'); pre.innerHTML = Prism.highlight(snippet, Prism.languages.javascript); }); diff --git a/examples/localvideosnapshot/public/index.css b/examples/localvideosnapshot/public/index.css index cc007ae4..5c7175b4 100644 --- a/examples/localvideosnapshot/public/index.css +++ b/examples/localvideosnapshot/public/index.css @@ -8,6 +8,18 @@ body { height: 100%; } +[data-toggle="collapse"].collapsed .if-not-collapsed { + display: none; +} + +[data-toggle="collapse"]:not(.collapsed) .if-collapsed { + display: none; +} + +.align { + align-content: flex-start; +} + div.container-fluid { height: 100%; } @@ -22,7 +34,11 @@ div.row.thin-gutters { div.row.thin-gutters > .col, div.row.thin-gutters > [class*="col-"] { - padding: 0 2px; + padding: 8px 8px; +} + +div.col-sm-6, div.col-sm-6 { + max-height: fit-content; } div.col-sm-8, div.col-sm-4 { @@ -48,17 +64,15 @@ div.card { overflow-y: auto; } -div.col-sm-8 > .card { - height: 100%; -} - -div.col-sm-4 > .card { - height: 50%; +.hidden { + display: none; } -canvas#snapshot, +canvas.snapshot-canvas, +img.snapshot-img, video#videoinputpreview { width: 100%; + height: 100%; background-color: lightgrey !important; background-image: url('https://static0.twilio.com/marketing/bundles/archetype/img/logo-wordmark.svg'); background-position: 50%; diff --git a/examples/localvideosnapshot/public/index.html b/examples/localvideosnapshot/public/index.html index 50dd0930..e2eb2a6d 100644 --- a/examples/localvideosnapshot/public/index.html +++ b/examples/localvideosnapshot/public/index.html @@ -5,24 +5,30 @@ Local Video Snapshot - +
-
-
+
+

Local Video Snapshot

-

+            
+            
+

+            
-
+

Local Video

@@ -33,12 +39,16 @@

Local Video

Snapshot

- + +
+ + + diff --git a/examples/localvideosnapshot/src/helpers.js b/examples/localvideosnapshot/src/helpers.js index c222039b..237919db 100644 --- a/examples/localvideosnapshot/src/helpers.js +++ b/examples/localvideosnapshot/src/helpers.js @@ -10,6 +10,7 @@ var Video = require('twilio-video'); function displayLocalVideo(video) { return Video.createLocalVideoTrack().then(function(localTrack) { localTrack.attach(video); + return localTrack; }); } @@ -17,12 +18,18 @@ function displayLocalVideo(video) { * Take snapshot of the local video from the HTMLVideoElement and render it * in the HTMLCanvasElement. * @param {HTMLVideoElement} video - * @param {HTMLCanvasElement} canvas + * @param {LocalVideoTrack} localVideoTrack + * @param {HTMLCanvasElement|HTMLImageElement} snapshot */ -function takeLocalVideoSnapshot(video, canvas) { - var context = canvas.getContext('2d'); - context.clearRect(0, 0, canvas.width, canvas.height); - context.drawImage(video, 0, 0, canvas.width, canvas.height); + function takeLocalVideoSnapshot(video, localVideoTrack, snapshot) { + if (window.ImageCapture) { + const imageCapture = new ImageCapture(localVideoTrack.mediaStreamTrack); + imageCapture.takePhoto().then(function(blob) { + snapshot.src = URL.createObjectURL(blob); + }); + } else { + snapshot.getContext('2d').drawImage(video, 0, 0); + } } module.exports.displayLocalVideo = displayLocalVideo; diff --git a/examples/localvideosnapshot/src/index.js b/examples/localvideosnapshot/src/index.js index a46b2b6d..09df08b4 100644 --- a/examples/localvideosnapshot/src/index.js +++ b/examples/localvideosnapshot/src/index.js @@ -6,13 +6,27 @@ var helpers = require('./helpers'); var displayLocalVideo = helpers.displayLocalVideo; var takeLocalVideoSnapshot = helpers.takeLocalVideoSnapshot; -var canvas = document.querySelector('canvas#snapshot'); +var canvas = document.querySelector('.snapshot-canvas'); +var img = document.querySelector('.snapshot-img'); var takeSnapshot = document.querySelector('button#takesnapshot'); var video = document.querySelector('video#videoinputpreview'); +let videoTrack; +let el; + +// Show image or canvas +window.onload = function() { + el = window.ImageCapture ? img : canvas; + el.classList.remove('hidden'); + if(videoTrack) { + setSnapshotSizeToVideo(el, videoTrack); + } +} + // Set the canvas size to the video size. -function setCanvasSizeToVideo(canvas, video) { - canvas.style.height = video.clientHeight + 'px'; +function setSnapshotSizeToVideo(snapshot, video) { + snapshot.width = video.dimensions.width; + snapshot.height = video.dimensions.height; } // Load the code snippet. @@ -22,15 +36,16 @@ getSnippet('./helpers.js').then(function(snippet) { }); // Request the default LocalVideoTrack and display it. -displayLocalVideo(video).then(function() { +displayLocalVideo(video).then(function(localVideoTrack) { // Display a snapshot of the LocalVideoTrack on the canvas. + videoTrack = localVideoTrack; takeSnapshot.onclick = function() { - setCanvasSizeToVideo(canvas, video); - takeLocalVideoSnapshot(video, canvas); + setSnapshotSizeToVideo(el, localVideoTrack); + takeLocalVideoSnapshot(video, localVideoTrack, el); }; }); // Resize the canvas to the video size whenever window is resized. window.onresize = function() { - setCanvasSizeToVideo(canvas, video); + setSnapshotSizeToVideo(el, videoTrack); }; diff --git a/examples/manualrenderhint/public/index.css b/examples/manualrenderhint/public/index.css new file mode 100644 index 00000000..1bcd12be --- /dev/null +++ b/examples/manualrenderhint/public/index.css @@ -0,0 +1,113 @@ +@import url('https://fonts.googleapis.com/css?family=Roboto+Mono:300'); + +html { + height: 100%; +} + +body { + height: 100%; +} + +[data-toggle="collapse"].collapsed .if-not-collapsed { + display: none; +} + +[data-toggle="collapse"]:not(.collapsed) .if-collapsed { + display: none; +} + +.align { + align-content: flex-start; +} + +div.container-fluid { + height: 100%; +} + +div.row { + height: 100%; +} + +div.row.thin-gutters { + margin: 0 2px 0 2px; +} + +div.row.thin-gutters > .col, +div.row.thin-gutters > [class*="col-"] { + padding: 8px 8px; +} + +div.card { + border: none; + overflow-y: auto; +} + +div.card-block { + margin: 5px; +} + +div.col-sm-6, div.col-sm-6 { + max-height: fit-content; +} + + +pre.language-javascript { + font-family: 'Roboto Mono', monospace; + font-size: 13px; +} + +pre.language-javascript a { + color: aquamarine; + text-decoration: underline; +} + +pre.language-javascript a:hover { + text-decoration: none; +} + +div#media-container { + position: relative; + display: grid; + grid-template-areas: 'content'; + max-width: 100%; +} + +span#trackIsSwitchedOff { + position: absolute; + z-index: 3; + width: 67px; + font-size: .9em; + right: 1%; + top: 1%; + align-items: center; + max-width: max-content; +} + +video { + width: 100% !important; + height: auto !important; +} + +div.bitrategraph { + margin-top: 5px; + text-align: center; +} + +@media (max-width: 900px) { + div.col-sm-8, div.col-sm-4 { + max-width: 100%; + flex: 100%; + } + + div.col-sm-8 { + height: 40%; + } + + div.col-sm-4 { + height: 60%; + } + + pre.language-javascript { + font-size: 12px; + } +} diff --git a/examples/manualrenderhint/public/index.html b/examples/manualrenderhint/public/index.html new file mode 100644 index 00000000..1b20f6c4 --- /dev/null +++ b/examples/manualrenderhint/public/index.html @@ -0,0 +1,72 @@ + + + + + + Video Track Manual Controls + + + + + + +
+
+
+
+
+

+ Video Track Manual Controls +

+ +
+

+            
+
+
+
+
+
+
+

Remote Video Controls

+
+
+ Switch Off Control: + + +
+
+ Content Preferences: + +
+
+
+

Remote Video Track

+
+ + +
+
+

Video Bitrate

+
+ +
+
+
+
+
+
+ + + + + + diff --git a/examples/manualrenderhint/public/prism.css b/examples/manualrenderhint/public/prism.css new file mode 100644 index 00000000..8d846b2b --- /dev/null +++ b/examples/manualrenderhint/public/prism.css @@ -0,0 +1,123 @@ +/* http://prismjs.com/download.html?themes=prism-okaidia&languages=markup+css+clike+javascript */ +/** + * okaidia theme for JavaScript, CSS and HTML + * Loosely based on Monokai textmate theme by http://www.monokai.nl/ + * @author ocodia + */ + + code[class*="language-"], + pre[class*="language-"] { + color: #f8f8f2; + background: none; + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; + } + + /* Code blocks */ + pre[class*="language-"] { + padding: 1em; + margin: .5em 0; + overflow: auto; + border-radius: 0.3em; + } + + :not(pre) > code[class*="language-"], + pre[class*="language-"] { + background: #272822; + } + + /* Inline code */ + :not(pre) > code[class*="language-"] { + padding: .1em; + border-radius: .3em; + white-space: normal; + } + + .token.comment, + .token.prolog, + .token.doctype, + .token.cdata { + color: #87ceeb; + } + + .token.operator, + .token.punctuation { + color: #ff5555; + } + + .namespace { + opacity: .7; + } + + .token.property, + .token.tag, + .token.constant, + .token.symbol, + .token.deleted { + color: #f92672; + } + + .token.boolean { + color: #55ff55; + } + + .token.number { + color: #cd5c5c; + } + + .token.selector, + .token.attr-name, + .token.string, + .token.char, + .token.builtin, + .token.inserted { + color: #ff55ff; + } + + .token.entity, + .token.url, + .language-css .token.string, + .style .token.string, + .token.variable { + color: #ff55ff; + } + + .token.function { + color: #ccc; + } + + .token.keyword { + color: #55ff55; + } + + .token.regex, + .token.important { + color: #fd971f; + } + + .token.important, + .token.bold { + font-weight: bold; + } + .token.italic { + font-style: italic; + } + + .token.entity { + cursor: help; + } diff --git a/examples/manualrenderhint/src/helpers.js b/examples/manualrenderhint/src/helpers.js new file mode 100644 index 00000000..b5c765ef --- /dev/null +++ b/examples/manualrenderhint/src/helpers.js @@ -0,0 +1,53 @@ +'use strict'; + +var Video = require('twilio-video'); + +/** + * Connect to a Room with 'manual' mode. The default mode is 'auto'. + * @param {string} token - AccessToken for joining the Room + * @returns {Room} + */ +function joinRoom(token) { + return Video.connect(token, { + name: 'my-cool-room', + bandwidthProfile: { + video: { + contentPreferencesMode: 'manual', + clientTrackSwitchOffControl: 'manual' + } + } + }); +} + +/** + * Switch on the RemoteVideoTrack. + * @param {RemoteVideoTrack} track - The RemoteVideoTrack you want to switch on. + * @returns {RemoteVideoTrack} + */ +function switchOn(track) { + return track.switchOn(); +} + +/** + * Switch off the RemoteVideoTrack. + * @param {RemoteVideoTrack} track - The RemoteVideoTrack you want to switch off. + * @returns {RemoteVideoTrack} + */ + function switchOff(track) { + return track.switchOff(); +} + +/** + * Set the render dimensions of the RemoteVideoTrack. + * @param {RemoteVideoTrack} track - The RemoteVideoTrack you want to set render dimensions for. + * @param {{height: number, width: number}} renderDimensions - The render dimensions for the RemoteVideoTrack. + * @returns {RemoteVideoTrack} + */ +function setRenderDimensions(track, renderDimensions) { + return track.setContentPreferences(renderDimensions); +} + +module.exports.switchOn = switchOn; +module.exports.switchOff = switchOff; +module.exports.setRenderDimensions = setRenderDimensions; +module.exports.joinRoom = joinRoom; \ No newline at end of file diff --git a/examples/manualrenderhint/src/index.js b/examples/manualrenderhint/src/index.js new file mode 100644 index 00000000..3dd9de49 --- /dev/null +++ b/examples/manualrenderhint/src/index.js @@ -0,0 +1,120 @@ +'use strict' + +const Prism = require('prismjs'); +const Video = require('twilio-video'); +const getSnippet = require('../../util/getsnippet'); +const getRoomCredentials = require('../../util/getroomcredentials'); +const setupBitrateGraph = require('../../util/setupbitrategraph'); +const helpers = require('./helpers'); +const joinRoom = helpers.joinRoom; +const switchOn = helpers.switchOn; +const switchOff = helpers.switchOff; +const setRenderDimensions = helpers.setRenderDimensions; + +const renderDimensionsOption = document.querySelector('select#renderDimensionsOption'); +const switchOnBtn = document.querySelector('button#switchOn'); +const switchOffBtn = document.querySelector('button#switchOff'); +const videoEl = document.querySelector('video#remotevideo'); +const trackIsSwitchedOff = document.querySelector('span#trackIsSwitchedOff'); +let roomP1 = null; +let remoteVideoTrack = null; +let stopVideoBitrateGraph = null; + +const handleIsSwitchedOff = (isTrackSwitchedOff) => { + if(isTrackSwitchedOff) { + trackIsSwitchedOff.textContent = 'Off'; + trackIsSwitchedOff.classList.remove('badge-success'); + trackIsSwitchedOff.classList.add('badge-danger'); + } else { + trackIsSwitchedOff.textContent = 'On'; + trackIsSwitchedOff.classList.remove('badge-danger'); + trackIsSwitchedOff.classList.add('badge-success'); + } +} + +(async function(){ + // Load the code snippet. + const snippet = await getSnippet('./helpers.js'); + const pre = document.querySelector('pre.language-javascript'); + + pre.innerHTML = Prism.highlight(snippet, Prism.languages.javascript); + + const logger = Video.Logger.getLogger('twilio-video'); + logger.setLevel('silent'); + + // Get the credentials to connect to the Room. + const credsP1 = await getRoomCredentials(); + const credsP2 = await getRoomCredentials(); + + // Create room instance and name for participants to join. + roomP1 = await joinRoom(credsP1.token); + + // Create the video track for the Remote Participant. + const videoTrack = await Video.createLocalVideoTrack(); + + // Connecting remote participant. + const roomP2 = await Video.connect(credsP2.token, { + name: 'my-cool-room', + bandwidthProfile: { + video: { + contentPreferencesMode: 'manual', + clientTrackSwitchOffControl: 'manual' + } + }, + tracks: [ videoTrack ] + }); + + // Set video bitrate graph. + let startVideoBitrateGraph = setupBitrateGraph('video', 'videobitrategraph', 'videobitratecanvas'); + + // Attach RemoteVideoTrack + roomP1.on('trackSubscribed', track => { + if(track.kind === 'video') { + track.attach(videoEl); + handleIsSwitchedOff(track.isSwitchedOff); + stopVideoBitrateGraph = startVideoBitrateGraph(roomP1, 1000); + + switchOnBtn.classList.remove('disabled'); + switchOffBtn.classList.remove('disabled'); + renderDimensionsOption.classList.remove('disabled'); + + track.on('switchedOff', track => { + handleIsSwitchedOff(track.isSwitchedOff); + }); + track.on('switchedOn', track => { + handleIsSwitchedOff(track.isSwitchedOff); + }); + } + }); + + // Remote Track Switch On + switchOnBtn.onclick = event => { + switchOn(remoteVideoTrack); + } + + // Remote Track Switch Off + switchOffBtn.onclick = event => { + switchOff(remoteVideoTrack); + } + + const renderDimensionsObj = { + HD: { width: 1280, height: 720 }, + VGA: { width: 640, height: 480 }, + QCIF: { width: 176, height: 144} + } + + // Set Render Dimensions. + renderDimensionsOption.addEventListener('change', () => { + const renderDimensions = renderDimensionsObj[renderDimensionsOption.value]; + setRenderDimensions(remoteVideoTrack, { renderDimensions }); + }); + + // Disconnect from the Room + window.onbeforeunload = () => { + if (stopVideoBitrateGraph) { + stopVideoBitrateGraph(); + } + roomP1.disconnect(); + roomP2.disconnect(); + } +}()); diff --git a/examples/mediadevices/public/index.css b/examples/mediadevices/public/index.css index f0530a90..8fbf2081 100644 --- a/examples/mediadevices/public/index.css +++ b/examples/mediadevices/public/index.css @@ -8,6 +8,23 @@ body { height: 100%; } +[data-toggle="collapse"].collapsed .if-not-collapsed { + display: none; +} + +[data-toggle="collapse"]:not(.collapsed) .if-collapsed { + display: none; +} + +.card { + border: none; + max-height: min-content; +} + +.align { + align-content: flex-start; +} + div.container-fluid { height: 100%; } @@ -20,13 +37,20 @@ div.row.thin-gutters { margin: 0 2px 0 2px; } +div#remote-media video { + max-width: 100% !important; + max-height: 80% !important; + background-color: #272726; + background-repeat: no-repeat; +} + div.row.thin-gutters > .col, div.row.thin-gutters > [class*="col-"] { - padding: 0 2px; + padding: 8px 8px; } -div.col-sm-8, div.col-sm-4 { - height: 100%; +div.col-sm-6, div.col-sm-6 { + height: fit-content; } pre.language-javascript { @@ -43,11 +67,6 @@ pre.language-javascript a:hover { text-decoration: none; } -div.card { - border: none; - overflow-y: auto; -} - div.input-group > select { width: 100%; } @@ -57,14 +76,6 @@ span.input-group-btn > button.btn.btn-primary.btn-sm { border-radius: 0; } -div.col-sm-8 > .card { - height: 100%; -} - -div.col-sm-4 > .card { - height: 50%; -} - div#audioinputwaveform { position: absolute; left: 20px; @@ -83,7 +94,8 @@ div#audioinputwaveform > canvas { } video#videoinputpreview { - width: 100%; + max-width: 100% !important; + max-height: 80% !important; background-color: lightgrey !important; background-image: url('https://static0.twilio.com/marketing/bundles/archetype/img/logo-wordmark.svg'); background-position: 50%; diff --git a/examples/mediadevices/public/index.html b/examples/mediadevices/public/index.html index 8e25f4f7..74c56487 100644 --- a/examples/mediadevices/public/index.html +++ b/examples/mediadevices/public/index.html @@ -5,29 +5,34 @@ Media Device Selection - +
-
-
+
+

Media Device Selection

-

+            
+            
+

+            
-
+

Preview Media

-
@@ -62,6 +67,15 @@

Select Media Devices

+ + +
@@ -69,5 +83,8 @@

Select Media Devices

+ + + diff --git a/examples/mediadevices/src/helpers.js b/examples/mediadevices/src/helpers.js index d9c5a074..68407b16 100644 --- a/examples/mediadevices/src/helpers.js +++ b/examples/mediadevices/src/helpers.js @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ 'use strict'; var Video = require('twilio-video'); @@ -15,41 +16,53 @@ function getDevicesOfKind(deviceInfos, kind) { } /** - * Apply the selected audio input device. + * Apply the selected audio output device. * @param {string} deviceId * @param {HTMLAudioElement} audio * @returns {Promise} */ -function applyAudioInputDeviceSelection(deviceId, audio) { - return Video.createLocalAudioTrack({ - deviceId: deviceId - }).then(function(localTrack) { - localTrack.attach(audio); - }); +function applyAudioOutputDeviceSelection(deviceId, audio) { + return typeof audio.setSinkId === 'function' + ? audio.setSinkId(deviceId) + : Promise.reject('This browser does not support setting an audio output device'); } /** - * Apply the selected audio output device. + * Apply the selected input device. * @param {string} deviceId - * @param {HTMLAudioElement} audio + * @param {?LocalTrack} localTrack - LocalAudioTrack or LocalVideoTrack; if null, a new LocalTrack will be created. + * @param {'audio' | 'video'} kind + * @returns {Promise} - The created or restarted LocalTrack */ -function applyAudioOutputDeviceSelection(deviceId, audio) { - audio.setSinkId(deviceId); +function applyInputDeviceSelection(deviceId, localTrack, kind) { + var constraints = { deviceId: { exact: deviceId } }; + if (localTrack) { + return localTrack.restart(constraints).then(function() { + return localTrack; + }); + } + return kind === 'audio' + ? Video.createLocalAudioTrack(constraints) + : Video.createLocalVideoTrack(constraints); } /** - * Apply the selected video input device. - * @param {string} deviceId - * @param {HTMLVideoElement} video + * Ensure that media permissions are obtained. * @returns {Promise} */ -function applyVideoInputDeviceSelection(deviceId, video) { - return Video.createLocalVideoTrack({ - deviceId: deviceId, - height: 240, - width: 320 - }).then(function(localTrack) { - localTrack.attach(video); +function ensureMediaPermissions() { + return navigator.mediaDevices.enumerateDevices().then(function(devices) { + return devices.every(function(device) { + return !(device.deviceId && device.label); + }); + }).then(function(shouldAskForMediaPermissions) { + if (shouldAskForMediaPermissions) { + return navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then(function(mediaStream) { + mediaStream.getTracks().forEach(function(track) { + track.stop(); + }); + }); + } }); } @@ -62,16 +75,19 @@ function applyVideoInputDeviceSelection(deviceId, video) { * @property {Array} videoinput */ function getDeviceSelectionOptions() { - return navigator.mediaDevices.enumerateDevices().then(function(deviceInfos) { - var kinds = [ 'audioinput', 'audiooutput', 'videoinput' ]; - return kinds.reduce(function(deviceSelectionOptions, kind) { - deviceSelectionOptions[kind] = getDevicesOfKind(deviceInfos, kind); - return deviceSelectionOptions; - }, {}); + // before calling enumerateDevices, get media permissions (.getUserMedia) + // w/o media permissions, browsers do not return device Ids and/or labels. + return ensureMediaPermissions().then(function() { + return navigator.mediaDevices.enumerateDevices().then(function(deviceInfos) { + var kinds = ['audioinput', 'audiooutput', 'videoinput']; + return kinds.reduce(function(deviceSelectionOptions, kind) { + deviceSelectionOptions[kind] = getDevicesOfKind(deviceInfos, kind); + return deviceSelectionOptions; + }, {}); + }); }); } -module.exports.applyAudioInputDeviceSelection = applyAudioInputDeviceSelection; +module.exports.applyInputDeviceSelection = applyInputDeviceSelection; module.exports.applyAudioOutputDeviceSelection = applyAudioOutputDeviceSelection; -module.exports.applyVideoInputDeviceSelection = applyVideoInputDeviceSelection; module.exports.getDeviceSelectionOptions = getDeviceSelectionOptions; diff --git a/examples/mediadevices/src/index.js b/examples/mediadevices/src/index.js index 0de99a30..cc8637ba 100644 --- a/examples/mediadevices/src/index.js +++ b/examples/mediadevices/src/index.js @@ -1,12 +1,22 @@ 'use strict'; +var Video = require('twilio-video'); var Prism = require('prismjs'); var getSnippet = require('../../util/getsnippet'); var helpers = require('./helpers'); var waveform = require('../../util/waveform'); -var applyAudioInputDeviceSelection = helpers.applyAudioInputDeviceSelection; +var applyInputDeviceSelection = helpers.applyInputDeviceSelection; var applyAudioOutputDeviceSelection = helpers.applyAudioOutputDeviceSelection; -var applyVideoInputDeviceSelection = helpers.applyVideoInputDeviceSelection; +const connectOrDisconnect = document.querySelector('input#connectordisconnect'); +const getRoomCredentials = require('../../util/getroomcredentials'); +const mediaContainer = document.getElementById('remote-media'); +const joinRoomBlock = document.querySelector('#joinRoom'); +const roomNameText = document.querySelector('#roomName'); +let someRoom = null; +let localAudioTrack = null; +let localVideoTrack = null; +let deviceId; + var getDeviceSelectionOptions = helpers.getDeviceSelectionOptions; var deviceSelections = { @@ -19,28 +29,186 @@ var deviceSelections = { * Build the list of available media devices. */ function updateDeviceSelectionOptions() { - getDeviceSelectionOptions().then(function (deviceSelectionOptions) { - ['audioinput', 'audiooutput', 'videoinput'].forEach(function(kind) { - var kindDeviceInfos = deviceSelectionOptions[kind]; - var select = deviceSelections[kind]; - - [].slice.call(select.children).forEach(function(option) { - option.remove(); - }); - - kindDeviceInfos.forEach(function(kindDeviceInfo) { - var deviceId = kindDeviceInfo.deviceId; - var label = kindDeviceInfo.label || 'Device [ id: ' - + deviceId.substr(0, 5) + '... ]'; - - var option = document.createElement('option'); - option.value = deviceId; - option.appendChild(document.createTextNode(label)); - select.appendChild(option); - }); - }); + return getDeviceSelectionOptions() + .then(function(deviceSelectionOptions) { + ['audioinput', 'audiooutput', 'videoinput'].forEach(function(kind) { + var kindDeviceInfos = deviceSelectionOptions[kind]; + var select = deviceSelections[kind]; + + [].slice.call(select.children).forEach(function(option) { + option.remove(); + }); + + kindDeviceInfos.forEach(function(kindDeviceInfo) { + var deviceId = kindDeviceInfo.deviceId; + var label = kindDeviceInfo.label || 'Device [ id: ' + + deviceId.substr(0, 5) + '... ]'; + + var option = document.createElement('option'); + option.value = deviceId; + option.appendChild(document.createTextNode(label)); + select.appendChild(option); + }); + }); + }); +} + +function updateRoomBlock(room) { + while (roomNameText.firstChild) { + roomNameText.removeChild(roomNameText.firstChild); + } + + joinRoomBlock.style.display = room ? 'block' : 'none'; + connectOrDisconnect.value = room ? 'Disconnect' : 'Connect'; + + if (room) { + roomNameText.appendChild(document.createTextNode(room.name)); + } +} + +// Attach the Track to the DOM. +function attachTrack(track, container) { + let audioEl; + if (track.kind === 'audio') { + audioEl = track.attach(); + audioEl.className = 'remote-audio' + deviceId ? applyAudioOutputDeviceSelection(deviceId, audioEl) : null; + container.appendChild(audioEl) + } else { + container.appendChild(track.attach()); + } +} + +// Detach given track from the DOM +function detachTrack(track) { + track.detach().forEach(function(element) { + element.srcObject = null; + element.remove(); + }); +} + +// A new RemoteTrack was published to the Room. +function trackPublished(publication, container) { + console.log('Track was of kind ' + publication.kind + ' was published:' + publication.isSubscribed); + if (publication.isSubscribed) { + attachTrack(publication.track, container); + } + publication.on('subscribed', function(track) { + console.log('Subscribed to ' + publication.kind + ' track'); + attachTrack(track, container); + }); + publication.on('unsubscribed', detachTrack); +} + +// A RemoteTrack was unpublished from the Room. +function trackUnpublished(publication) { + console.log(publication.kind + ' track was unpublished.'); +} +// A new RemoteParticipant joined the Room +function participantConnected(participant) { + console.log("Participant '" + participant.identity + "' joined the room"); + let participantDiv = document.getElementById(participant.sid); + if (!participantDiv) { + participantDiv = document.createElement('div'); + participantDiv.id = participant.sid; + mediaContainer.appendChild(participantDiv); + } + participant.tracks.forEach(function(publication) { + trackPublished(publication, participantDiv); }); + participant.on('trackPublished', function(publication) { + trackPublished(publication, participantDiv); + }); + participant.on('trackUnpublished', trackUnpublished); +} + +// Detach the Participant's Tracks from the DOM. +function participantDisconnected(participant) { + console.log("Participant '" + participant.identity + "' joined the room"); + const participantDiv = document.getElementById(participant.sid); + participantDiv.parentNode.removeChild(participantDiv); +} + +// reads selected audio input, and updates preview and room to use the device. +async function applyAudioInputDeviceChange(event) { + const waveformContainer = document.querySelector('div#audioinputwaveform'); + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + localAudioTrack = await applyInputDeviceSelection(deviceSelections.audioinput.value, localAudioTrack, 'audio'); + const canvas = waveformContainer.querySelector('canvas'); + waveform.setStream(new MediaStream([localAudioTrack.mediaStreamTrack])); + if (!canvas) { + waveformContainer.appendChild(waveform.element); + } + maybeEnableConnectButton(); + return localAudioTrack; +} + +// reads selected video input, and updates preview and room to use the device. +async function applyVideoInputDeviceChange(event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + const video = document.querySelector('video#videoinputpreview'); + localVideoTrack = await applyInputDeviceSelection(deviceSelections.videoinput.value, localVideoTrack, 'video'); + localVideoTrack.attach(video); + maybeEnableConnectButton(); + return localVideoTrack; +} + +// reads selected audio output, and updates preview to use the device. +function applyAudioOutputDeviceChange(event) { + if (event) { + event.preventDefault(); + } + deviceId = deviceSelections.audiooutput.value; + document.querySelectorAll('.remote-audio').forEach(audioEl => { + if (deviceId) { + // Note: not supported on safari + applyAudioOutputDeviceSelection(deviceId, audioEl) + .then(() => { + console.log(`Success, audio output device attached: ${deviceId}`); + }) + .catch(error => { + let errorMessage = error; + console.error('ERROR: ', errorMessage); + }); + } + }) +} + +function maybeEnableConnectButton() { + connectOrDisconnect.disabled = !(localAudioTrack && localVideoTrack); +} + +async function connectOrDisconnectRoom(event) { + event.preventDefault(); + event.stopPropagation(); + if (someRoom) { + someRoom.disconnect(); + someRoom = null; + } else { + const creds = await getRoomCredentials(); + someRoom = await Video.connect(creds.token, { + tracks: [localVideoTrack, localAudioTrack] + }); + + // sync the preview with connected tracks. + applyVideoInputDeviceChange(); + applyAudioInputDeviceChange(); + + someRoom.participants.forEach(participantConnected); + + // listen as participants connect/disconnect + someRoom.on('participantConnected', participantConnected); + someRoom.on('participantDisconnected', participantDisconnected); + } + updateRoomBlock(someRoom); + connectOrDisconnect.disabled = false; } // Load the code snippet. @@ -49,41 +217,32 @@ getSnippet('./helpers.js').then(function(snippet) { pre.innerHTML = Prism.highlight(snippet, Prism.languages.javascript); }); -// Build the list of available media devices. +// setup device selections updateDeviceSelectionOptions(); +// Check if there are Tracks +maybeEnableConnectButton(); + // Whenever a media device is added or removed, update the list. navigator.mediaDevices.ondevicechange = updateDeviceSelectionOptions; // Apply the selected audio input media device. -document.querySelector('button#audioinputapply').onclick = function(event) { - var audio = document.querySelector('audio#audioinputpreview'); - var waveformContainer = document.querySelector('div#audioinputwaveform'); - - applyAudioInputDeviceSelection(deviceSelections.audioinput.value, audio).then(function() { - var canvas = waveformContainer.querySelector('canvas'); - waveform.setStream(audio.srcObject); - if (!canvas) { - waveformContainer.appendChild(waveform.element); - } - }); +document.querySelector('button#audioinputapply').onclick = applyAudioInputDeviceChange; - event.preventDefault(); - event.stopPropagation(); -}; +// Apply the selected video input media device. +document.querySelector('button#videoinputapply').onclick = applyVideoInputDeviceChange; // Apply the selected audio output media device. -document.querySelector('button#audiooutputapply').onclick = function(event) { - var audio = document.querySelector('audio#audioinputpreview'); - applyAudioOutputDeviceSelection(deviceSelections.audiooutput.value, audio); - event.preventDefault(); - event.stopPropagation(); -}; +// NOTE: safari does not let us query the output device (and its HTMLAudioElement does not have setSinkId) +document.querySelector('button#audiooutputapply').onclick = applyAudioOutputDeviceChange; -// Apply the selected video input media device. -document.querySelector('button#videoinputapply').onclick = function(event) { - var video = document.querySelector('video#videoinputpreview'); - applyVideoInputDeviceSelection(deviceSelections.videoinput.value, video); - event.preventDefault(); - event.stopPropagation(); +// Connect/Disconnect the room. +connectOrDisconnect.onclick = connectOrDisconnectRoom; + +// Disconnect from the Room on page unload. +window.onbeforeunload = function() { + if (someRoom) { + someRoom.disconnect(); + someRoom = null; + } }; diff --git a/examples/networkquality/public/index.css b/examples/networkquality/public/index.css new file mode 100644 index 00000000..ebdd3ad0 --- /dev/null +++ b/examples/networkquality/public/index.css @@ -0,0 +1,146 @@ +@import url('https://fonts.googleapis.com/css?family=Roboto+Mono:300'); + +html { + height: 100%; +} + +body { + height: 100%; +} + +[data-toggle="collapse"].collapsed .if-not-collapsed { + display: none; +} + +[data-toggle="collapse"]:not(.collapsed) .if-collapsed { + display: none; +} + +.card { + border: none; + max-height: min-content; +} + +.align { + align-content: flex-start; +} + +div.container-fluid { + height: 100%; +} + +div.row { + height: 100%; +} + +div.row.thin-gutters { + margin: 0 2px 0 2px; +} + +div.row.thin-gutters > .col, +div.row.thin-gutters > [class*="col-"] { + padding: 8px 8px; +} + +div.col-sm-6, div.col-sm-6 { + height: fit-content; +} + +div#remotemedia { + display: grid; + justify-content: flex-start; + flex-wrap: wrap; + height: auto; + width: 100%; + background-color: #fff; + text-align: center; + margin: auto; +} + +div.mediadiv { + margin: 10px; + width: 200px; +} + +div#remotemedia h6 { + position: absolute; + font-size: 14px; + background-color: black; + color: white; +} + +#roomname { + padding: 2px; + border: 1px solid black; + margin: 2px; +} + +div#remotemedia video { + max-width: 100% !important; + max-height: 80% !important; + background-color: #272726; + background-repeat: no-repeat; +} + +div#remotemedia textarea { + resize: none; + width: 200px; + height: 200px; + font-size: 0.6em; + font-family: Courier, sans-serif; +} + +div#usercontrols { + display: grid; + flex-direction: column; + flex-wrap: wrap; + width: 100%; + padding: 5px 10px; +} + +div.usercontrol { + margin: 5px; + border: 1px solid black; + padding: 10px; +} + +div.usercontrol button { + float: right; +} + +pre.language-javascript { + font-family: 'Roboto Mono', monospace; + font-size: 13px; +} + +pre.language-javascript a { + color: aquamarine; + text-decoration: underline; +} + +pre.language-javascript a:hover { + text-decoration: none; +} + +div.input-group > select { + width: 100%; +} + +@media (max-width: 900px) { + div.col-sm-8, div.col-sm-4 { + max-width: 100%; + flex: 100%; + } + + div.col-sm-8 { + height: 40%; + } + + div.col-sm-4 { + height: 60%; + } + + pre.language-javascript { + font-size: 12px; + } +} diff --git a/examples/networkquality/public/index.html b/examples/networkquality/public/index.html new file mode 100644 index 00000000..d9afc034 --- /dev/null +++ b/examples/networkquality/public/index.html @@ -0,0 +1,81 @@ + + + + + + Network Quality + + + + + +
+
+
+
+
+

+ Network Quality +

+ +
+

+            
+
+
+
+
+
+
+

Set Network Quality Verbosity Levels

+
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+
+
+ +
+
+
+
+
+ + + + + + diff --git a/examples/networkquality/public/prism.css b/examples/networkquality/public/prism.css new file mode 100644 index 00000000..7e2569ab --- /dev/null +++ b/examples/networkquality/public/prism.css @@ -0,0 +1,123 @@ +/* http://prismjs.com/download.html?themes=prism-okaidia&languages=markup+css+clike+javascript */ +/** + * okaidia theme for JavaScript, CSS and HTML + * Loosely based on Monokai textmate theme by http://www.monokai.nl/ + * @author ocodia + */ + +code[class*="language-"], +pre[class*="language-"] { + color: #f8f8f2; + background: none; + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: .5em 0; + overflow: auto; + border-radius: 0.3em; +} + +:not(pre) > code[class*="language-"], +pre[class*="language-"] { + background: #272822; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: .1em; + border-radius: .3em; + white-space: normal; +} + +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: #87ceeb; +} + +.token.operator, +.token.punctuation { + color: #ff5555; +} + +.namespace { + opacity: .7; +} + +.token.property, +.token.tag, +.token.constant, +.token.symbol, +.token.deleted { + color: #f92672; +} + +.token.boolean { + color: #55ff55; +} + +.token.number { + color: #cd5c5c; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #ff55ff; +} + +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string, +.token.variable { + color: #ff55ff; +} + +.token.function { + color: #ccc; +} + +.token.keyword { + color: #55ff55; +} + +.token.regex, +.token.important { + color: #fd971f; +} + +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} diff --git a/examples/networkquality/src/helpers.js b/examples/networkquality/src/helpers.js new file mode 100644 index 00000000..df13a05e --- /dev/null +++ b/examples/networkquality/src/helpers.js @@ -0,0 +1,79 @@ +'use strict'; + +var Video = require('twilio-video'); + +/** + * Connect to a Room with the Network Quality API enabled. + * This API is available only in Small Group or Group Rooms. + * @param {string} token - Token for joining the Room + * @param {number} localVerbosity - Verbosity level of Network Quality reports + * for the LocalParticipant [1 - 3] + * @param {number} remoteVerbosity - Verbosity level of Network Quality reports + * for the RemoteParticipant(s) [0 - 3] + * @returns {CancelablePromise} + */ +function connectToRoomWithNetworkQuality(token, localVerbosity, remoteVerbosity) { + return Video.connect(token, { + networkQuality: { + local: localVerbosity, + remote: remoteVerbosity + } + }); +} + +/** + * Listen to changes in the Network Quality report of a Participant and update + * your application. + * @param {Participant} participant - The Participant whose updates you want to listen to + * @param {function} updateNetworkQualityReport - Updates the app UI with the new + * Network Quality report of the Participant. + * @returns {void} + */ +function setupNetworkQualityUpdatesForParticipant(participant, updateNetworkQualityReport) { + updateNetworkQualityReport(participant); + participant.on('networkQualityLevelChanged', function() { + updateNetworkQualityReport(participant); + }); +} + +/** + * Listen to changes in the Network Quality reports and update your application. + * @param {Room} room - The Room you just joined + * @param {function} updateNetworkQualityReport - Updates the app UI with the new + * Network Quality report of a Participant. + * @returns {void} + */ +function setupNetworkQualityUpdates(room, updateNetworkQualityReport) { + // Listen to changes in Network Quality level of the LocalParticipant. + setupNetworkQualityUpdatesForParticipant(room.localParticipant, updateNetworkQualityReport); + // Listen to changes in Network Quality levels of RemoteParticipants already + // in the Room. + room.participants.forEach(function(participant) { + setupNetworkQualityUpdatesForParticipant(participant, updateNetworkQualityReport); + }); + // Listen to changes in Network Quality levels of RemoteParticipants that will + // join the Room in the future. + room.on('participantConnected', function(participant) { + setupNetworkQualityUpdatesForParticipant(participant, updateNetworkQualityReport); + }); +} + +/** + * Change the local and remote Network Quality verbosity levels after joining the Room. + * @param {Room} room - The Room you just joined + * @param {number} localVerbosity - Verbosity level of Network Quality reports + * for the LocalParticipant [1 - 3] + * @param {number} remoteVerbosity - Verbosity level of Network Quality reports + * for the RemoteParticipant(s) [0 - 3] + * @returns {void} + */ +function setNetworkQualityConfiguration(room, localVerbosity, remoteVerbosity) { + room.localParticipant.setNetworkQualityConfiguration({ + local: localVerbosity, + remote: remoteVerbosity + }); +} + +exports.connectToRoomWithNetworkQuality = connectToRoomWithNetworkQuality; +exports.setupNetworkQualityUpdates = setupNetworkQualityUpdates; +exports.setNetworkQualityConfiguration = setNetworkQualityConfiguration; diff --git a/examples/networkquality/src/index.js b/examples/networkquality/src/index.js new file mode 100644 index 00000000..3f0a0ace --- /dev/null +++ b/examples/networkquality/src/index.js @@ -0,0 +1,229 @@ +'use strict'; + +const Prism = require('prismjs'); +const Video = require('twilio-video'); +const getRoomCredentials = require('../../util/getroomcredentials'); +const getSnippet = require('../../util/getsnippet'); +const helpers = require('./helpers'); +const connectToRoomWithNetworkQuality = helpers.connectToRoomWithNetworkQuality; +const setupNetworkQualityUpdates = helpers.setupNetworkQualityUpdates; +const setNetworkQualityConfiguration = helpers.setNetworkQualityConfiguration; + +const joinRoomBlock = document.getElementById('joinroom'); +const roomNameText = document.getElementById('roomname'); +const mediaContainer = document.getElementById('remotemedia'); +const userControls = document.getElementById('usercontrols'); +const setupRoomBtn = document.getElementById('setuproom'); +const localVerbosity = document.getElementById('local'); +const remoteVerbosity = document.getElementById('remote'); + +let roomName = null; +let rooms = new Set(); +let someRoom = null; + +/** + * creates a button and add to given container. + */ +function createButton(text, container) { + const btn = document.createElement('button'); + btn.innerHTML = text; + btn.classList.add('btn', 'btn-outline-primary', 'btn-sm'); + container.appendChild(btn); + return btn; +} + +/** + * Creates controls for additional users to connect/disconnect from the Room. + */ +async function createUserControls(userIdentity) { + const creds = await getRoomCredentials(userIdentity); + let room = null; + + const currentUserControls = document.createElement('div'); + currentUserControls.classList.add('usercontrol'); + + const title = document.createElement('span'); + title.innerText = creds.identity; + currentUserControls.appendChild(title); + + // connect button + const connectDisconnect = createButton('Connect', currentUserControls); + connectDisconnect.onclick = async function() { + connectDisconnect.disabled = true; + const connected = room !== null; + if (connected) { + room.disconnect(); + rooms.delete(room); + room = null; + } else { + room = await connectToRoom(creds); + rooms.add(room); + } + connectDisconnect.innerHTML = connected ? 'Connect' : 'Disconnect'; + connectDisconnect.disabled = false; + }; + userControls.appendChild(currentUserControls); +} + +/** + * Clear the user controls. + */ +function clearUserControls() { + userControls.querySelectorAll('.usercontrol').forEach(function(controls) { + controls.remove(); + }); +} + +/** + * Connect the Participant with media to the Room. + */ +function connectToRoom(creds) { + return Video.connect(creds.token, { + name: roomName + }); +} + +/** + * Get the Tracks of the given Participant. + */ +function getTracks(participant) { + return Array.from(participant.tracks.values()).filter(function(publication) { + return publication.track; + }).map(function(publication) { + return publication.track; + }); +} + +/** + * Show the UI for the given Participant. + */ +function showParticipant(participant, isRemote) { + const participantDiv = document.createElement('div'); + participantDiv.id = participant.sid; + const mediaDiv = document.createElement('div'); + mediaDiv.classList.add('mediadiv'); + + const title = document.createElement('h6'); + mediaDiv.appendChild(title); + participantDiv.appendChild(mediaDiv); + + const stats = document.createElement('textarea'); + stats.setAttribute('readonly', 'true'); + participantDiv.appendChild(stats); + + mediaContainer.appendChild(participantDiv); + updateNetworkQualityReport(participant); + + if (isRemote) { + participant.on('trackSubscribed', function(track) { + mediaDiv.appendChild(track.attach()); + }); + } else { + getTracks(participant).forEach(function(track) { + mediaDiv.appendChild(track.attach()); + }); + } +} + +/** + * Remove a Participant's UI. + */ +function removeParticipant(participant) { + const participantDiv = document.getElementById(participant.sid); + participantDiv.parentNode.removeChild(participantDiv); +} + +/** + * Updates the Network Quality report for a Participant. + */ +function updateNetworkQualityReport(participant) { + const participantDiv = document.getElementById(participant.sid); + const title = participantDiv.querySelector('h6'); + title.innerHTML = `NQ Level (${participant.identity}): ${participant.networkQualityLevel}`; + const stats = participantDiv.querySelector('textarea'); + stats.value = `NQ Stats:\r\n========\r\n${JSON.stringify(participant.networkQualityStats, null, 2)}`; +} + +/** + * Set up the Room. + */ +async function setupRoom(e) { + e.preventDefault(); + + // Get the credentials to connect to the Room. + const creds = await getRoomCredentials('You'); + + // Connect to a random Room with no media. This Participant will + // display the media of the other Participants that will enter + // the Room and watch for Network Quality updates. + someRoom = await connectToRoomWithNetworkQuality( + creds.token, + parseInt(localVerbosity.value, 10), + parseInt(remoteVerbosity.value, 10)); + + showParticipant(someRoom.localParticipant, false); + + // Set the name of the Room to which the Participant that shares + // media should join. + joinRoomBlock.style.display = 'block'; + roomName = someRoom.name; + roomNameText.innerText = roomName; + + // Listen for changes in verbosity levels and update the Room's Network Quality + // Configuration. + localVerbosity.onchange = remoteVerbosity.onchange = function() { + setNetworkQualityConfiguration( + someRoom, + parseInt(localVerbosity.value, 10), + parseInt(remoteVerbosity.value, 10)); + }; + + // Convert the "Create Room" button to a "Leave Room" button. + setupRoomBtn.onclick = teardownRoom; + setupRoomBtn.value = 'Leave Room'; + + // create controls to connect few users + ['Alice', 'Bob', 'Charlie', 'Mak'].forEach(createUserControls); + + someRoom.on('participantConnected', function(participant) { + showParticipant(participant, true); + }); + + someRoom.on('participantDisconnected', removeParticipant); + setupNetworkQualityUpdates(someRoom, updateNetworkQualityReport); + + // Disconnect from the Room on page unload. + window.onbeforeunload = teardownRoom; +} + +/** + * Tear down the Room. + */ +function teardownRoom(e) { + e.preventDefault(); + if (someRoom) { + someRoom.participants.forEach(removeParticipant); + removeParticipant(someRoom.localParticipant); + clearUserControls(); + someRoom.disconnect(); + someRoom = null; + joinRoomBlock.style.display = 'none'; + roomName = ''; + roomNameText.innerText = ''; + setupRoomBtn.onclick = setupRoom; + setupRoomBtn.value = 'Create Room'; + localVerbosity.onchange = remoteVerbosity.onchange = null; + } + rooms.forEach(function(room) { + room.disconnect(); + rooms.delete(room); + }); +} + +(async function() { + // Load the code snippet. + const snippet = await getSnippet('./helpers.js'); + const pre = document.querySelector('pre.language-javascript'); + pre.innerHTML = Prism.highlight(snippet, Prism.languages.javascript); + setupRoomBtn.onclick = setupRoom; +}()); diff --git a/examples/reconnection/public/index.css b/examples/reconnection/public/index.css new file mode 100644 index 00000000..fa87928c --- /dev/null +++ b/examples/reconnection/public/index.css @@ -0,0 +1,125 @@ +@import url('https://fonts.googleapis.com/css?family=Roboto+Mono:300'); + +html { + height: 100%; +} + +body { + height: 100%; +} + +[data-toggle="collapse"].collapsed .if-not-collapsed { + display: none; +} + +[data-toggle="collapse"]:not(.collapsed) .if-collapsed { + display: none; +} + +.align { + align-content: flex-start; +} + +div.container-fluid { + height: 100%; +} + +div.row { + height: 100%; +} + +div.row.thin-gutters { + margin: 0 2px 0 2px; +} + +div.row.thin-gutters > .col, +div.row.thin-gutters > [class*="col-"] { + padding: 8px 8px; +} + +div.col-sm-6, div.col-sm-6 { + max-height: fit-content; +} + +div#remote-media { + height: 50%; + width: 100%; + background-color: #fff; + text-align: center; + margin: auto; +} + +div#remote-media video { + margin: 1em; + max-width: 80% !important; + max-height: 80% !important; + background-color: #272726; + background-repeat: no-repeat; +} + +div.roomstate { + background: white; + margin: 2px; + color: black; + border: solid 1px black; + padding: 20px; + margin: 10px; +} + +div.roomstate.unknown.current { + background:gray; +} + +div.roomstate.connected.current { + background: green; +} + +div.roomstate.disconnected.current { + background:red; +} + +div.roomstate.reconnecting.current { + background: yellow; +} + +pre.language-javascript { + font-family: 'Roboto Mono', monospace; + font-size: 13px; +} + +pre.language-javascript a { + color: aquamarine; + text-decoration: underline; +} + +pre.language-javascript a:hover { + text-decoration: none; +} + +div.card { + border: none; + overflow-y: auto; +} + +div.input-group > select { + width: 100%; +} + +@media (max-width: 900px) { + div.col-sm-8, div.col-sm-4 { + max-width: 100%; + flex: 100%; + } + + div.col-sm-8 { + height: 40%; + } + + div.col-sm-4 { + height: 60%; + } + + pre.language-javascript { + font-size: 12px; + } +} diff --git a/examples/reconnection/public/index.html b/examples/reconnection/public/index.html new file mode 100644 index 00000000..d666e82c --- /dev/null +++ b/examples/reconnection/public/index.html @@ -0,0 +1,58 @@ + + + + + + Reconnection States and Events + + + + + +
+
+
+
+
+

+ Reconnection States and Events +

+ +
+

+            
+
+
+
+
+
+

Room state is:

+
+
Connected
+
Disconnected
+
Reconnecting
+ +

+ After you have created the Room, please either turn off your internet network for a little while + and then turn it back on, or switch between networks. The Twilio Video SDK will try to re-establish + connection to the Room, which will transition to the "connecting" state. Once reconnection is complete, + the Room will transition back to the "connected" state. + + You can join other user to the room using button below. +

+ +
+
+
+
+
+
+ + + + + + diff --git a/examples/reconnection/public/prism.css b/examples/reconnection/public/prism.css new file mode 100644 index 00000000..7e2569ab --- /dev/null +++ b/examples/reconnection/public/prism.css @@ -0,0 +1,123 @@ +/* http://prismjs.com/download.html?themes=prism-okaidia&languages=markup+css+clike+javascript */ +/** + * okaidia theme for JavaScript, CSS and HTML + * Loosely based on Monokai textmate theme by http://www.monokai.nl/ + * @author ocodia + */ + +code[class*="language-"], +pre[class*="language-"] { + color: #f8f8f2; + background: none; + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: .5em 0; + overflow: auto; + border-radius: 0.3em; +} + +:not(pre) > code[class*="language-"], +pre[class*="language-"] { + background: #272822; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: .1em; + border-radius: .3em; + white-space: normal; +} + +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: #87ceeb; +} + +.token.operator, +.token.punctuation { + color: #ff5555; +} + +.namespace { + opacity: .7; +} + +.token.property, +.token.tag, +.token.constant, +.token.symbol, +.token.deleted { + color: #f92672; +} + +.token.boolean { + color: #55ff55; +} + +.token.number { + color: #cd5c5c; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #ff55ff; +} + +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string, +.token.variable { + color: #ff55ff; +} + +.token.function { + color: #ccc; +} + +.token.keyword { + color: #55ff55; +} + +.token.regex, +.token.important { + color: #fd971f; +} + +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} diff --git a/examples/reconnection/src/helpers.js b/examples/reconnection/src/helpers.js new file mode 100644 index 00000000..7a27d51d --- /dev/null +++ b/examples/reconnection/src/helpers.js @@ -0,0 +1,36 @@ +'use strict'; + +/** + * Listen to Room reconnection events and update the UI accordingly. + * @param {Room} room - The Room you have joined + * @param {function} updateRoomState - Updates the app UI with the new Room state + * @returns {void} + */ +function setupReconnectionUpdates(room, updateRoomState) { + room.on('disconnected', (room, error) => { + if (error.code === 20104) { + console.log('Signaling reconnection failed due to expired AccessToken!'); + } else if (error.code === 53000) { + console.log('Signaling reconnection attempts exhausted!'); + } else if (error.code === 53204) { + console.log('Signaling reconnection took too long!'); + } + updateRoomState(room.state); + }); + + room.on('reconnected', function() { + console.log('Reconnected to the Room!'); + updateRoomState(room.state); + }); + + room.on('reconnecting', function(error) { + if (error.code === 53001) { + console.log('Reconnecting your signaling connection!', error.message); + } else if (error.code === 53405) { + console.log('Reconnecting your media connection!', error.message); + } + updateRoomState(room.state); + }); +} + +exports.setupReconnectionUpdates = setupReconnectionUpdates; diff --git a/examples/reconnection/src/index.js b/examples/reconnection/src/index.js new file mode 100644 index 00000000..e88a1d6d --- /dev/null +++ b/examples/reconnection/src/index.js @@ -0,0 +1,129 @@ +'use strict'; + +const Prism = require('prismjs'); +const Video = require('twilio-video'); +const getRoomCredentials = require('../../util/getroomcredentials'); +const getSnippet = require('../../util/getsnippet'); +const helpers = require('./helpers'); +const setupReconnectionUpdates = helpers.setupReconnectionUpdates; +const connectOrDisconnect = document.querySelector('input#connectordisconnect'); +const createRoomBtn = document.querySelector('input#createRoom'); + +const mediaContainer = document.getElementById('remote-media'); +let roomName = null; +let room = null; +let someRoom = null; + +/** + * Connect the Participant with media to the Room. + */ +async function connectToRoom() { + const creds = await getRoomCredentials(); + room = await Video.connect( creds.token, { + name: roomName + }); + connectOrDisconnect.value = 'Leave Room'; +} + +/** + * Disconnect the Participant with media from the Room. + */ +function disconnectFromRoom() { + room.disconnect(); + room = null; + connectOrDisconnect.value = 'Join Room'; + return; +} + +function connectToOrDisconnectFromRoom(event) { + event.preventDefault(); + return room ? disconnectFromRoom() : connectToRoom(); +} + +/** + * update the UI to indicate room state. + */ +function onRoomStateChange(newState) { + const oldStateBtn = document.querySelector('div.current'); + if (oldStateBtn) { + oldStateBtn.classList.remove('current'); + } + + const newStateBtn = document.querySelector('div.' + newState); + newStateBtn.classList.add('current'); + + if (newState === 'disconnected') { + // once disconnected room needs to be recreated. + cleanupRoom(); + } +} + +function cleanupRoom() { + roomName = null; + someRoom = null; + createRoomBtn.disabled = false; + connectOrDisconnect.disabled = true; + connectOrDisconnect.value = 'Join Room'; + + // remove all participant media nodes. + while (mediaContainer.firstChild) { + mediaContainer.removeChild(mediaContainer.firstChild); + } +} + +async function setupRoom() { + try { + // Get the credentials to connect to the Room. + createRoomBtn.disabled = true; + const creds = await getRoomCredentials(); + + // Connect to a random Room with no media. This Participant will + // display the media of the other Participants that will enter + // the Room and watch for reconnection updates. + someRoom = await Video.connect(creds.token, { tracks: [] }); + setupReconnectionUpdates(someRoom, onRoomStateChange); + onRoomStateChange(someRoom.state); + + // Set the name of the Room to which the Participant that shares + // media should join. + roomName = someRoom.name; + + // set listener to connect new user to the room. + connectOrDisconnect.disabled = false; + connectOrDisconnect.onclick = connectToOrDisconnectFromRoom; + + // Disconnect from the Room on page unload. + window.onbeforeunload = function() { + someRoom.disconnect(); + }; + + someRoom.on('participantConnected', function(participant) { + const div = document.createElement('div'); + div.id = participant.sid; + mediaContainer.appendChild(div); + participant.on('trackSubscribed', function(track) { + div.appendChild(track.attach()); + }); + }); + + someRoom.on('participantDisconnected', function(participant) { + const participantDiv = document.getElementById(participant.sid); + participantDiv.parentNode.removeChild(participantDiv); + }); + } catch (error) { + console.log("Error while setting up room - was network turned off?", error); + cleanupRoom(); + } +} + +(async function() { + // Load the code snippet. + const snippet = await getSnippet('./helpers.js'); + const pre = document.querySelector('pre.language-javascript'); + + pre.innerHTML = Prism.highlight(snippet, Prism.languages.javascript); + + // set listener to create new room. + createRoomBtn.onclick = setupRoom; + cleanupRoom(); +}()); diff --git a/examples/remotereconnection/public/index.css b/examples/remotereconnection/public/index.css new file mode 100644 index 00000000..27ff2fd3 --- /dev/null +++ b/examples/remotereconnection/public/index.css @@ -0,0 +1,121 @@ +@import url('https://fonts.googleapis.com/css?family=Roboto+Mono:300'); + +html { + height: 100%; +} + +body { + height: 100%; +} + +[data-toggle="collapse"].collapsed .if-not-collapsed { + display: none; +} + +[data-toggle="collapse"]:not(.collapsed) .if-collapsed { + display: none; +} + +.card { + border: none; + max-height: min-content; +} + +.align { + align-content: flex-start; +} + +div.container-fluid { + height: 100%; +} + +div.row { + height: 100%; +} + +div.row.thin-gutters { + margin: 0 2px 0 2px; +} + +div.row.thin-gutters > .col, +div.row.thin-gutters > [class*="col-"] { + padding: 8px 8px; +} + +div.col-sm-6, div.col-sm-6 { + max-height: fit-content; +} + +div.roomstate { + background: white; + margin: 2px; + color: black; + border: solid 1px black; + padding: 20px; + margin: 10px; +} + +div.roomstate.unknown.current { + background:gray; +} + +div.roomstate.connected.current { + background: green; +} + +div.roomstate.reconnecting.current { + background: yellow; +} + +div#p1-media, +div#p2-media { + height: 50%; + width: 100%; + background-color: #fff; + text-align: center; + margin: auto; +} + +div#p1-media video, +div#p2-media video { + margin: 1em; + max-width: 80% !important; + max-height: 60% !important; + background-color: lightgrey !important; + background-image: url('https://static0.twilio.com/marketing/bundles/archetype/img/logo-wordmark.svg'); + background-position: 50%; + background-repeat: no-repeat; +} + +pre.language-javascript { + font-family: 'Roboto Mono', monospace; + font-size: 13px; +} + +pre.language-javascript a { + color: aquamarine; + text-decoration: underline; +} + +pre.language-javascript a:hover { + text-decoration: none; +} + +@media (max-width: 900px) { + div.col-sm-8, div.col-sm-4 { + max-width: 100%; + flex: 100%; + } + + div.col-sm-8 { + height: 40%; + } + + div.col-sm-4 { + height: 60%; + } + + pre.language-javascript { + font-size: 12px; + } +} diff --git a/examples/remotereconnection/public/index.html b/examples/remotereconnection/public/index.html new file mode 100644 index 00000000..1428121b --- /dev/null +++ b/examples/remotereconnection/public/index.html @@ -0,0 +1,66 @@ + + + + + + RemoteParticipant Reconnection States + + + + + +
+
+
+
+
+

+ Remote Participant Reconnection States +

+ +
+

+            
+
+
+
+
+
+
+

Local View

+
+
+ +
State
+
+
Connected
+
Reconnecting
+
+
+
+
+
+
+

Remote View

+
+
+ +
State
+
+
Connected
+
Reconnecting
+
+
+
+
+
+
+ + + + + + diff --git a/examples/remotereconnection/public/prism.css b/examples/remotereconnection/public/prism.css new file mode 100644 index 00000000..d8d7c2f3 --- /dev/null +++ b/examples/remotereconnection/public/prism.css @@ -0,0 +1,124 @@ +/* http://prismjs.com/download.html?themes=prism-okaidia&languages=markup+css+clike+javascript */ +/** + * okaidia theme for JavaScript, CSS and HTML + * Loosely based on Monokai textmate theme by http://www.monokai.nl/ + * @author ocodia + */ + + code[class*="language-"], + pre[class*="language-"] { + color: #f8f8f2; + background: none; + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; + } + + /* Code blocks */ + pre[class*="language-"] { + padding: 1em; + margin: .5em 0; + overflow: auto; + border-radius: 0.3em; + } + + :not(pre) > code[class*="language-"], + pre[class*="language-"] { + background: #272822; + } + + /* Inline code */ + :not(pre) > code[class*="language-"] { + padding: .1em; + border-radius: .3em; + white-space: normal; + } + + .token.comment, + .token.prolog, + .token.doctype, + .token.cdata { + color: #87ceeb; + } + + .token.operator, + .token.punctuation { + color: #ff5555; + } + + .namespace { + opacity: .7; + } + + .token.property, + .token.tag, + .token.constant, + .token.symbol, + .token.deleted { + color: #f92672; + } + + .token.boolean { + color: #55ff55; + } + + .token.number { + color: #cd5c5c; + } + + .token.selector, + .token.attr-name, + .token.string, + .token.char, + .token.builtin, + .token.inserted { + color: #ff55ff; + } + + .token.entity, + .token.url, + .language-css .token.string, + .style .token.string, + .token.variable { + color: #ff55ff; + } + + .token.function { + color: #ccc; + } + + .token.keyword { + color: #55ff55; + } + + .token.regex, + .token.important { + color: #fd971f; + } + + .token.important, + .token.bold { + font-weight: bold; + } + .token.italic { + font-style: italic; + } + + .token.entity { + cursor: help; + } + \ No newline at end of file diff --git a/examples/remotereconnection/src/helpers.js b/examples/remotereconnection/src/helpers.js new file mode 100644 index 00000000..a6eb1994 --- /dev/null +++ b/examples/remotereconnection/src/helpers.js @@ -0,0 +1,38 @@ +'use strict'; + +/** + * Listen to RemoteParticipant reconnection events and update the UI accordingly. + * @param {Room} room - The Room you have joined + * @param {function} updateRoomState - Updates the app UI with the new state + * @returns {void} + */ +function handleRemoteParticipantReconnectionUpdates(room, updateParticipantState) { + room.on('participantReconnecting', function(participant) { + updateParticipantState(participant.state); + }); + + room.on('participantReconnected', function(participant) { + updateParticipantState(participant.state); + }); +} + +/** + * Listen to LocalParticipant reconnection events and update the UI accordingly. + * @param {Room} room - The Room you have joined + * @param {function} updateRoomState - Updates the app UI with the new state + * @returns {void} + */ +function handleLocalParticipantReconnectionUpdates(room, updateParticipantState) { + const localParticipant = room.localParticipant; + + localParticipant.on('reconnecting', function() { + updateParticipantState(localParticipant.state); + }); + + localParticipant.on('reconnected', function() { + updateParticipantState(localParticipant.state); + }); +} + +exports.handleLocalParticipantReconnectionUpdates = handleLocalParticipantReconnectionUpdates; +exports.handleRemoteParticipantReconnectionUpdates = handleRemoteParticipantReconnectionUpdates; diff --git a/examples/remotereconnection/src/index.js b/examples/remotereconnection/src/index.js new file mode 100644 index 00000000..bfb9aba7 --- /dev/null +++ b/examples/remotereconnection/src/index.js @@ -0,0 +1,100 @@ +'use strict'; + +const Prism = require('prismjs'); +const Video = require('twilio-video'); +const getSnippet = require('../../util/getsnippet'); +const getRoomCredentials = require('../../util/getroomcredentials'); +const helpers = require('./helpers'); +const handleLocalParticipantReconnectionUpdates = helpers.handleLocalParticipantReconnectionUpdates; +const handleRemoteParticipantReconnectionUpdates = helpers.handleRemoteParticipantReconnectionUpdates; + +const p1Media = document.getElementById('p1-media'); +const p2Media = document.getElementById('p2-media'); +const P1simulateReconnection = document.getElementById('p1-simulate-reconnection'); +const P2simulateReconnection = document.getElementById('p2-simulate-reconnection'); + +// Update UI to indicate remote side room state changes +const onRoomStateChange = (participant, newState) => { + const oldRoomState = document.querySelector(`#${participant} div.current`) + if (oldRoomState) { + oldRoomState.classList.remove('current'); + } + + const newRoomState = document.querySelector(`#${participant} div.${newState}`) + newRoomState.classList.add('current'); +} + +//Get the Tracks of the given Participant. +function getTracks(participant) { + return Array.from(participant.tracks.values()).filter(function(publication) { + return publication.track; + }).map(function(publication) { + return publication.track; + }); +} + +(async function() { + // Load the code snippet. + const snippet = await getSnippet('./helpers.js'); + const pre = document.querySelector('pre.language-javascript'); + + pre.innerHTML = Prism.highlight(snippet, Prism.languages.javascript); + + // Get the credentials to connect to the Room. + const credsP1 = await getRoomCredentials(); + const credsP2 = await getRoomCredentials(); + + // Create Local Tracks + const localTracks = await Video.createLocalTracks(); + + // Create room instance and name for participants to join. + const roomP1 = await Video.connect(credsP1.token, { + region: 'au1', + tracks: localTracks, + }); + + // Set room name for participant 2 to join. + const roomName = roomP1.name; + + // Appends video/audio tracks when LocalParticipant is connected. + getTracks(roomP1.localParticipant).forEach(track => { + p1Media.appendChild(track.attach()); + }); + + // Appends video/audio tracks when LocalParticipant is subscribed. + roomP1.on('trackSubscribed', track => { + p2Media.appendChild(track.attach()); + }); + + // Connecting remote participants. + const roomP2 = await Video.connect(credsP2.token, { + name: roomName, + region: 'au1', + tracks: localTracks + }); + + // Simulate reconnection button functionalities, adding in region in order to extend reconnection time + P1simulateReconnection.onclick = () => { + roomP1._signaling._transport._twilioConnection._close({ code: 4999, reason: 'simulate-reconnect'}); + } + + P2simulateReconnection.onclick = () => { + roomP2._signaling._transport._twilioConnection._close({ code: 4999, reason: 'simulate-reconnect'}); + } + + // Remote room listening on remote participant's (P1) reconnection state + handleRemoteParticipantReconnectionUpdates(roomP1, state => { + onRoomStateChange('p2', state); + }); + + handleLocalParticipantReconnectionUpdates(roomP1, state => { + onRoomStateChange('p1', state); + }); + + // Disconnect from the Room + window.onbeforeunload = () => { + roomP1.disconnect(); + roomP2.disconnect(); + roomName = null; + } +}()); diff --git a/examples/screenshare/public/index.css b/examples/screenshare/public/index.css new file mode 100644 index 00000000..f8eab111 --- /dev/null +++ b/examples/screenshare/public/index.css @@ -0,0 +1,104 @@ +@import url('https://fonts.googleapis.com/css?family=Roboto+Mono:300'); + +html { + height: 100%; +} + +body { + height: 100%; +} + +[data-toggle="collapse"].collapsed .if-not-collapsed { + display: none; +} + +[data-toggle="collapse"]:not(.collapsed) .if-collapsed { + display: none; +} + +.card { + border: none; + max-height: fit-content; + overflow-y: auto; +} + +.align { + align-content: flex-start; +} + +.card-body { + width: 640px; + max-width: fit-content; +} + +div.container-fluid { + height: 100%; +} + +div.row { + height: 100%; +} + +div.row.thin-gutters { + margin: 0 2px 0 2px; +} + +div.row.thin-gutters > .col, +div.row.thin-gutters > [class*="col-"] { + padding: 8px 8px; +} + +div.col-sm-8, div.col-sm-4 { + height: 100%; +} + +div.col-sm-6, div.col-sm-6 { + max-height: fit-content; +} + +pre.language-javascript { + font-family: 'Roboto Mono', monospace; + font-size: 13px; +} + +pre.language-javascript a { + color: aquamarine; + text-decoration: underline; +} + +pre.language-javascript a:hover { + text-decoration: none; +} + +video#screenpreview { + margin: auto; + background-color: lightgrey !important; + background-image: url('https://static0.twilio.com/marketing/bundles/archetype/img/logo-wordmark.svg'); + background-position: 50%; + background-repeat: no-repeat; + min-width: 100%; + max-width: 100%; +} + +.screenshare > .btn-block { + width:640px +} + +@media (max-width: 900px) { + div.col-sm-8, div.col-sm-4 { + max-width: 100%; + flex: 100%; + } + + div.col-sm-8 { + height: 40%; + } + + div.col-sm-4 { + height: 60%; + } + + pre.language-javascript { + font-size: 12px; + } +} diff --git a/examples/screenshare/public/index.html b/examples/screenshare/public/index.html new file mode 100644 index 00000000..20ce175f --- /dev/null +++ b/examples/screenshare/public/index.html @@ -0,0 +1,56 @@ + + + + + + + Share Your Screen + + + + + +
+
+
+
+
+

+ Share Your Screen +

+ +
+

+          
+
+
+
+
+
+
+

Local Screen

+
+ + + +
+
+
+

Remote Screen

+
+ +
+
+
+
+
+
+ + + + + + diff --git a/examples/screenshare/public/prism.css b/examples/screenshare/public/prism.css new file mode 100644 index 00000000..7e2569ab --- /dev/null +++ b/examples/screenshare/public/prism.css @@ -0,0 +1,123 @@ +/* http://prismjs.com/download.html?themes=prism-okaidia&languages=markup+css+clike+javascript */ +/** + * okaidia theme for JavaScript, CSS and HTML + * Loosely based on Monokai textmate theme by http://www.monokai.nl/ + * @author ocodia + */ + +code[class*="language-"], +pre[class*="language-"] { + color: #f8f8f2; + background: none; + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: .5em 0; + overflow: auto; + border-radius: 0.3em; +} + +:not(pre) > code[class*="language-"], +pre[class*="language-"] { + background: #272822; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: .1em; + border-radius: .3em; + white-space: normal; +} + +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: #87ceeb; +} + +.token.operator, +.token.punctuation { + color: #ff5555; +} + +.namespace { + opacity: .7; +} + +.token.property, +.token.tag, +.token.constant, +.token.symbol, +.token.deleted { + color: #f92672; +} + +.token.boolean { + color: #55ff55; +} + +.token.number { + color: #cd5c5c; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #ff55ff; +} + +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string, +.token.variable { + color: #ff55ff; +} + +.token.function { + color: #ccc; +} + +.token.keyword { + color: #55ff55; +} + +.token.regex, +.token.important { + color: #fd971f; +} + +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} diff --git a/examples/screenshare/src/helpers.js b/examples/screenshare/src/helpers.js new file mode 100644 index 00000000..c561f144 --- /dev/null +++ b/examples/screenshare/src/helpers.js @@ -0,0 +1,28 @@ +'use strict'; + +const Video = require('twilio-video'); + +/** + * Create a LocalVideoTrack for your screen. You can then share it + * with other Participants in the Room. + * @param {number} height - Desired vertical resolution in pixels + * @param {number} width - Desired horizontal resolution in pixels + * @returns {Promise} + */ +function createScreenTrack(height, width) { + if (typeof navigator === 'undefined' + || !navigator.mediaDevices + || !navigator.mediaDevices.getDisplayMedia) { + return Promise.reject(new Error('getDisplayMedia is not supported')); + } + return navigator.mediaDevices.getDisplayMedia({ + video: { + height: height, + width: width + } + }).then(function(stream) { + return new Video.LocalVideoTrack(stream.getVideoTracks()[0]); + }); +} + +exports.createScreenTrack = createScreenTrack; diff --git a/examples/screenshare/src/index.js b/examples/screenshare/src/index.js new file mode 100644 index 00000000..319550b0 --- /dev/null +++ b/examples/screenshare/src/index.js @@ -0,0 +1,120 @@ +'use strict'; + +const Prism = require('prismjs'); +const Video = require('twilio-video'); +const getSnippet = require('../../util/getsnippet'); +const getRoomCredentials = require('../../util/getroomcredentials'); +const helpers = require('./helpers'); +const createScreenTrack = helpers.createScreenTrack; +const captureScreen = document.querySelector('button#capturescreen'); +const screenPreview = document.querySelector('video#screenpreview'); +const stopScreenCapture = document.querySelector('button#stopscreencapture'); +const remoteScreenPreview = document.querySelector('video.remote-screenpreview'); + +(async function() { + // Load the code snippet. + const snippet = await getSnippet('./helpers.js'); + const pre = document.querySelector('pre.language-javascript'); + pre.innerHTML = Prism.highlight(snippet, Prism.languages.javascript); + + const logger = Video.Logger.getLogger('twilio-video'); + logger.setLevel('silent'); + + // Connect Local Participant (screen-sharer) to a room + const localCreds = await getRoomCredentials(); + const roomLocal = await Video.connect(localCreds.token, { + tracks: [] + }); + + // Connect Remote Participant (screen-viewer) to the room + const remoteCreds = await getRoomCredentials(); + const roomRemote = await Video.connect(remoteCreds.token, { + name: roomLocal.name, + tracks: [] + }); + + // Hide the "Stop Capture Screen" button. + stopScreenCapture.style.display = 'none'; + + // The LocalVideoTrack for your screen. + let screenTrack; + + captureScreen.onclick = async function() { + try { + // Create and preview your local screen. + screenTrack = await createScreenTrack(720, 1280); + screenTrack.attach(screenPreview); + + // Publish screen track to room + await roomLocal.localParticipant.publishTrack(screenTrack); + + // When screen sharing is stopped, unpublish the screen track. + screenTrack.on('stopped', () => { + if (roomLocal) { + roomLocal.localParticipant.unpublishTrack(screenTrack); + } + toggleButtons(); + }); + + // Show the "Stop Capture Screen" button. + toggleButtons(); + } catch (e) { + alert(e.message); + } + }; + + // Stop capturing your screen. + const stopScreenSharing = () => screenTrack.stop(); + + stopScreenCapture.onclick = stopScreenSharing; + + // Remote Participant handles screen share track + if(roomRemote) { + roomRemote.on('trackPublished', publication => { + onTrackPublished('publish', publication, remoteScreenPreview); + }); + + roomRemote.on('trackUnpublished', publication => { + onTrackPublished('unpublish', publication, remoteScreenPreview); + }); + } + + // Disconnect from the Room on page unload. + window.onbeforeunload = function() { + if (roomLocal) { + roomLocal.disconnect(); + roomLocal = null; + } + if (roomRemote) { + roomRemote.disconnect(); + roomRemote = null; + } + }; +}()); + +function toggleButtons() { + captureScreen.style.display = captureScreen.style.display === 'none' ? '' : 'none'; + stopScreenCapture.style.display = stopScreenCapture.style.display === 'none' ? '' : 'none'; +} + +function onTrackPublished(publishType, publication, view) { + if (publishType === 'publish') { + if (publication.track) { + publication.track.attach(view); + } + + publication.on('subscribed', track => { + track.attach(view); + }); + } else if (publishType === 'unpublish') { + if (publication.track) { + publication.track.detach(view); + view.srcObject = null; + } + + publication.on('subscribed', track => { + track.detach(view); + view.srcObject = null; + }); + } +} diff --git a/examples/util/getroomcredentials.js b/examples/util/getroomcredentials.js index 53b2417f..572d6c85 100644 --- a/examples/util/getroomcredentials.js +++ b/examples/util/getroomcredentials.js @@ -1,12 +1,47 @@ 'use strict'; +const ADJECTIVES = [ + 'Awesome', 'Bold', 'Creative', 'Dapper', 'Eccentric', 'Fiesty', 'Golden', + 'Holy', 'Ignominious', 'Jolly', 'Kindly', 'Lucky', 'Mushy', 'Natural', + 'Oaken', 'Precise', 'Quiet', 'Rowdy', 'Sunny', 'Tall', + 'Unique', 'Vivid', 'Wonderful', 'Xtra', 'Yawning', 'Zesty' +]; + +const FIRST_NAMES = [ + 'Anna', 'Bobby', 'Cameron', 'Danny', 'Emmett', 'Frida', 'Gracie', 'Hannah', + 'Isaac', 'Jenova', 'Kendra', 'Lando', 'Mufasa', 'Nate', 'Owen', 'Penny', + 'Quincy', 'Roddy', 'Samantha', 'Tammy', 'Ulysses', 'Victoria', 'Wendy', + 'Xander', 'Yolanda', 'Zelda' +]; + +const LAST_NAMES = [ + 'Anchorage', 'Berlin', 'Cucamonga', 'Davenport', 'Essex', 'Fresno', + 'Gunsight', 'Hanover', 'Indianapolis', 'Jamestown', 'Kane', 'Liberty', + 'Minneapolis', 'Nevis', 'Oakland', 'Portland', 'Quantico', 'Raleigh', + 'SaintPaul', 'Tulsa', 'Utica', 'Vail', 'Warsaw', 'XiaoJin', 'Yale', + 'Zimmerman' +]; + +function randomItem(array) { + var randomIndex = Math.floor(Math.random() * array.length); + return array[randomIndex]; +} + +function randomName() { + return [ADJECTIVES, FIRST_NAMES, LAST_NAMES] + .map(randomItem) + .join(' '); +} + /** * Get the Room credentials from the server. + * @param {string} [identity] identitiy to use, if not specified use random name. * @returns {Promise<{identity: string, token: string}>} */ -async function getRoomCredentials() { - const response = await fetch('/token'); - return response.json(); +async function getRoomCredentials(identity = randomName()) { + const response = await fetch(`/token?identity=${identity}`); + const token = await response.text(); + return { identity, token }; } module.exports = getRoomCredentials; diff --git a/examples/util/setupbitrategraph.js b/examples/util/setupbitrategraph.js new file mode 100644 index 00000000..f9c47bab --- /dev/null +++ b/examples/util/setupbitrategraph.js @@ -0,0 +1,48 @@ +'use strict'; + +const DataSeries = require('./timelinegraph').DataSeries; +const GraphView = require('./timelinegraph').GraphView; + +/** + * Set up the bitrate graph for audio or video media. + * @param {string} kind - 'video' or 'audio'. + * @param {string} containerId - The id of the graph container. + * @param {string} canvasId - The id of the canvas. + */ + function setupBitrateGraph(kind, containerId, canvasId) { + const bitrateSeries = new DataSeries(); + const bitrateGraph = new GraphView(containerId, canvasId); + + bitrateGraph.graphDiv_.style.display = 'none'; + return function startBitrateGraph(room, intervalMs) { + let bytesReceivedPrev = 0; + let timestampPrev = Date.now(); + const interval = setInterval(async function() { + if (!room) { + clearInterval(interval); + return; + } + const stats = await room.getStats(); + const remoteTrackStats = kind === 'audio' + ? stats[0].remoteAudioTrackStats[0] + : stats[0].remoteVideoTrackStats[0] + const bytesReceived = remoteTrackStats.bytesReceived; + const timestamp = remoteTrackStats.timestamp; + const bitrate = Math.round((bytesReceivedPrev - bytesReceived) * 8 / (timestampPrev - timestamp)); + + bitrateSeries.addPoint(timestamp, bitrate); + bitrateGraph.setDataSeries([bitrateSeries]); + bitrateGraph.updateEndDate(); + bytesReceivedPrev = bytesReceived; + timestampPrev = timestamp; + }, intervalMs); + + bitrateGraph.graphDiv_.style.display = ''; + return function stop() { + clearInterval(interval); + bitrateGraph.graphDiv_.style.display = 'none'; + }; + }; +} + +module.exports = setupBitrateGraph; diff --git a/examples/util/waveform.js b/examples/util/waveform.js index edf50cd0..623b8344 100644 --- a/examples/util/waveform.js +++ b/examples/util/waveform.js @@ -3,6 +3,12 @@ const CANVAS_HEIGHT = 150; const CANVAS_WIDTH = 300; const FFT_SIZE = 512; +const AudioContext = window.AudioContext // Default + || window.webkitAudioContext; // Safari and old versions of Chrome + +if (!AudioContext) { + console.error('AudioContext is not supported on this platform '); +} /** * Create a waveform element to attach to the DOM. @@ -75,16 +81,20 @@ function Waveform(options) { * @returns {void} */ Waveform.prototype.setStream = function setStream(stream) { - // Disconnect any existing audio source. - this.unsetStream(); + // audioContext created w/o user action gets started as suspended. + // need to resume. ( see: https://goo.gl/7K7WLu ) + this._audioContext.resume().then(function() { + // Disconnect any existing audio source. + this.unsetStream(); - // Create a new audio source for the passed stream, and connect it to the analyser. - this._audioSource = this._audioContext.createMediaStreamSource(stream); - this._audioSource.connect(this._analyser); + // Create a new audio source for the passed stream, and connect it to the analyser. + this._audioSource = this._audioContext.createMediaStreamSource(stream); + this._audioSource.connect(this._analyser); - // Start the render loop - renderFrame(this); -} + // Start the render loop + renderFrame(this); + }.bind(this)); +}; /** * Stop visualizing the current stream. @@ -95,7 +105,7 @@ Waveform.prototype.unsetStream = function unsetStream() { this._audioSource.disconnect(this._analyser); this._audioSource = null; } -} +}; /** * Render the current audio frequency snapshot to the canvas. @@ -137,9 +147,9 @@ function renderFrame(waveform) { var x = 0; for (var i = 0; i < bufferLength; i++) { var v = dataArray[i] / 128.0; - var y = v * CANVAS_HEIGHT/2; + var y = v * CANVAS_HEIGHT / 2; - if(i === 0) { + if (i === 0) { canvasCtx.moveTo(x, y); } else { canvasCtx.lineTo(x, y); @@ -149,7 +159,7 @@ function renderFrame(waveform) { } // End the line at the middle right, and draw the line. - canvasCtx.lineTo(canvas.width, canvas.height/2); + canvasCtx.lineTo(canvas.width, canvas.height / 2); canvasCtx.stroke(); } diff --git a/package.json b/package.json index 7de83aa1..a72efa49 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,35 @@ "build:examples": "npm-run-all build:examples:*", "build:examples:bandwidthconstraints": "copyfiles -f examples/bandwidthconstraints/src/helpers.js examples/bandwidthconstraints/public && browserify examples/bandwidthconstraints/src/index.js > examples/bandwidthconstraints/public/index.js", "build:examples:codecpreferences": "copyfiles -f examples/codecpreferences/src/helpers.js examples/codecpreferences/public && browserify examples/codecpreferences/src/index.js > examples/codecpreferences/public/index.js", - "build:examples:localvideofilter": "copyfiles -f examples/localvideofilter/src/helpers.js examples/localvideofilter/public && browserify examples/localvideofilter/src/index.js > examples/localvideofilter/public/index.js", + "build:examples:dominantspeaker": "copyfiles -f examples/dominantspeaker/src/helpers.js examples/dominantspeaker/public && browserify examples/dominantspeaker/src/index.js > examples/dominantspeaker/public/index.js", + "build:examples:localvideofilter": "copyfiles -f examples/localvideofilter/src/helpers*.js examples/localvideofilter/public && browserify examples/localvideofilter/src/index.js > examples/localvideofilter/public/index.js", "build:examples:localvideosnapshot": "copyfiles -f examples/localvideosnapshot/src/helpers.js examples/localvideosnapshot/public && browserify examples/localvideosnapshot/src/index.js > examples/localvideosnapshot/public/index.js", "build:examples:mediadevices": "copyfiles -f examples/mediadevices/src/helpers.js examples/mediadevices/public && browserify examples/mediadevices/src/index.js > examples/mediadevices/public/index.js", + "build:examples:networkquality": "copyfiles -f examples/networkquality/src/helpers.js examples/networkquality/public && browserify examples/networkquality/src/index.js > examples/networkquality/public/index.js", + "build:examples:reconnection": "copyfiles -f examples/reconnection/src/helpers.js examples/reconnection/public && browserify examples/reconnection/src/index.js > examples/reconnection/public/index.js", + "build:examples:screenshare": "copyfiles -f examples/screenshare/src/helpers.js examples/screenshare/public && browserify examples/screenshare/src/index.js > examples/screenshare/public/index.js", + "build:examples:localmediacontrols": "copyfiles -f examples/localmediacontrols/src/helpers.js examples/localmediacontrols/public && browserify examples/localmediacontrols/src/index.js > examples/localmediacontrols/public/index.js", + "build:examples:remotereconnection": "copyfiles -f examples/remotereconnection/src/helpers.js examples/remotereconnection/public && browserify examples/remotereconnection/src/index.js > examples/remotereconnection/public/index.js", + "build:examples:datatracks": "copyfiles -f examples/datatracks/src/helpers.js examples/datatracks/public && browserify examples/datatracks/src/index.js > examples/datatracks/public/index.js", + "build:examples:manualrenderhint": "copyfiles -f examples/manualrenderhint/src/helpers.js examples/manualrenderhint/public && browserify examples/manualrenderhint/src/index.js > examples/manualrenderhint/public/index.js", + "build:examples:autorenderhint": "copyfiles -f examples/autorenderhint/src/helpers.js examples/autorenderhint/public && browserify examples/autorenderhint/src/index.js > examples/autorenderhint/public/index.js", "build:quickstart": "browserify quickstart/src/index.js > quickstart/public/index.js", "clean": "npm-run-all clean:*", "clean:examples": "npm-run-all clean:examples:*", "clean:examples:bandwidthconstraints": "rimraf examples/bandwidthconstraints/public/index.js examples/bandwidthconstraints/public/helpers.js", "clean:examples:codecpreferences": "rimraf examples/codecpreferences/public/index.js examples/codecpreferences/public/helpers.js", - "clean:examples:localvideofilter": "rimraf examples/localvideofilter/public/index.js examples/localvideofilter/public/helpers.js", + "clean:examples:dominantspeaker": "rimraf examples/dominantspeaker/public/index.js examples/dominantspeaker/public/helpers.js", + "clean:examples:localvideofilter": "rimraf examples/localvideofilter/public/index.js examples/localvideofilter/public/helpers*.js", "clean:examples:localvideosnapshot": "rimraf examples/localvideosnapshot/public/index.js examples/localvideosnapshot/public/helpers.js", "clean:examples:mediadevices": "rimraf examples/mediadevices/public/index.js examples/mediadevices/public/helpers.js", + "clean:examples:networkquality": "rimraf examples/networkquality/public/index.js examples/networkquality/public/helpers.js", + "clean:examples:reconnection": "rimraf examples/reconnection/public/index.js examples/reconnection/public/helpers.js", + "clean:examples:screenshare": "rimraf examples/screenshare/public/index.js examples/screenshare/public/helpers.js", + "clean:examples:localmediacontrols": "rimraf examples/localmediacontrols/public/index.js examples/localmediacontrols/public/helpers.js", + "clean:examples:remotereconnection": "rimraf examples/remotereconnection/public/index.js examples/remotereconnection/public/helpers.js", + "clean:examples:datatracks": "rimraf examples/datatracks/public/index.js examples/datatracks/public/helpers.js", + "clean:examples:manualrenderhint": "rimraf examples/manualrenderhint/public/index.js examples/manualrenderhint/public/helpers.js", + "clean:examples:autorenderhint": "rimraf examples/autorenderhint/public/index.js examples/autorenderhint/public/helpers.js", "clean:quickstart": "rimraf quickstart/public/index.js", "start": "npm run clean && npm run build && node server" }, @@ -46,11 +64,11 @@ "express": "^4.15.2", "prismjs": "^1.6.0", "stackblur-canvas": "^1.4.0", - "twilio": "^3.0.0-rc.16", - "twilio-video": "^1.3.0" + "twilio": "^3.80.1", + "twilio-video": "^2.23.0" }, "devDependencies": { - "browserify": "^14.3.0", + "browserify": "^17.0.0", "copyfiles": "^1.2.0", "npm-run-all": "^4.0.2", "rimraf": "^2.6.1" diff --git a/quickstart/public/index.css b/quickstart/public/index.css index b85623cc..03a1ec07 100644 --- a/quickstart/public/index.css +++ b/quickstart/public/index.css @@ -1,144 +1,86 @@ -@import url(https://fonts.googleapis.com/css?family=Share+Tech+Mono); - -body, -p { - padding: 0; - margin: 0; +html { + height: 100%; } body { - background: #272726; -} - -div#remote-media { - height: 43%; - width: 100%; - background-color: #fff; - text-align: center; - margin: auto; -} - -div#remote-media video { - border: 1px solid #272726; - margin: 3em 2em; - height: 70%; - max-width: 27% !important; - background-color: #272726; - background-repeat: no-repeat; -} - -div#controls { - padding: 3em; - max-width: 1200px; - margin: 0 auto; -} - -div#controls div { - float: left; -} - -div#controls div#room-controls, -div#controls div#preview { - width: 16em; - margin: 0 1.5em; - text-align: center; -} - -div#controls p.instructions { - text-align: left; - margin-bottom: 1em; - font-family: Helvetica-LightOblique, Helvetica, sans-serif; - font-style: oblique; - font-size: 1.25em; - color: #777776; + height: 100%; } -div#controls button { - width: 15em; - height: 2.5em; - margin-top: 1.75em; - border-radius: 1em; - font-family: "Helvetica Light", Helvetica, sans-serif; - font-size: .8em; - font-weight: lighter; - outline: 0; +div.container-fluid { + height: 100%; } -div#controls div#room-controls input { - font-family: Helvetica-LightOblique, Helvetica, sans-serif; - font-style: oblique; - font-size: 1em; +div#participants { + overflow-y: auto; } -div#controls button:active { - position: relative; - top: 1px; +div.participant { + background: center no-repeat url("data:image/svg+xml;utf8,"); + border: 1px solid gray; + display: inline-flex; + height: 90px; + margin: 10px 5px; + max-width: 160px; + overflow: hidden; } -div#controls div#preview div#local-media { - width: 270px; - height: 202px; - border: 1px solid #cececc; - box-sizing: border-box; - background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjgwcHgiIGhlaWdodD0iODBweCIgdmlld0JveD0iMCAwIDgwIDgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbG5zOnNrZXRjaD0iaHR0cDovL3d3dy5ib2hlbWlhbmNvZGluZy5jb20vc2tldGNoL25zIj4KICAgIDwhLS0gR2VuZXJhdG9yOiBTa2V0Y2ggMy4zLjEgKDEyMDAyKSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5GaWxsIDUxICsgRmlsbCA1MjwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxkZWZzPjwvZGVmcz4KICAgIDxnIGlkPSJQYWdlLTEiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHNrZXRjaDp0eXBlPSJNU1BhZ2UiPgogICAgICAgIDxnIGlkPSJjdW1tYWNrIiBza2V0Y2g6dHlwZT0iTVNMYXllckdyb3VwIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTU5LjAwMDAwMCwgLTE3NDYuMDAwMDAwKSIgZmlsbD0iI0ZGRkZGRiI+CiAgICAgICAgICAgIDxnIGlkPSJGaWxsLTUxLSstRmlsbC01MiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTU5LjAwMDAwMCwgMTc0Ni4wMDAwMDApIiBza2V0Y2g6dHlwZT0iTVNTaGFwZUdyb3VwIj4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0zOS42ODYsMC43MyBDMTcuODUsMC43MyAwLjA4NSwxOC41IDAuMDg1LDQwLjMzIEMwLjA4NSw2Mi4xNyAxNy44NSw3OS45MyAzOS42ODYsNzkuOTMgQzYxLjUyMiw3OS45MyA3OS4yODcsNjIuMTcgNzkuMjg3LDQwLjMzIEM3OS4yODcsMTguNSA2MS41MjIsMC43MyAzOS42ODYsMC43MyBMMzkuNjg2LDAuNzMgWiBNMzkuNjg2LDEuNzMgQzYxLjAwNSwxLjczIDc4LjI4NywxOS4wMiA3OC4yODcsNDAuMzMgQzc4LjI4Nyw2MS42NSA2MS4wMDUsNzguOTMgMzkuNjg2LDc4LjkzIEMxOC4zNjcsNzguOTMgMS4wODUsNjEuNjUgMS4wODUsNDAuMzMgQzEuMDg1LDE5LjAyIDE4LjM2NywxLjczIDM5LjY4NiwxLjczIEwzOS42ODYsMS43MyBaIiBpZD0iRmlsbC01MSI+PC9wYXRoPgogICAgICAgICAgICAgICAgPHBhdGggZD0iTTQ3Ljk2LDUzLjMzNSBMNDcuOTYsNTIuODM1IEwyMC4wOTMsNTIuODM1IEwyMC4wOTMsMjcuODI1IEw0Ny40NiwyNy44MjUgTDQ3LjQ2LDM4LjI1NSBMNTkuMjc5LDMwLjgwNSBMNTkuMjc5LDQ5Ljg1NSBMNDcuNDYsNDIuNDA1IEw0Ny40Niw1My4zMzUgTDQ3Ljk2LDUzLjMzNSBMNDcuOTYsNTIuODM1IEw0Ny45Niw1My4zMzUgTDQ4LjQ2LDUzLjMzNSBMNDguNDYsNDQuMjE1IEw2MC4yNzksNTEuNjY1IEw2MC4yNzksMjguOTk1IEw0OC40NiwzNi40NDUgTDQ4LjQ2LDI2LjgyNSBMMTkuMDkzLDI2LjgyNSBMMTkuMDkzLDUzLjgzNSBMNDguNDYsNTMuODM1IEw0OC40Niw1My4zMzUgTDQ3Ljk2LDUzLjMzNSIgaWQ9IkZpbGwtNTIiPjwvcGF0aD4KICAgICAgICAgICAgPC9nPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+); - background-position: center; - background-repeat: no-repeat; - margin: 0 auto; +div.participant > video { + width: 100%; } -div#controls div#preview div#local-media video { +div.participant.main { + height: inherit; + margin: 10px 0; max-width: 100%; - max-height: 100%; - border: none; + width: 100%; } -div#controls div#preview button#button-preview { - background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjE3cHgiIGhlaWdodD0iMTJweCIgdmlld0JveD0iMCAwIDE3IDEyIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbG5zOnNrZXRjaD0iaHR0cDovL3d3dy5ib2hlbWlhbmNvZGluZy5jb20vc2tldGNoL25zIj4KICAgIDwhLS0gR2VuZXJhdG9yOiBTa2V0Y2ggMy4zLjEgKDEyMDAyKSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5GaWxsIDM0PC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGRlZnM+PC9kZWZzPgogICAgPGcgaWQ9IlBhZ2UtMSIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc2tldGNoOnR5cGU9Ik1TUGFnZSI+CiAgICAgICAgPGcgaWQ9ImN1bW1hY2siIHNrZXRjaDp0eXBlPSJNU0xheWVyR3JvdXAiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xMjUuMDAwMDAwLCAtMTkwOS4wMDAwMDApIiBmaWxsPSIjMEEwQjA5Ij4KICAgICAgICAgICAgPHBhdGggZD0iTTEzNi40NzEsMTkxOS44NyBMMTM2LjQ3MSwxOTE5LjYyIEwxMjUuNzY3LDE5MTkuNjIgTDEyNS43NjcsMTkxMC4wOCBMMTM2LjIyMSwxOTEwLjA4IEwxMzYuMjIxLDE5MTQuMTUgTDE0MC43ODUsMTkxMS4yNyBMMTQwLjc4NSwxOTE4LjQyIEwxMzYuMjIxLDE5MTUuNTUgTDEzNi4yMjEsMTkxOS44NyBMMTM2LjQ3MSwxOTE5Ljg3IEwxMzYuNDcxLDE5MTkuNjIgTDEzNi40NzEsMTkxOS44NyBMMTM2LjcyMSwxOTE5Ljg3IEwxMzYuNzIxLDE5MTYuNDUgTDE0MS4yODUsMTkxOS4zMyBMMTQxLjI4NSwxOTEwLjM3IEwxMzYuNzIxLDE5MTMuMjQgTDEzNi43MjEsMTkwOS41OCBMMTI1LjI2NywxOTA5LjU4IEwxMjUuMjY3LDE5MjAuMTIgTDEzNi43MjEsMTkyMC4xMiBMMTM2LjcyMSwxOTE5Ljg3IEwxMzYuNDcxLDE5MTkuODciIGlkPSJGaWxsLTM0IiBza2V0Y2g6dHlwZT0iTVNTaGFwZUdyb3VwIj48L3BhdGg+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4=)1em center no-repeat #fff; - border: none; - padding-left: 1.5em; +div.participant.main > video { + height: 720px; } -div#controls div#log { - border: 1px solid #686865; +div.participant.active { + border: 1px solid crimson; + box-shadow: 0 0 5px crimson; } -div#controls div#room-controls { - display: none; +div.participant.active.pinned { + border: 1px solid limegreen; + box-shadow: 0 0 5px limegreen; } -div#controls div#room-controls input { - width: 100%; - height: 2.5em; - padding: .5em; - display: block; +div.participant:hover { + cursor: pointer; } -div#controls div#room-controls button { - color: #fff; - background: 0 0; - border: 1px solid #686865; +div.participant::before { + background-color: black; + color: white !important; + content: attr(data-identity); + font-size: 10px; + padding: 0 5px; + position: absolute; + z-index: 1000; } -div#controls div#room-controls button#button-leave { - display: none; +div.participant.main::before { + font-size: 14px; + padding: 0 10px; } -div#controls div#log { - width: 35%; - height: 9.5em; - margin-top: 2.75em; - text-align: left; - padding: 1.5em; - float: right; - overflow-y: scroll; +@media (max-width: 576px) { + div#participants { + overflow-x: auto; + white-space: nowrap; + } + + div.participant.main > video { + height: 180px; + } } -div#controls div#log p { - color: #686865; - font-family: 'Share Tech Mono', 'Courier New', Courier, fixed-width; - font-size: 1.25em; - line-height: 1.25em; - margin-left: 1em; - text-indent: -1.25em; - width: 90%; +@media (max-width: 768px) { + div.participant.main > video { + height: 270px; + } } diff --git a/quickstart/public/index.html b/quickstart/public/index.html index ae4ba641..75a28dd4 100644 --- a/quickstart/public/index.html +++ b/quickstart/public/index.html @@ -1,27 +1,122 @@ - - - Twilio Video - Video Quickstart - - - -
-
-
-

Hello Beautiful

-
- -
-
-

Room Name:

- - - -
-
-
- - - - + + + Twilio Video QuickStart + + + + + + +
+
+
+
+
+ +
+
+
+
+ + + + +
+ + + + diff --git a/quickstart/public/quickstart.png b/quickstart/public/quickstart.png new file mode 100644 index 00000000..b1ac211c Binary files /dev/null and b/quickstart/public/quickstart.png differ diff --git a/quickstart/src/browser.js b/quickstart/src/browser.js new file mode 100644 index 00000000..26e9b316 --- /dev/null +++ b/quickstart/src/browser.js @@ -0,0 +1,44 @@ +'use strict'; + +/** + * Add URL parameters to the web app URL. + * @param params - the parameters to add + */ +function addUrlParams(params) { + const combinedParams = Object.assign(getUrlParams(), params); + const serializedParams = Object.entries(combinedParams) + .map(([name, value]) => `${name}=${encodeURIComponent(value)}`) + .join('&'); + history.pushState(null, '', `${location.pathname}?${serializedParams}`); +} + +/** + * Generate an object map of URL parameters. + * @returns {*} + */ +function getUrlParams() { + const serializedParams = location.search.split('?')[1]; + const nvpairs = serializedParams ? serializedParams.split('&') : []; + return nvpairs.reduce((params, nvpair) => { + const [name, value] = nvpair.split('='); + params[name] = decodeURIComponent(value); + return params; + }, {}); +} + +/** + * Whether the web app is running on a mobile browser. + * @type {boolean} + */ +const isMobile = (() => { + if (typeof navigator === 'undefined' || typeof navigator.userAgent !== 'string') { + return false; + } + return /Mobile/.test(navigator.userAgent); +})(); + +module.exports = { + addUrlParams, + getUrlParams, + isMobile +}; diff --git a/quickstart/src/index.js b/quickstart/src/index.js index 6cb27271..1c111218 100644 --- a/quickstart/src/index.js +++ b/quickstart/src/index.js @@ -1,171 +1,150 @@ 'use strict'; -var Video = require('twilio-video'); - -var activeRoom; -var previewTracks; -var identity; -var roomName; - -// Attach the Tracks to the DOM. -function attachTracks(tracks, container) { - tracks.forEach(function(track) { - container.appendChild(track.attach()); - }); -} +const { isSupported } = require('twilio-video'); + +const { isMobile } = require('./browser'); +const joinRoom = require('./joinroom'); +const micLevel = require('./miclevel'); +const selectMedia = require('./selectmedia'); +const selectRoom = require('./selectroom'); +const showError = require('./showerror'); + +const $modals = $('#modals'); +const $selectMicModal = $('#select-mic', $modals); +const $selectCameraModal = $('#select-camera', $modals); +const $showErrorModal = $('#show-error', $modals); +const $joinRoomModal = $('#join-room', $modals); + +// ConnectOptions settings for a video web application. +const connectOptions = { + // Available only in Small Group or Group Rooms only. Please set "Room Type" + // to "Group" or "Small Group" in your Twilio Console: + // https://www.twilio.com/console/video/configure + bandwidthProfile: { + video: { + dominantSpeakerPriority: 'high', + mode: 'collaboration', + clientTrackSwitchOffControl: 'auto', + contentPreferencesMode: 'auto' + } + }, -// Attach the Participant's Tracks to the DOM. -function attachParticipantTracks(participant, container) { - var tracks = Array.from(participant.tracks.values()); - attachTracks(tracks, container); -} + // Available only in Small Group or Group Rooms only. Please set "Room Type" + // to "Group" or "Small Group" in your Twilio Console: + // https://www.twilio.com/console/video/configure + dominantSpeaker: true, -// Detach the Tracks from the DOM. -function detachTracks(tracks) { - tracks.forEach(function(track) { - track.detach().forEach(function(detachedElement) { - detachedElement.remove(); - }); - }); -} + // Comment this line if you are playing music. + maxAudioBitrate: 16000, + + // VP8 simulcast enables the media server in a Small Group or Group Room + // to adapt your encoded video quality for each RemoteParticipant based on + // their individual bandwidth constraints. This has no utility if you are + // using Peer-to-Peer Rooms, so you can comment this line. + preferredVideoCodecs: [{ codec: 'VP8', simulcast: true }], + + // Capture 720p video @ 24 fps. + video: { height: 720, frameRate: 24, width: 1280 } +}; -// Detach the Participant's Tracks from the DOM. -function detachParticipantTracks(participant) { - var tracks = Array.from(participant.tracks.values()); - detachTracks(tracks); +// For mobile browsers, limit the maximum incoming video bitrate to 2.5 Mbps. +if (isMobile) { + connectOptions + .bandwidthProfile + .video + .maxSubscriptionBitrate = 2500000; } -// When we are about to transition away from this page, disconnect -// from the room, if joined. -window.addEventListener('beforeunload', leaveRoomIfJoined); +// On mobile browsers, there is the possibility of not getting any media even +// after the user has given permission, most likely due to some other app reserving +// the media device. So, we make sure users always test their media devices before +// joining the Room. For more best practices, please refer to the following guide: +// https://www.twilio.com/docs/video/build-js-video-application-recommendations-and-best-practices +const deviceIds = { + audio: isMobile ? null : localStorage.getItem('audioDeviceId'), + video: isMobile ? null : localStorage.getItem('videoDeviceId') +}; -// Obtain a token from the server in order to connect to the Room. -$.getJSON('/token', function(data) { - identity = data.identity; - document.getElementById('room-controls').style.display = 'block'; +/** + * Select your Room name, your screen name and join. + * @param [error=null] - Error from the previous Room session, if any + */ +async function selectAndJoinRoom(error = null) { + const formData = await selectRoom($joinRoomModal, error); + if (!formData) { + // User wants to change the camera and microphone. + // So, show them the microphone selection modal. + deviceIds.audio = null; + deviceIds.video = null; + return selectMicrophone(); + } + const { identity, roomName } = formData; - // Bind button to join Room. - document.getElementById('button-join').onclick = function() { - roomName = document.getElementById('room-name').value; - if (!roomName) { - alert('Please enter a room name.'); - return; - } + try { + // Fetch an AccessToken to join the Room. + const response = await fetch(`/token?identity=${identity}`); - log("Joining room '" + roomName + "'..."); - var connectOptions = { - name: roomName, - logLevel: 'debug' - }; + // Extract the AccessToken from the Response. + const token = await response.text(); - if (previewTracks) { - connectOptions.tracks = previewTracks; - } + // Add the specified audio device ID to ConnectOptions. + connectOptions.audio = { deviceId: { exact: deviceIds.audio } }; - // Join the Room with the token from the server and the - // LocalParticipant's Tracks. - Video.connect(data.token, connectOptions).then(roomJoined, function(error) { - log('Could not connect to Twilio: ' + error.message); - }); - }; - - // Bind button to leave Room. - document.getElementById('button-leave').onclick = function() { - log('Leaving room...'); - activeRoom.disconnect(); - }; -}); + // Add the specified Room name to ConnectOptions. + connectOptions.name = roomName; -// Successfully connected! -function roomJoined(room) { - window.room = activeRoom = room; + // Add the specified video device ID to ConnectOptions. + connectOptions.video.deviceId = { exact: deviceIds.video }; - log("Joined as '" + identity + "'"); - document.getElementById('button-join').style.display = 'none'; - document.getElementById('button-leave').style.display = 'inline'; + // Join the Room. + await joinRoom(token, connectOptions); - // Attach LocalParticipant's Tracks, if not already attached. - var previewContainer = document.getElementById('local-media'); - if (!previewContainer.querySelector('video')) { - attachParticipantTracks(room.localParticipant, previewContainer); + // After the video session, display the room selection modal. + return selectAndJoinRoom(); + } catch (error) { + return selectAndJoinRoom(error); } +} - // Attach the Tracks of the Room's Participants. - room.participants.forEach(function(participant) { - log("Already in Room: '" + participant.identity + "'"); - var previewContainer = document.getElementById('remote-media'); - attachParticipantTracks(participant, previewContainer); - }); - - // When a Participant joins the Room, log the event. - room.on('participantConnected', function(participant) { - log("Joining: '" + participant.identity + "'"); - }); - - // When a Participant adds a Track, attach it to the DOM. - room.on('trackAdded', function(track, participant) { - log(participant.identity + " added track: " + track.kind); - var previewContainer = document.getElementById('remote-media'); - attachTracks([track], previewContainer); - }); - - // When a Participant removes a Track, detach it from the DOM. - room.on('trackRemoved', function(track, participant) { - log(participant.identity + " removed track: " + track.kind); - detachTracks([track]); - }); - - // When a Participant leaves the Room, detach its Tracks. - room.on('participantDisconnected', function(participant) { - log("Participant '" + participant.identity + "' left the room"); - detachParticipantTracks(participant); - }); - - // Once the LocalParticipant leaves the room, detach the Tracks - // of all Participants, including that of the LocalParticipant. - room.on('disconnected', function() { - log('Left'); - if (previewTracks) { - previewTracks.forEach(function(track) { - track.stop(); +/** + * Select your camera. + */ +async function selectCamera() { + if (deviceIds.video === null) { + try { + deviceIds.video = await selectMedia('video', $selectCameraModal, videoTrack => { + const $video = $('video', $selectCameraModal); + videoTrack.attach($video.get(0)) }); + } catch (error) { + showError($showErrorModal, error); + return; } - detachParticipantTracks(room.localParticipant); - room.participants.forEach(detachParticipantTracks); - activeRoom = null; - document.getElementById('button-join').style.display = 'inline'; - document.getElementById('button-leave').style.display = 'none'; - }); + } + return selectAndJoinRoom(); } -// Preview LocalParticipant's Tracks. -document.getElementById('button-preview').onclick = function() { - var localTracksPromise = previewTracks - ? Promise.resolve(previewTracks) - : Video.createLocalTracks(); - - localTracksPromise.then(function(tracks) { - window.previewTracks = previewTracks = tracks; - var previewContainer = document.getElementById('local-media'); - if (!previewContainer.querySelector('video')) { - attachTracks(tracks, previewContainer); +/** + * Select your microphone. + */ +async function selectMicrophone() { + if (deviceIds.audio === null) { + try { + deviceIds.audio = await selectMedia('audio', $selectMicModal, audioTrack => { + const $levelIndicator = $('svg rect', $selectMicModal); + const maxLevel = Number($levelIndicator.attr('height')); + micLevel(audioTrack, maxLevel, level => $levelIndicator.attr('y', maxLevel - level)); + }); + } catch (error) { + showError($showErrorModal, error); + return; } - }, function(error) { - console.error('Unable to access local media', error); - log('Unable to access Camera and Microphone'); - }); -}; - -// Activity log. -function log(message) { - var logDiv = document.getElementById('log'); - logDiv.innerHTML += '

> ' + message + '

'; - logDiv.scrollTop = logDiv.scrollHeight; -} - -// Leave Room. -function leaveRoomIfJoined() { - if (activeRoom) { - activeRoom.disconnect(); } + return selectCamera(); } + +// If the current browser is not supported by twilio-video.js, show an error +// message. Otherwise, start the application. +window.addEventListener('load', isSupported ? selectMicrophone : () => { + showError($showErrorModal, new Error('This browser is not supported.')); +}); diff --git a/quickstart/src/joinroom.js b/quickstart/src/joinroom.js new file mode 100644 index 00000000..080ddf77 --- /dev/null +++ b/quickstart/src/joinroom.js @@ -0,0 +1,341 @@ +'use strict'; + +const { connect, createLocalVideoTrack, Logger } = require('twilio-video'); +const { isMobile } = require('./browser'); + +const $leave = $('#leave-room'); +const $room = $('#room'); +const $activeParticipant = $('div#active-participant > div.participant.main', $room); +const $activeVideo = $('video', $activeParticipant); +const $participants = $('div#participants', $room); + +// The current active Participant in the Room. +let activeParticipant = null; + +// Whether the user has selected the active Participant by clicking on +// one of the video thumbnails. +let isActiveParticipantPinned = false; + +/** + * Set the active Participant's video. + * @param participant - the active Participant + */ +function setActiveParticipant(participant) { + if (activeParticipant) { + const $activeParticipant = $(`div#${activeParticipant.sid}`, $participants); + $activeParticipant.removeClass('active'); + $activeParticipant.removeClass('pinned'); + + // Detach any existing VideoTrack of the active Participant. + const { track: activeTrack } = Array.from(activeParticipant.videoTracks.values())[0] || {}; + if (activeTrack) { + activeTrack.detach($activeVideo.get(0)); + $activeVideo.css('opacity', '0'); + } + } + + // Set the new active Participant. + activeParticipant = participant; + const { identity, sid } = participant; + const $participant = $(`div#${sid}`, $participants); + + $participant.addClass('active'); + if (isActiveParticipantPinned) { + $participant.addClass('pinned'); + } + + // Attach the new active Participant's video. + const { track } = Array.from(participant.videoTracks.values())[0] || {}; + if (track) { + track.attach($activeVideo.get(0)); + $activeVideo.css('opacity', ''); + } + + // Set the new active Participant's identity + $activeParticipant.attr('data-identity', identity); +} + +/** + * Set the current active Participant in the Room. + * @param room - the Room which contains the current active Participant + */ +function setCurrentActiveParticipant(room) { + const { dominantSpeaker, localParticipant } = room; + setActiveParticipant(dominantSpeaker || localParticipant); +} + +/** + * Set up the Participant's media container. + * @param participant - the Participant whose media container is to be set up + * @param room - the Room that the Participant joined + */ +function setupParticipantContainer(participant, room) { + const { identity, sid } = participant; + + // Add a container for the Participant's media. + const $container = $(`
+ + +
`); + + // Toggle the pinning of the active Participant's video. + $container.on('click', () => { + if (activeParticipant === participant && isActiveParticipantPinned) { + // Unpin the RemoteParticipant and update the current active Participant. + setVideoPriority(participant, null); + isActiveParticipantPinned = false; + setCurrentActiveParticipant(room); + } else { + // Pin the RemoteParticipant as the active Participant. + if (isActiveParticipantPinned) { + setVideoPriority(activeParticipant, null); + } + setVideoPriority(participant, 'high'); + isActiveParticipantPinned = true; + setActiveParticipant(participant); + } + }); + + // Add the Participant's container to the DOM. + $participants.append($container); +} + +/** + * Set the VideoTrack priority for the given RemoteParticipant. This has no + * effect in Peer-to-Peer Rooms. + * @param participant - the RemoteParticipant whose VideoTrack priority is to be set + * @param priority - null | 'low' | 'standard' | 'high' + */ +function setVideoPriority(participant, priority) { + participant.videoTracks.forEach(publication => { + const track = publication.track; + if (track && track.setPriority) { + track.setPriority(priority); + } + }); +} + +/** + * Attach a Track to the DOM. + * @param track - the Track to attach + * @param participant - the Participant which published the Track + */ +function attachTrack(track, participant) { + // Attach the Participant's Track to the thumbnail. + const $media = $(`div#${participant.sid} > ${track.kind}`, $participants); + $media.css('opacity', ''); + track.attach($media.get(0)); + + // If the attached Track is a VideoTrack that is published by the active + // Participant, then attach it to the main video as well. + if (track.kind === 'video' && participant === activeParticipant) { + track.attach($activeVideo.get(0)); + $activeVideo.css('opacity', ''); + } +} + +/** + * Detach a Track from the DOM. + * @param track - the Track to be detached + * @param participant - the Participant that is publishing the Track + */ +function detachTrack(track, participant) { + // Detach the Participant's Track from the thumbnail. + const $media = $(`div#${participant.sid} > ${track.kind}`, $participants); + const mediaEl = $media.get(0); + $media.css('opacity', '0'); + track.detach(mediaEl); + mediaEl.srcObject = null; + + // If the detached Track is a VideoTrack that is published by the active + // Participant, then detach it from the main video as well. + if (track.kind === 'video' && participant === activeParticipant) { + const activeVideoEl = $activeVideo.get(0); + track.detach(activeVideoEl); + activeVideoEl.srcObject = null; + $activeVideo.css('opacity', '0'); + } +} + +/** + * Handle the Participant's media. + * @param participant - the Participant + * @param room - the Room that the Participant joined + */ +function participantConnected(participant, room) { + // Set up the Participant's media container. + setupParticipantContainer(participant, room); + + // Handle the TrackPublications already published by the Participant. + participant.tracks.forEach(publication => { + trackPublished(publication, participant); + }); + + // Handle theTrackPublications that will be published by the Participant later. + participant.on('trackPublished', publication => { + trackPublished(publication, participant); + }); +} + +/** + * Handle a disconnected Participant. + * @param participant - the disconnected Participant + * @param room - the Room that the Participant disconnected from + */ +function participantDisconnected(participant, room) { + // If the disconnected Participant was pinned as the active Participant, then + // unpin it so that the active Participant can be updated. + if (activeParticipant === participant && isActiveParticipantPinned) { + isActiveParticipantPinned = false; + setCurrentActiveParticipant(room); + } + + // Remove the Participant's media container. + $(`div#${participant.sid}`, $participants).remove(); +} + +/** + * Handle to the TrackPublication's media. + * @param publication - the TrackPublication + * @param participant - the publishing Participant + */ +function trackPublished(publication, participant) { + // If the TrackPublication is already subscribed to, then attach the Track to the DOM. + if (publication.track) { + attachTrack(publication.track, participant); + } + + // Once the TrackPublication is subscribed to, attach the Track to the DOM. + publication.on('subscribed', track => { + attachTrack(track, participant); + }); + + // Once the TrackPublication is unsubscribed from, detach the Track from the DOM. + publication.on('unsubscribed', track => { + detachTrack(track, participant); + }); +} + +/** + * Join a Room. + * @param token - the AccessToken used to join a Room + * @param connectOptions - the ConnectOptions used to join a Room + */ +async function joinRoom(token, connectOptions) { + // Comment the next two lines to disable verbose logging. + const logger = Logger.getLogger('twilio-video'); + logger.setLevel('debug'); + + // Join to the Room with the given AccessToken and ConnectOptions. + const room = await connect(token, connectOptions); + + // Save the LocalVideoTrack. + let localVideoTrack = Array.from(room.localParticipant.videoTracks.values())[0].track; + + // Make the Room available in the JavaScript console for debugging. + window.room = room; + + // Handle the LocalParticipant's media. + participantConnected(room.localParticipant, room); + + // Subscribe to the media published by RemoteParticipants already in the Room. + room.participants.forEach(participant => { + participantConnected(participant, room); + }); + + // Subscribe to the media published by RemoteParticipants joining the Room later. + room.on('participantConnected', participant => { + participantConnected(participant, room); + }); + + // Handle a disconnected RemoteParticipant. + room.on('participantDisconnected', participant => { + participantDisconnected(participant, room); + }); + + // Set the current active Participant. + setCurrentActiveParticipant(room); + + // Update the active Participant when changed, only if the user has not + // pinned any particular Participant as the active Participant. + room.on('dominantSpeakerChanged', () => { + if (!isActiveParticipantPinned) { + setCurrentActiveParticipant(room); + } + }); + + // Leave the Room when the "Leave Room" button is clicked. + $leave.click(function onLeave() { + $leave.off('click', onLeave); + room.disconnect(); + }); + + return new Promise((resolve, reject) => { + // Leave the Room when the "beforeunload" event is fired. + window.onbeforeunload = () => { + room.disconnect(); + }; + + if (isMobile) { + // TODO(mmalavalli): investigate why "pagehide" is not working in iOS Safari. + // In iOS Safari, "beforeunload" is not fired, so use "pagehide" instead. + window.onpagehide = () => { + room.disconnect(); + }; + + // On mobile browsers, use "visibilitychange" event to determine when + // the app is backgrounded or foregrounded. + document.onvisibilitychange = async () => { + if (document.visibilityState === 'hidden') { + // When the app is backgrounded, your app can no longer capture + // video frames. So, stop and unpublish the LocalVideoTrack. + localVideoTrack.stop(); + room.localParticipant.unpublishTrack(localVideoTrack); + } else { + // When the app is foregrounded, your app can now continue to + // capture video frames. So, publish a new LocalVideoTrack. + localVideoTrack = await createLocalVideoTrack(connectOptions.video); + await room.localParticipant.publishTrack(localVideoTrack); + } + }; + } + + room.once('disconnected', (room, error) => { + // Clear the event handlers on document and window.. + window.onbeforeunload = null; + if (isMobile) { + window.onpagehide = null; + document.onvisibilitychange = null; + } + + // Stop the LocalVideoTrack. + localVideoTrack.stop(); + + // Handle the disconnected LocalParticipant. + participantDisconnected(room.localParticipant, room); + + // Handle the disconnected RemoteParticipants. + room.participants.forEach(participant => { + participantDisconnected(participant, room); + }); + + // Clear the active Participant's video. + $activeVideo.get(0).srcObject = null; + + // Clear the Room reference used for debugging from the JavaScript console. + window.room = null; + + if (error) { + // Reject the Promise with the TwilioError so that the Room selection + // modal (plus the TwilioError message) can be displayed. + reject(error); + } else { + // Resolve the Promise so that the Room selection modal can be + // displayed. + resolve(); + } + }); + }); +} + +module.exports = joinRoom; diff --git a/quickstart/src/miclevel.js b/quickstart/src/miclevel.js new file mode 100644 index 00000000..e5e2e161 --- /dev/null +++ b/quickstart/src/miclevel.js @@ -0,0 +1,71 @@ +'use strict'; + +const AudioContext = window.AudioContext || window.webkitAudioContext; +const audioContext = AudioContext ? new AudioContext() : null; + +/** + * Calculate the root mean square (RMS) of the given array. + * @param samples + * @returns {number} the RMS value + */ +function rootMeanSquare(samples) { + const sumSq = samples.reduce((sumSq, sample) => sumSq + sample * sample, 0); + return Math.sqrt(sumSq / samples.length); +} + +/** + * Poll the microphone's input level. + * @param audioTrack - the AudioTrack representing the microphone + * @param maxLevel - the calculated level should be in the range [0 - maxLevel] + * @param onLevel - called when the input level changes + */ +module.exports = audioContext + ? function micLevel(audioTrack, maxLevel, onLevel) { + audioContext.resume().then(() => { + let rafID; + + const initializeAnalyser = () => { + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 1024; + analyser.smoothingTimeConstant = 0.5; + + const stream = new MediaStream([audioTrack.mediaStreamTrack]); + const audioSource = audioContext.createMediaStreamSource(stream); + const samples = new Uint8Array(analyser.frequencyBinCount); + + audioSource.connect(analyser); + startAnimation(analyser, samples); + }; + + initializeAnalyser(); + + // We listen to when the Audio Track is started, and once it is, + // the Analyser Node is restarted. + audioTrack.on('started', initializeAnalyser); + + let level = null; + + function startAnimation(analyser, samples) { + window.cancelAnimationFrame(rafID); + + rafID = requestAnimationFrame(function checkLevel() { + analyser.getByteFrequencyData(samples); + const rms = rootMeanSquare(samples); + const log2Rms = rms && Math.log2(rms); + const newLevel = Math.ceil((maxLevel * log2Rms) / 8); + + if (level !== newLevel) { + level = newLevel; + onLevel(level); + } + + rafID = requestAnimationFrame(audioTrack.mediaStreamTrack.readyState === 'ended' + ? () => onLevel(0) + : checkLevel); + }); + } + }); + } + : function notSupported() { + // Do nothing. + }; diff --git a/quickstart/src/selectmedia.js b/quickstart/src/selectmedia.js new file mode 100644 index 00000000..463e6a65 --- /dev/null +++ b/quickstart/src/selectmedia.js @@ -0,0 +1,112 @@ +'use strict'; + +const { createLocalTracks } = require('twilio-video'); + +const localTracks = { + audio: null, + video: null +}; + +/** + * Start capturing media from the given input device. + * @param kind - 'audio' or 'video' + * @param deviceId - the input device ID + * @param render - the render callback + * @returns {Promise} Promise that is resolved if successful + */ +async function applyInputDevice(kind, deviceId, render) { + // Create a new LocalTrack from the given Device ID. + const [track] = await createLocalTracks({ [kind]: { deviceId } }); + + // Stop the previous LocalTrack, if present. + if (localTracks[kind]) { + localTracks[kind].stop(); + } + + // Render the current LocalTrack. + localTracks[kind] = track; + render(track); +} + +/** + * Get the list of input devices of a given kind. + * @param kind - 'audio' | 'video' + * @returns {Promise} the list of media devices + */ +async function getInputDevices(kind) { + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.filter(device => device.kind === `${kind}input`); +} + +/** + * Select the input for the given media kind. + * @param kind - 'audio' or 'video' + * @param $modal - the modal for selecting the media input + * @param render - the media render function + * @returns {Promise} the device ID of the selected media input + */ +async function selectMedia(kind, $modal, render) { + const $apply = $('button', $modal); + const $inputDevices = $('select', $modal); + const setDevice = () => applyInputDevice(kind, $inputDevices.val(), render); + + // Get the list of available media input devices. + let devices = await getInputDevices(kind); + + // Apply the default media input device. + await applyInputDevice(kind, devices[0].deviceId, render); + + // If all device IDs and/or labels are empty, that means they were + // enumerated before the user granted media permissions. So, enumerate + // the devices again. + if (devices.every(({ deviceId, label }) => !deviceId || !label)) { + devices = await getInputDevices(kind); + } + + // Populate the modal with the list of available media input devices. + $inputDevices.html(devices.map(({ deviceId, label }) => { + return ``; + })); + + return new Promise(resolve => { + $modal.on('shown.bs.modal', function onShow() { + $modal.off('shown.bs.modal', onShow); + + // When the user selects a different media input device, apply it. + $inputDevices.change(setDevice); + + // When the user clicks the "Apply" button, close the modal. + $apply.click(function onApply() { + $inputDevices.off('change', setDevice); + $apply.off('click', onApply); + $modal.modal('hide'); + }); + }); + + // When the modal is closed, save the device ID. + $modal.on('hidden.bs.modal', function onHide() { + $modal.off('hidden.bs.modal', onHide); + + // Stop the LocalTrack, if present. + if (localTracks[kind]) { + localTracks[kind].stop(); + localTracks[kind] = null; + } + + // Resolve the Promise with the saved device ID. + const deviceId = $inputDevices.val(); + localStorage.setItem(`${kind}DeviceId`, deviceId); + resolve(deviceId); + }); + + // Show the modal. + $modal.modal({ + backdrop: 'static', + focus: true, + keyboard: false, + show: true + }); + }); +} + +module.exports = selectMedia; diff --git a/quickstart/src/selectroom.js b/quickstart/src/selectroom.js new file mode 100644 index 00000000..57a57c05 --- /dev/null +++ b/quickstart/src/selectroom.js @@ -0,0 +1,80 @@ +'use strict'; + +const { addUrlParams, getUrlParams } = require('./browser'); +const getUserFriendlyError = require('./userfriendlyerror'); + +/** + * Select your Room name and identity (screen name). + * @param $modal - modal for selecting your Room name and identity + * @param error - Error from the previous Room session, if any + */ +function selectRoom($modal, error) { + const $alert = $('div.alert', $modal); + const $changeMedia = $('button.btn-dark', $modal); + const $identity = $('#screen-name', $modal); + const $join = $('button.btn-primary', $modal); + const $roomName = $('#room-name', $modal); + + // If Room name is provided as a URL parameter, pre-populate the Room name field. + const { roomName } = getUrlParams(); + if (roomName) { + $roomName.val(roomName); + } + + // If any previously saved user name exists, pre-populate the user name field. + const identity = localStorage.getItem('userName'); + if (identity) { + $identity.val(identity); + } + + if (error) { + $alert.html(`
${error.name}${error.message + ? `: ${error.message}` + : ''}
${getUserFriendlyError(error)}`); + $alert.css('display', ''); + } else { + $alert.css('display', 'none'); + } + + return new Promise(resolve => { + $modal.on('shown.bs.modal', function onShow() { + $modal.off('shown.bs.modal', onShow); + $changeMedia.click(function onChangeMedia() { + $changeMedia.off('click', onChangeMedia); + $modal.modal('hide'); + resolve(null); + }); + + $join.click(function onJoin() { + const identity = $identity.val(); + const roomName = $roomName.val(); + if (identity && roomName) { + // Append the Room name to the web application URL. + addUrlParams({ roomName }); + + // Save the user name. + localStorage.setItem('userName', identity); + + $join.off('click', onJoin); + $modal.modal('hide'); + } + }); + }); + + $modal.on('hidden.bs.modal', function onHide() { + $modal.off('hidden.bs.modal', onHide); + const identity = $identity.val(); + const roomName = $roomName.val(); + resolve({ identity, roomName }); + }); + + $modal.modal({ + backdrop: 'static', + focus: true, + keyboard: false, + show: true + }); + }); +} + +module.exports = selectRoom; diff --git a/quickstart/src/showerror.js b/quickstart/src/showerror.js new file mode 100644 index 00000000..f6d88b35 --- /dev/null +++ b/quickstart/src/showerror.js @@ -0,0 +1,25 @@ +'use strict'; + +const getUserFriendlyError = require('./userfriendlyerror'); + +/** + * Show the given error. + * @param $modal - modal for showing the error. + * @param error - Error to be shown. + */ +function showError($modal, error) { + // Add the appropriate error message to the alert. + $('div.alert', $modal).html(getUserFriendlyError(error)); + $modal.modal({ + backdrop: 'static', + focus: true, + keyboard: false, + show: true + }); + + $('#show-error-label', $modal).text(`${error.name}${error.message + ? `: ${error.message}` + : ''}`); +} + +module.exports = showError; diff --git a/quickstart/src/userfriendlyerror.js b/quickstart/src/userfriendlyerror.js new file mode 100644 index 00000000..b36e345a --- /dev/null +++ b/quickstart/src/userfriendlyerror.js @@ -0,0 +1,42 @@ +'use strict'; + +const USER_FRIENDLY_ERRORS = { + NotAllowedError: () => { + return 'Causes:
1. The user has denied permission for your app to access the input device either by dismissing the permission dialog or clicking on the "deny" button.
2. The user has denied permission for your app to access the input device in the browser settings.
' + +'
Solutions:
1. The user should reload your app and grant permission to access the input device.
2. The user should allow access to the input device in the browser settings and then reload your app.'; + }, + NotFoundError: () => { + return 'Cause:
1. The user has disabled the input device for the browser in the system settings.
2. The user\'s machine does not have such input device connected to it.
' + +'
Solution
1. The user should enable the input device for the browser in the system settings
2. The user should have atleast one input device connected.'; + }, + NotReadableError: () => { + return 'Cause:
The browser could not start media capture with the input device even after the user gave permission, probably because another app or tab has reserved the input device.
' + +'
Solution:
The user should close all other apps and tabs that have reserved the input device and reload your app, or worst case, restart the browser.'; + }, + OverconstrainedError: error => { + return error.constraint === 'deviceId' + ? 'Cause:
Your saved microphone or camera is no longer available.

Solution:
Please make sure the input device is connected to the machine.' + : 'Cause:
Could not satisfy the requested media constraints. One of the reasons ' + + 'could be that your saved microphone or camera is no longer available.

Solution:
Please make sure the input device is connected to the machine.'; + }, + TypeError: () => { + return 'Cause:
navigator.mediaDevices does not exist.
' + + '
Solution:
If you\'re sure that the browser supports ' + + 'navigator.mediaDevices, make sure your app is being served ' + + 'from a secure context (localhost or an https domain).'; + } +}; + +/** + * Get a user friendly Error message. + * @param error - the Error for which a user friendly message is needed + * @returns {string} the user friendly message + */ +function getUserFriendlyError(error) { + const errorName = [error.name, error.constructor.name].find(errorName => { + return errorName in USER_FRIENDLY_ERRORS; + }); + return errorName ? USER_FRIENDLY_ERRORS[errorName](error) : error.message; +} + +module.exports = getUserFriendlyError; diff --git a/server/index.js b/server/index.js index 7bd92f36..e5408d84 100644 --- a/server/index.js +++ b/server/index.js @@ -9,40 +9,52 @@ */ require('dotenv').load(); -var http = require('http'); -var path = require('path'); -var AccessToken = require('twilio').jwt.AccessToken; -var VideoGrant = AccessToken.VideoGrant; -var express = require('express'); -var randomName = require('./randomname'); +const express = require('express'); +const http = require('http'); +const path = require('path'); +const { jwt: { AccessToken } } = require('twilio'); + +const VideoGrant = AccessToken.VideoGrant; + +// Max. period that a Participant is allowed to be in a Room (currently 14400 seconds or 4 hours) +const MAX_ALLOWED_SESSION_DURATION = 14400; // Create Express webapp. -var app = express(); +const app = express(); // Set up the paths for the examples. [ 'bandwidthconstraints', 'codecpreferences', + 'dominantspeaker', 'localvideofilter', 'localvideosnapshot', - 'mediadevices' -].forEach(function(example) { - var examplePath = path.join(__dirname, `../examples/${example}/public`); + 'mediadevices', + 'networkquality', + 'reconnection', + 'screenshare', + 'localmediacontrols', + 'remotereconnection', + 'datatracks', + 'manualrenderhint', + 'autorenderhint' +].forEach(example => { + const examplePath = path.join(__dirname, `../examples/${example}/public`); app.use(`/${example}`, express.static(examplePath)); }); // Set up the path for the quickstart. -var quickstartPath = path.join(__dirname, '../quickstart/public'); +const quickstartPath = path.join(__dirname, '../quickstart/public'); app.use('/quickstart', express.static(quickstartPath)); // Set up the path for the examples page. -var examplesPath = path.join(__dirname, '../examples'); +const examplesPath = path.join(__dirname, '../examples'); app.use('/examples', express.static(examplesPath)); /** * Default to the Quick Start application. */ -app.get('/', function(request, response) { +app.get('/', (request, response) => { response.redirect('/quickstart'); }); @@ -52,33 +64,31 @@ app.get('/', function(request, response) { * parameter. */ app.get('/token', function(request, response) { - var identity = randomName(); + const { identity } = request.query; // Create an access token which we will sign and return to the client, // containing the grant we just created. - var token = new AccessToken( + const token = new AccessToken( process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_API_KEY, - process.env.TWILIO_API_SECRET + process.env.TWILIO_API_SECRET, + { ttl: MAX_ALLOWED_SESSION_DURATION } ); // Assign the generated identity to the token. token.identity = identity; // Grant the access token Twilio Video capabilities. - var grant = new VideoGrant(); + const grant = new VideoGrant(); token.addGrant(grant); - // Serialize the token to a JWT string and include it in a JSON response. - response.send({ - identity: identity, - token: token.toJwt() - }); + // Serialize the token to a JWT string. + response.send(token.toJwt()); }); // Create http server and run it. -var server = http.createServer(app); -var port = process.env.PORT || 3000; +const server = http.createServer(app); +const port = process.env.PORT || 3000; server.listen(port, function() { console.log('Express server running on *:' + port); }); diff --git a/server/randomname.js b/server/randomname.js deleted file mode 100644 index 6e97afa1..00000000 --- a/server/randomname.js +++ /dev/null @@ -1,34 +0,0 @@ -'use strict'; - -var ADJECTIVES = [ - 'Abrasive', 'Brash', 'Callous', 'Daft', 'Eccentric', 'Fiesty', 'Golden', - 'Holy', 'Ignominious', 'Joltin', 'Killer', 'Luscious', 'Mushy', 'Nasty', - 'OldSchool', 'Pompous', 'Quiet', 'Rowdy', 'Sneaky', 'Tawdry', - 'Unique', 'Vivacious', 'Wicked', 'Xenophobic', 'Yawning', 'Zesty' -]; - -var FIRST_NAMES = [ - 'Anna', 'Bobby', 'Cameron', 'Danny', 'Emmett', 'Frida', 'Gracie', 'Hannah', - 'Isaac', 'Jenova', 'Kendra', 'Lando', 'Mufasa', 'Nate', 'Owen', 'Penny', - 'Quincy', 'Roddy', 'Samantha', 'Tammy', 'Ulysses', 'Victoria', 'Wendy', - 'Xander', 'Yolanda', 'Zelda' -]; - -var LAST_NAMES = [ - 'Anchorage', 'Berlin', 'Cucamonga', 'Davenport', 'Essex', 'Fresno', - 'Gunsight', 'Hanover', 'Indianapolis', 'Jamestown', 'Kane', 'Liberty', - 'Minneapolis', 'Nevis', 'Oakland', 'Portland', 'Quantico', 'Raleigh', - 'SaintPaul', 'Tulsa', 'Utica', 'Vail', 'Warsaw', 'XiaoJin', 'Yale', - 'Zimmerman' -]; - -function randomItem(array) { - var randomIndex = Math.floor(Math.random() * array.length); - return array[randomIndex]; -} - -module.exports = function randomName() { - return randomItem(ADJECTIVES) + - randomItem(FIRST_NAMES) + - randomItem(LAST_NAMES); -};