Build an Online Presentation System with Python, JavaScript, and Twilio Programmable Video

February 18, 2021
Written by
Grey Li
Contributor
Opinions expressed by Twilio contributors are their own

Build an Online Presentation System with Python, JavaScript, and Twilio Programmable Video

In this tutorial, we will implement an online presentation system where the presenter can share the screen contents and their video and audio in real-time, while attendees who join the presentation can watch.

For the back end portion of this application we will use Flask, a Python web application framework, while the front end will be built using vanilla JavaScript. The Twilio Programmable Video service will be used for video and audio streaming.

The final application is shown below:

Project demonstration

Tutorial requirements

To finish this tutorial, you will need to meet the following requirements:

  • A computer with Python 3.6 or newer version installed. If your operating system does not provide a Python package, download an official build from python.org. You can check it by typing the python or python3 command then press Enter in your terminal.
  • A free or paid Twilio account. If you use this link to register, you will receive a $10 credit when you upgrade to a paid account.

Project environment setup

First, we need to create a directory to save our project:

$ mkdir presentation

Then change into the directory, create a Python virtual environment with Python's venv module:

$ cd presentation
$ python3 -m venv venv

Note that in the last command above, your Python interpreter may be called python instead of python3 if you are using Windows. The second "venv" in the command above is the name of the virtual environment, a folder called "venv" will appear in our project directory after you executed this command.

Now we can activate the virtual environment we just created by using the activate script inside our virtual environment folder. If you are using Linux or macOS:

$ source venv/bin/activate

Or if you are using Windows:

$ venv\Scripts\activate

From now on, you will see a (venv) prompt in your terminal or command line window, to indicate that you have activated the environment successfully:

(venv) $ _

Next step is install the dependencies for our project, which are:

- Flask: A Python micro web framework.

- twilio: A library for communicating with the Twilio API.

- python-dotenv: A library for importing environment variables from .env  file.

Use the command below to install these packages:

(venv) $ pip install flask twilio python-dotenv

The last step is to generate a requirements file (normally named requirements.txt) to record the dependencies in the current environment so that you can replicate the same environment on other machines:

(venv) $ pip freeze > requirements.txt

The content of the requirements file will look like this:

certifi==2020.12.5
chardet==4.0.0
click==7.1.2
Flask==1.1.2
idna==2.10
itsdangerous==1.1.0
Jinja2==2.11.2
MarkupSafe==1.1.1
PyJWT==2.0.0
python-dotenv==0.15.0
pytz==2020.4
requests==2.25.1
six==1.15.0
twilio==6.50.1
urllib3==1.26.2
Werkzeug==1.0.1

Twilio service setup

To connect your application with Twilio, we have to create a file called .env, where we can save the needed Twilio account credentials as environment variables:

TWILIO_ACCOUNT_SID=your-twilio-account-sid
TWILIO_API_KEY=your-twilio-api-key
TWILIO_API_SECRET=your-twilio-api-secret

These values used to identify your account when you connect and talk to the Twilio servers. You can obtain them from your Twilio Console. You can find the Twilio Account SID field in your dashboard:

Twilio Account SID

The remaining two variables require a bit of extra work. First we need to find the "All products & Services" button on the left sidebar of the Twilio Console page, then find the "Programmable Video" service in the "Communications Cloud" section (click the "Pin" button to pin it on your left sidebar for convenient access). From there access the "API Keys" page on the "Tools" menu. Click the red "+" button to create a new API key for this project. Give it a friendly name (here I used presentation), and keep the "KEY TYPE" as "Standard".

Twilio API Key

After clicking the "Create API Key" button, you will see the API Key ("SID" field) and the API Secret ("SECRET" field):

API Key information

Copy and paste these values to the last two variables in your .env file.

Since these environment variables contain sensitive data, if you are going to use Git to manage your project, be sure to add .env to your .gitignore file to prevent it from ever being committed into your Git history.

Now everything is ready, let's start coding!

Implementing the front end page layout

In the Flask application, the HTML templates and static files will be saved in templates and static folders respectively. We will create these two folders at the root of the project directory:

$ mkdir templates
$ mkdir static

When we have multiple pages in a web application, we don’t need to write the same layout element (e.g. <head></head>, navigation bar, page footer, etc.) in every HTML template. With Jinja’s template inheritance, we can create a base template which contains the basic page elements and layout. Then we can define some Jinja blocks in the base template that will be filled or updated in the child templates. You can learn more about template inheritance at Flask’s documentation.

In the templates folder, we will create a base template to display the basic page layout called base.html. Below is the content of the templates/base.html file:

<!doctype html>
<html>

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="shortcut icon" href="https://www.twilio.com/marketing/bundles/marketing/img/favicons/favicon.ico">
    <title>Twilio Online Presentation</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>

<body>
    <h2>Flask & Twilio Online Presentation</h2>
    {% block main %}{% endblock %}

    {% block scripts %}
    <script src="https://media.twiliocdn.com/sdk/js/video/releases/2.3.0/twilio-video.min.js"></script>
    {% endblock %}
</body>

</html>

We introduced two static files in the code above. The style.css in the <head> section will be added later. This file will have the CSS rules to style our page.

<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">

At the bottom of the <body> element, we loaded the Twilio Video JavaScript SDK library. We imported this file from the CDN server provided by Twilio:

<script src="//media.twiliocdn.com/sdk/js/video/releases/2.3.0/twilio-video.min.js"></script>

We also created two blocks. The main block will house the main content of the page:

{% block main %}{% endblock %}

The script block will be used to add more JavaScript files in the child templates used for the presenter and attendee pages.

{% block scripts %}
<script src="https://media.twiliocdn.com/sdk/js/video/releases/2.3.0/twilio-video.min.js"></script>
{% endblock %}

Attendee user interface

Our application is going to have two main URLs, one for the presenter and one for attendees. Below you can see the web page that will be used for attendees. This will be in a template file called templates/index.html that you need to store in the templates folder of the project:

{% extends 'base.html' %}

{% block main %}
<form method="post">
    <button id="connect">Join</button>
</form>
<p id="status">Disconnected</p>
<div id="screen"><div id="presenter"></div></div>
{% endblock %}

{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='main.js') }}"></script>
{% endblock %}

Firstly, we use extends Jinja tag to declare that this template inherits from the base.html template added in the previous section.

Secondly, we have a main block to put our page content. In this block, we create a connection button to let the user join the presentation. The <p id=”status”> element will be used to display the connection status. The two <div> elements are where the video streams will be presented: one for the screen track, and one for the presenter video track.

Thirdly, we also have a scripts block, in order to append the attendee specific JavaScript to the corresponding block in the base template. The {{ super() }} allows us to preserve the content this block has in the base template.

The new JavaScript file that we are importing is called main.js. This is where the front end application for attendees will be added.

We will use the following CSS styles, which go in the static/style.css file. These will help to format the two video elements so that the video window is pinned to the top right corner of the screen window:

#screen {
    position: absolute;
    width: 1280px;
    height: 720px;
    background: lightgrey;
}

#presenter {
    position: absolute;
    right: 0;
    width: 320px;
    height: 240px;
    background: darkgrey;
}

#presenter video {
    width: 100%;
    height: 100%;
}

To help us distinguish the screen and video parts before the presentation starts, we give them different background colors: the screen window will be displayed in lightgrey, and the video window will be displayed in darkgrey.

In order to display the index page, we will need to render the HTML file with Flask. Copy the following code in a file named app.py in the top-level directory of the project:

from flask import Flask, render_template

app = Flask(__name__)


@app.route('/')
def index():
    return render_template('index.html')

In the code above, we created a Flask application instance called app, then registered a function as a route with the app.route() decorator. The index() view function is used for rendering the index page we just created.

Run the Flask server with the flask run command, as follows:

(venv) $ flask run
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

Now visit http://localhost:5000 on your web browser, and you will see our main application page:

Page layout for attendees

Flask can automatically restart and reload the application when code changes and display useful debug information for errors. To enable these features in your Flask application, we will need to set the environment variable FLASK_ENV to development. We can do this in a file named .flaskenv, which is used to store Flask-specific environment variables:

FLASK_ENV=development

Now when you run flask run the application starts in debug mode:

(venv) $ flask run
 * Environment: development
 * Debug mode: on
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 101-750-099
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

Presenter user interface

Now we will create a template page for the presenter called presenter.html. The content for this page is shown below. Save the presenter.html file to the templates directory.

{% extends 'base.html' %}

{% block main %}
<form method="post">
    <button id="connect">Start Presenting!</button>
</form>
<p id="status">Disconnected</p>
<div id="video"></div>
{% endblock %}

{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='presenter.js') }}"></script>
{% endblock %}

The content of presenter.html is very similar to the one we created for index.html. In the main block, we changed the text of the connection button to Start Presenting!. We also do not have the container element of screen share.

In the scripts block, we referenced a new JavaScript file for the presenter called presenter.js where we will write the presenter client logic.

To expose the presenter page, we need to create a route for it in app.py. Add the following code at the bottom of app.py:

@app.route('/present')
def present():
    return render_template('presenter.html')

For your reference, here is a diagram with the current project structure:

.
├── .env
├── .flaskenv
├── app.py
├── requirements.txt
├── static
│   ├── main.js
│   ├── presenter.js
│   └── style.css
├── templates
│   ├── base.html
│   ├── index.html
│   └── presenter.html
└── venv

Generating access tokens for clients

The JavaScript front end will need to connect to Twilio to access the streaming and sharing features of the Programmable Video service. To connect to the Twilio servers, the clients need to authenticate with an access token, which is generated in our server.

Below you can see an updated app.py that adds the login() view function to generate Twilio access tokens for clients.

import os
import uuid

from flask import Flask, request, render_template, session
from twilio.jwt.access_token import AccessToken
from twilio.jwt.access_token.grants import VideoGrant
from twilio.rest import Client

app = Flask(__name__)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev')

# get credentials from environment variables
account_sid = os.getenv('TWILIO_ACCOUNT_SID')
api_key = os.getenv('TWILIO_API_KEY')
api_secret = os.getenv('TWILIO_API_SECRET')

room_name = 'My Presentation'


@app.route('/')
def index():
    return render_template('index.html')


@app.route('/present')
def present():
    return render_template('presenter.html')


@app.route('/token', methods=['POST'])
def login():
    if request.args.get('present'):
        username = 'presenter'
    else:
        username = uuid.uuid4().hex
    session['username'] = username

    # create access token with credentials
    token = AccessToken(account_sid, api_key, api_secret, identity=username)
    # create a Video grant and add to token
    video_grant = VideoGrant(room=room_name)
    token.add_grant(video_grant)
    return {'token': token.to_jwt()}

Near the top of the file we retrieve the credential values that we saved in the .env file, which Flask will automatically import into the system environment when python-dotenv is installed. For this we use the os.getenv() function from the Python standard library:

account_sid = os.getenv('TWILIO_ACCOUNT_SID')
api_key = os.getenv('TWILIO_API_KEY')
api_secret = os.getenv('TWILIO_API_SECRET')

In the new /login route, we check if the request was sent with a query parameter present and in that case we assume that the request was sent by the presenter. The query variable can be obtained with request.args.get() method. If the request came from the presenter, then we set the username to presenter. Otherwise, we just generate a random username for the attendee with uuid.uuid4().hex. The username is stored in the user session, so that it can be recalled later.

if request.args.get('present'):
    username = 'presenter'
else:
    username = uuid.uuid4().hex
    session['username'] = username

The request variable is part of the flask package, while uuid is imported directly from the Python standard library.

To use Fask’s session object, we will need to set a secret key in the Flask application instance:

app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev')

The Twilio package for Python provides the AccessToken class, which is used to create access tokens. We pass account_sid, api_key, api_secret, and the identity as arguments:

token = AccessToken(account_sid, api_key, api_secret, identity=username)

We then use the VideoGrant class to provision the token with access to the video service. The room argument limits the token to a specific room:

video_grant = VideoGrant(room=room_name)
token.add_grant(video_grant)

The room name is defined in the global variable room_name:

room_name = 'My Presentation'

To complete the function, we generate the token and return a JSON response that contains it:

return {'token': token.to_jwt()}

Connecting the presenter to the presentation

To connect the presenter to the presentation, we will create a function to handle the connection logic. Add this code to a presenter.js file in the static directory:

const connectButton = document.getElementById('connect')
let connected = false

function connectButtonHandler(event) {
    event.preventDefault()
    if (!connected) {
        connectButton.disabled = true
        connectButton.innerHTML = 'Connecting...'
        connect().then(() => {
            connectButton.innerHTML = 'Stop Presentation'
            connectButton.disabled = false
        }).catch(error => {
            alert('Connection failed.')
            console.error(`Unable to connect: ${error.message}`)
            connectButton.innerHTML = 'Start Presenting!'
            connectButton.disabled = false
        })
    } else {
        disconnect()
        connectButton.innerHTML = 'Start Presenting!'
    }
}

connectButton.addEventListener('click', connectButtonHandler)

In the snippet above, we create a connectButtonHandler() function that will be triggered when the presenter clicks the “Start Presenting!” button. Note how the function is bound to the click event of the button in the last line.

To determine the current connection status, we will use a connected variable that is initialized to false. The connect button acts as a toggle, so based on the state of the connected variable it will initiate a connection to the video room, or disconnect from it.

When starting a connection, we call the connect() function to finish the actual connection process and eventually set connected to true. When disconnecting, we will call the disconnect() function to disconnect the user and end the presentation. The label and status of the connect button is always updated based on the connection status.

Now let's jump into the connect() function. Add the code below to the presenter.js file:

let room

function connect() {
    let promise = new Promise((resolve, reject) => {
        fetch('/token?present=true', {method: 'POST'}).then(res => res.json()).then(data => {
            return Twilio.Video.connect(data.token, {
                automaticSubscription: false
            })
        }).then(_room => {
            room = _room
            publishPresenterScreen()
            room.on('participantConnected', participantConnected)
            room.on('participantDisconnected', participantDisconnected)
            connected = true
            updateParticipantCount()
            resolve()
        }).catch(error => {
            console.error(`Unable to connect to Room: ${error.message}`)
            reject()
        })
    })
    return promise
}

In this function we return a promise, which will let the caller bind success and error callbacks to it. The result of the promise is controlled by the resolve() and reject() functions that are passed as arguments when creating the Promise object.

We begin the connection process by obtaining an access token from the server. For this we use the fetch() function to fire an AJAX request:

fetch('/token?present=true', {method: 'POST'})

Notice we pass the query string ?present=true in the URL to tell the server that this is a request from the presenter. We also set the request method to POST by passing an options object.

Next, we use then() method to continue the call on the return value of the fetch() call and turn the return value into JSON format with res => res.json(). Since the then() method will return a Promise object, we can bind additional then() clauses to it. In the subsequent then() call, we grab the token from the JSON response and connect the user to the video room by calling the Twilio.Video.connect() function from the twilio-video.js library, with the access token passed as an argument. We also pass an additional options object to set automaticSubscription to false. This is because by default, a participant in a video room subscribes to all media streams published by other participants. Since the presenter doesn’t need to subscribe to any other streams, we use this setting to overwrite the default subscribe-to-all rule. You can find more details about this in the Overriding the Default Rule section in the Track Subscriptions Documentation.

.then(res => res.json()).then(data => {
    return Twilio.Video.connect(data.token, {
        automaticSubscription: false
    })
})

In another subsequent .then() call, we catch the return value of the Twilio.Video.connect() function, which is the room the user is connected to. Here we save the _room object returned by Twilio.Video.connect() to variable room for future use:

.then(_room => {
    room = _room

    // …

}).catch(error => {
    console.error(`Unable to connect to Room: ${error.message}`)
    reject()
})

Next, we publish the presenter’s screen share track. We will talk about the details of the publishPresenterScreen() function later.

publishPresenterScreen()

In the remaining code, we define event handlers for when a participant connects or disconnects from the video room, update the state to connected, and finally resolve the promise.

room.on('participantConnected', participantConnected)
room.on('participantDisconnected', participantDisconnected)
connected = true
resolve()

Below you can see the two event handlers for participantConnected and participantDisconnected event, which also go in the static/presenter.js file:

function participantConnected(participant) {
    console.log(`${participant.identity} just joined the room.`)
    updateParticipantCount()
}

function participantDisconnected(participant) {
    console.log(`${participant.identity} left the room.`)
    updateParticipantCount()
}

In these two functions, we just call the updateParticipantCount() function, which updates the number of participants shown in the page. Add this function also in static/presenter.js:

function updateParticipantCount() {
    if (!connected) {
        status.innerHTML = 'Disconnected'
    } else {
        status.innerHTML = room.participants.size + ' participants are watching.'
    }
}

Publishing the presenter's video feed and screen

The video and audio tracks for the presenter will automatically be published to the video room upon connection, as this is the default. But we also want the presenter to see their own video. To display the video for the presenter, we will create a displayPresenterVideo function in static/presenter.js, which will be called when the presenter opens the page:

const videoContainer = document.getElementById('video')

function displayPresenterVideo() {
    Twilio.Video.createLocalVideoTrack().then(track => {
        videoContainer.appendChild(track.attach())
    })
}

displayPresenterVideo()

In this function, we call Twilio.Video.createLocalVideoTrack() to create the local video track. Once the video track is created, we append it to the video element of the page.

In addition to the video and audio tracks, we need to create a video track for the presenter’s screen and publish it to the video room. Below we define a publishPresenterScreen() function, which will be called when the presenter is connected. Add this function to presenter.js in the static directory:

let screenTrack

function publishPresenterScreen() {
    navigator.mediaDevices.getDisplayMedia({
        video: {
            width: 1280,
            height: 720
        }
    }).then(stream => {
        screenTrack = new Twilio.Video.LocalVideoTrack(stream.getTracks()[0], {name: 'screen'})
        room.localParticipant.publishTrack(screenTrack)
        screenTrack.mediaStreamTrack.onended = connectButtonHandler
    }).catch((error) => {
        alert('Could not share the screen.')
        console.error(`Unable to share screen: ${error.message}`)
    })
}

Here we use navigator.mediaDevices.getDisplayMedia() method exposed by web browsers to capture the screen stream via WebRTC (Web Real-Time Communication) technology, passing an options object to set the video height and width to fit the screen container element that we will use to display the feed to attendees. You can read more on screen sharing in MDN's Screen Capture API Documentation.

The navigator.mediaDevices.getDisplayMedia() method returns a Promise object. In the callback function, we create a video track object and save it to the variable screenTrack. We pass the screen sharing stream as an argument, and we also pass an options object that sets the name of the track to screen. Giving the screen track a custom track name is useful so that we can distinguish it from other tracks when displaying the page for the participants.

We then publish the screen track to the room with room.localParticipant.publishTrack() method, passing the screen track as an argument.

To complete the screen sharing set up, we bind the onended event of this stream to the connectButtonHandler function, so that the presentation ends if the presenter clicks the “Stop sharing” button:

Stop sharing button

Disconnecting the presenter

The connectButtonHandler() defined above works as a toggle. When it is invoked to disconnect the video call it calls a disconnect() function to disconnect the presenter from the room and end the presentation. Here is the disconnect() function for the presenter, which also goes in static/presenter.js:

function disconnect() {
    room.disconnect()
    connected = false
    updateParticipantCount()
    endPresentation()
}

This function is quite simple, we just call room.disconnect() to disconnect the user from the room. This will also unpublish the video and audio tracks. Then we set connected to false and call the updateParticipantCount() and the endPresentation() function.

In the endPresentation() function, also in static/presenter.js, we stop the screen share and unpublish the screen track from the room.:

function endPresentation() {
    console.log('The presentation is over.')
    // unpublish screen track
    room.localParticipant.unpublishTrack(screenTrack)
    screenTrack.stop()
    screenTrack = null
}

If the presenter refreshes the page or closes the page, the presentation may not reset correctly. To cover these rare cases, we will add a function to listen the window's beforeunload event in static/presenter.js, that will be called when the user refresh/close the browser tab:

window.addEventListener('beforeunload', () => {
    endPresentation()
})

Starting a presentation

Make sure that your Flask server is running, make sure the URL in your browser is http://localhost:5000/present in the refresh your page to force the browser to update all the files. You should now see your video stream. Then click the Start Presenting! button to begin a presentation. The browser will ask for your permission to enable the camera and the microphone:

Share your camera and microphone notice

Then you will be prompted to choose the screen/window/tab to share:

Screen sharing options

The final application when the presentation starts will look like this:

Presenter page

Connecting attendees to the presentation

In the following sections we are going to implement the attendee front end logic, which has some differences to the presenter, but overall follows the same structure. The JavaScript file that will have the attendee logic is going to be called main.js, and will also be stored in the static directory.

The connectButtonHandler function for participants is almost the same as the one we created for the presenter:

const connectButton = document.getElementById('connect')
let connected = false

function connectButtonHandler(event) {
    event.preventDefault()
    if (!connected) {
        connectButton.disabled = true
        connectButton.innerHTML = 'Connecting...'
        connect().then(() => {
            connectButton.innerHTML = 'Leave'
            connectButton.disabled = false
        }).catch(error => {
            alert('Connection failed.')
            console.error(`Unable to connect: ${error.message}`)
            connectButton.innerHTML = 'Join'
            connectButton.disabled = false
        })
    } else {
        disconnect()
        connectButton.innerHTML = 'Join'
    }
}

connectButton.addEventListener('click', connectButtonHandler)

However, the connect() function is a little different:

function connect() {
    let promise = new Promise((resolve, reject) => {
        fetch('/token', {method: 'POST'}).then(res => res.json()).then(data => {
            return Twilio.Video.connect(data.token, {
                automaticSubscription: false,
                audio: false,
                video: false
            })
        }).then(_room => {
            room = _room
            subscribe()
            room.participants.forEach(participantConnected)
            room.on('participantConnected', participantConnected)
            room.on('participantDisconnected', participantDisconnected)
            connected = true
            updateParticipantCount()
            resolve()
        }).catch(error => {
            console.error(`Unable to connect to Room: ${error.message}`)
            reject()
        })
    })
    return promise
}

When calling the Twilio.Video.connect() method, we set audio and video to false in the options argument, to prevent the audio and video from the participant from being published to the video room when connecting to the Twilio server. We also set automaticSubscription to false to disable the default subscribe-to-all subscribe rule since we only want the participant to subscribe the presenter’s tracks instead of every participant:

Twilio.Video.connect(data.token, {
    automaticSubscription: false,
    audio: false,
    video: false
})

Once the connection is made we call a subscribe() function to set the participant’s subscribe rule. Here is the definition of this function, which also goes in static/main.js:

function subscribe() {
    fetch('/subscribe', {method: 'POST'}).catch(error => {
        console.error(`Unable to set subscribe rule: ${error.message}`)
    })    
}

In this function, we just send a POST request to the URL /subscribe on our server, where the subscribe rule will be set. We will define the server route soon.

After setting the subscribe rule, we can receive the presenter’s tracks and display them on the attendee page. We iterate over the whole room.participants map, which stores the information of all the participants and call the participantConnected() function for each. We also set up handlers to receive callbacks when new participants join or leave the room in the future:

room.participants.forEach(participantConnected)
room.on('participantConnected', participantConnected)
room.on('participantDisconnected', participantDisconnected)

Here is the implementation of the participantConnected() function. Add it at the bottom of  static/main.js:

function participantConnected(participant) {
    console.log(`${participant.identity} just joined the room.`)
    // display presenter's tracks for new participant
    if (participant.identity == 'presenter') {
        participant.on('trackSubscribed', track => trackSubscribed(track))
        participant.on('trackUnsubscribed', trackUnsubscribed)
    }
    updateParticipantCount()
}

The participant.identity attribute stores the participant's username. If the joining participant is the presenter, then we define handlers for the trackSubscribed and trackUnsubscribed events, which would allow us to receive the video, audio and screen streams.

Here are the implementations of trackSubscribed() and trackUnsubscribed(). Add them to static/main.js:

const screenContainer = document.getElementById('screen')
const videoContainer = document.getElementById('presenter')

function trackSubscribed(track) {
    if (track.name == 'screen') {
        screenContainer.appendChild(track.attach())
    } else {
        videoContainer.appendChild(track.attach())
    }
}

function trackUnsubscribed(track) {
    track.detach().forEach(element => element.remove())
}

The trackSubscribed event will be triggered when the presenter track is published to the video room. The event handler receives the track object as an argument. To display the track to the participant, we append it to the related element container. We use the track.name attribute to assign the video track and the screen track into the right containers.

The trackUnsubscribed event will be triggered when the track is unpublished, which will happen when the presenter leaves the video room. In the trackUnsubscribed() callback function, we detach the track from the HTML element and remove the corresponding media element.

Finally, the attendee logic also uses the updateParticipantCount() function, which is identical to the one used in the presenter logic. Add a copy to static/main.js:

function updateParticipantCount() {
    if (!connected) {
        status.innerHTML = 'Disconnected'
    } else {
        status.innerHTML = room.participants.size + ' participants are watching.'
    }
}

To complete the attendee connection logic we have to add the /subscribe route to our Flask server. Add the following view function at the bottom of app.py to handle the subscribe rule update:

@app.route('/subscribe', methods=['POST'])
def set_subscribe_rule():
    username = session['username']
    client = Client(api_key, api_secret)
    client.video.rooms(room_name).participants.get(username)\
        .subscribe_rules.update(
            rules=[
                {'type': 'include', 'publisher': 'presenter'}
            ]
        )
    return '', 204

To set the subscribe rule for a participant, we will need to create a Twilio Client object with twilio.rest.Client class and passing API Key and API Secret as arguments:

client = Client(api_key, api_secret)

We obtain the username from the Flask user session. To update the subscribe rule for a specific user in a specific room, we use the client.video.rooms(room_name).subscribe_rules.update() method, passing the room_name variable and the username we acquired from the session object.

username = session['username']
client.video.rooms(room_name).participants.get(username)\
.subscribe_rules.update(
    rules = [
        {'type': 'include', 'publisher': 'presenter'}
    ]
 )

The update() method accepts a list of rules in the rules keyword argument. Each rule is a dictionary in the following format:

{'type': rule_type, filter_name: filter_value, filter_name: filter_value, ...}

Since we only need the participant to subscribe to the presenter’s tracks, we use the include rule type and set the publisher filter to the presenter’s username, which is presenter.

You can learn more about the subscribe rule at the Specifying Subscribe Rules section in the Twilio Subscriptions documentation.

Disconnecting participants

To disconnect the participants from the presentation, we will create a disconnect() function in static/main.js:

function disconnect() {
    room.disconnect()
    connected = false
    updateParticipantCount()
}

In this function, we also need to call updateParticipantCount() to update the participant count message to show that we are disconnected.

We also need to define a participantDisconnected() event handler in static/main.js:

function participantDisconnected(participant) {
    console.log(`${participant.identity} left the room.`)
    if (participant.identity == 'presenter') {
        alert('The presentation is over.')
    }
    updateParticipantCount()
}

Here we add a notification for the participants when the presenter leaves the presentation.

At this point the logic for the presenter and the attendees is complete. You can open two web browser tabs on your computer, connect to http://localhost:5000/present in one, and to http://localhost:5000 in the other and test the two sides of this application.

This is how the attendee’s user interface looks like:

Attendee&#x27;s page

Host your online presentation with ngrok

Congratulations! We have finished the whole application. If you want to host a real online presentation and let your friends or coworkers join, you have to make your application public on the Internet. Instead of deploying your application to a web hosting service, we can use a tunnel forwarding tool to expose your local server to the Internet. While this isn’t as robust as a full deployment on a host, it is a good and simple solution to test the application. Here we will use ngrok, we can install it via a third-party Python package called pyngrok. Make sure the terminal on which you run the following command has the Python virtual environment activated:

pip install pyngrok

Make sure your application is still running, then in a different terminal window, activate the virtual environment and type the following commands to let ngrok expose http://localhost:5000 to the Internet:

$ ngrok http 5000

Pyngrok will download and install the ngrok client when you execute the ngrok command for the first time. Then It will output a command-line dashboard like this:

ngrok screenshot

You can find a randomly generated public URL (https://xxx.ngrok.io) in the Forwarding section. Share this URL to your friends, tell them to join your online presentation! The presenter will use the https://xxx.ngrok.io/present URL to access the presenter page.

Conclusion

In this tutorial, we only explored some basics of the Twilio Video service. You can learn more about it in the Twilio Programmable Video Documentation. The complete example code for this tutorial can be found on GitHub. Please feel free to fork the project and add more features. Enjoy the adventure!

This tutorial is inspired by two related tutorials written by Miguel Grinberg. If you want to learn more details, check out the following tutorials:

Grey Li is a freelance web developer and technical writer. He is also a maintainer of the Flask web application framework. You can learn more about him at his website, GitHub, and Twitter.