');
35 | $container.append($user).append($message);
36 | $chatWindow.append($container);
37 | $chatWindow.scrollTop($chatWindow[0].scrollHeight);
38 | }
39 |
40 | // Alert the user they have been assigned a random username
41 | print('Logging in...');
42 |
43 | // Get an access token for the current user, passing a username (identity)
44 | $.getJSON('/token', function(data) {
45 |
46 |
47 | // Initialize the Chat client
48 | Twilio.Chat.Client.create(data.token).then(client => {
49 | console.log('Created chat client');
50 | chatClient = client;
51 | chatClient.getSubscribedChannels().then(createOrJoinGeneralChannel);
52 |
53 | // when the access token is about to expire, refresh it
54 | chatClient.on('tokenAboutToExpire', function() {
55 | refreshToken(username);
56 | });
57 |
58 | // if the access token already expired, refresh it
59 | chatClient.on('tokenExpired', function() {
60 | refreshToken(username);
61 | });
62 |
63 | // Alert the user they have been assigned a random username
64 | username = data.identity;
65 | print('You have been assigned a random username of: '
66 | + '
' + username + '', true);
67 |
68 | }).catch(error => {
69 | console.error(error);
70 | print('There was an error creating the chat client:
' + error, true);
71 | print('Please check your .env file.', false);
72 | });
73 | });
74 |
75 | function refreshToken(identity) {
76 | console.log('Token about to expire');
77 | // Make a secure request to your backend to retrieve a refreshed access token.
78 | // Use an authentication mechanism to prevent token exposure to 3rd parties.
79 | $.getJSON('/token/' + identity, function(data) {
80 | console.log('updated token for chat client');
81 | chatClient.updateToken(data.token);
82 | });
83 | }
84 |
85 | function createOrJoinGeneralChannel() {
86 | // Get the general chat channel, which is where all the messages are
87 | // sent in this simple application
88 | print('Attempting to join "general" chat channel...');
89 | chatClient.getChannelByUniqueName('general')
90 | .then(function(channel) {
91 | generalChannel = channel;
92 | console.log('Found general channel:');
93 | console.log(generalChannel);
94 | setupChannel();
95 | }).catch(function() {
96 | // If it doesn't exist, let's create it
97 | console.log('Creating general channel');
98 | chatClient.createChannel({
99 | uniqueName: 'general',
100 | friendlyName: 'General Chat Channel'
101 | }).then(function(channel) {
102 | console.log('Created general channel:');
103 | console.log(channel);
104 | generalChannel = channel;
105 | setupChannel();
106 | }).catch(function(channel) {
107 | console.log('Channel could not be created:');
108 | console.log(channel);
109 | });
110 | });
111 | }
112 |
113 | // Set up channel after it has been found
114 | function setupChannel() {
115 | // Join the general channel
116 | generalChannel.join().then(function(channel) {
117 | print('Joined channel as '
118 | + '
' + username + '.', true);
119 | });
120 |
121 | // Listen for new messages sent to the channel
122 | generalChannel.on('messageAdded', function(message) {
123 | printMessage(message.author, message.body);
124 | });
125 | }
126 |
127 | // Send a new message to the general channel
128 | var $input = $('#chat-input');
129 | $input.on('keydown', function(e) {
130 |
131 | if (e.keyCode == 13) {
132 | if (generalChannel === undefined) {
133 | print('The Chat Service is not configured. Please check your .env file.', false);
134 | return;
135 | }
136 | generalChannel.sendMessage($input.val())
137 | $input.val('');
138 | }
139 | });
140 | });
141 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | # Twilio SDK Starter Application for Python
6 |
7 | This sample project demonstrates how to use Twilio APIs in a Python web
8 | application. Once the app is up and running, check out [the home page](http://localhost:5000)
9 | to see which demos you can run. You'll find examples for [Chat](https://www.twilio.com/chat),
10 | [Video](https://www.twilio.com/video), [Sync](https://www.twilio.com/sync), and more.
11 |
12 | Let's get started!
13 |
14 | ## Configure the sample application
15 |
16 | To run the application, you'll need to gather your Twilio account credentials and configure them
17 | in a file named `.env`. To create this file from an example template, do the following in your
18 | Terminal.
19 |
20 | ```bash
21 | cp .env.example .env
22 | ```
23 |
24 | Open `.env` in your favorite text editor and configure the following values. The application runs
25 | by default in `production` environment. Feel free to update `DEBUG` to True if needed.
26 |
27 | ### Configure account information
28 |
29 | Every sample in the demo requires some basic credentials from your Twilio account. Configure these first.
30 |
31 | | Config Value | Description |
32 | | :------------- |:------------- |
33 | `TWILIO_ACCOUNT_SID` | Your primary Twilio account identifier - find this [in the console here](https://www.twilio.com/console).
34 | `TWILIO_API_KEY` | Used to authenticate - [generate one here](https://www.twilio.com/console/dev-tools/api-keys).
35 | `TWILIO_API_SECRET` | Used to authenticate - [just like the above, you'll get one here](https://www.twilio.com/console/dev-tools/api-keys).
36 |
37 | #### A Note on API Keys
38 |
39 | When you generate an API key pair at the URLs above, your API Secret will only be shown once -
40 | make sure to save this information in a secure location, or possibly your `~/.bash_profile`.
41 |
42 | ### Configure product-specific settings
43 |
44 | Depending on which demos you'd like to run, you may need to configure a few more values in your `.env` file.
45 |
46 | #### Configuring Twilio Sync
47 |
48 | Twilio Sync works out of the box, using default settings per account. Once you have your API keys configured, run the application (see below) and [open a browser](http://localhost:5000/sync)!
49 |
50 | #### Configuring Twilio Chat
51 |
52 | In addition to the above, you'll need to [generate a Chat Service](https://www.twilio.com/console/chat/services) in the Twilio Console. Put the result in your `.env` file.
53 |
54 | | Config Value | Where to get one. |
55 | | :------------- |:------------- |
56 | `TWILIO_CHAT_SERVICE_SID` | Generate one in the [Twilio Chat console](https://www.twilio.com/console/chat/services)
57 |
58 | With this in place, run the application (see below) and [open a browser](http://localhost:5000/chat)!
59 |
60 | #### Configuring Twilio Notify
61 |
62 | You will need to create a Notify Service and add at least one credential on the [Mobile Push Credential screen](https://www.twilio.com/console/notify/credentials) (such as Apple Push Notification Service or Firebase Cloud Messaging for Android) to send notifications using Notify.
63 |
64 | | Config Value | Where to get one. |
65 | | :------------- |:------------- |
66 | `TWILIO_NOTIFICATION_SERVICE_SID` | Generate one in the [Notify Console](https://www.twilio.com/console/notify/services) and put this in your `.env` file.
67 | A Push Credential | Generate one with Apple or Google and [configure it as a Notify credential](https://www.twilio.com/console/notify/credentials).
68 |
69 | Once you've done that, run the application (see below) and [open a browser](http://localhost:5000/notify)!
70 |
71 | ## Run the sample application
72 |
73 | This application uses the lightweight [Flask Framework](http://flask.pocoo.org/).
74 |
75 | We need to set up your Python environment. Install `virtualenv` via `pip`:
76 |
77 | ```bash
78 | pip install virtualenv
79 | ```
80 |
81 | Next, we need to install our dependencies:
82 |
83 | ```bash
84 | virtualenv venv
85 | source venv/bin/activate
86 | pip install -r requirements.txt
87 | ```
88 |
89 | Now we should be all set! Run the application using the `python` command.
90 |
91 | ```bash
92 | python app.py
93 | ```
94 |
95 | Your application should now be running at [http://localhost:5000](http://localhost:5000). When you're finished, deactivate your virtual environment using `deactivate`.
96 |
97 | 
98 |
99 | ## Running the SDK Starter Kit with ngrok
100 |
101 | If you are going to connect to this SDK Starter Kit with a mobile app (and you should try it out!), your phone won't be able to access localhost directly. You'll need to create a publicly accessible URL using a tool like [ngrok](https://ngrok.com/) to send HTTP/HTTPS traffic to a server running on your localhost. Use HTTPS to make web connections that retrieve a Twilio access token.
102 |
103 | ```bash
104 | ngrok http 5000
105 | ```
106 |
107 | ## License
108 | MIT
109 |
--------------------------------------------------------------------------------
/static/video/quickstart.js:
--------------------------------------------------------------------------------
1 | var activeRoom;
2 | var previewTracks;
3 | var identity;
4 | var roomName;
5 |
6 | function attachTracks(tracks, container) {
7 | tracks.forEach(function(track) {
8 | container.appendChild(track.attach());
9 | });
10 | }
11 |
12 | function attachParticipantTracks(participant, container) {
13 | var tracks = Array.from(participant.tracks.values());
14 | attachTracks(tracks, container);
15 | }
16 |
17 | function detachTracks(tracks) {
18 | tracks.forEach(function(track) {
19 | track.detach().forEach(function(detachedElement) {
20 | detachedElement.remove();
21 | });
22 | });
23 | }
24 |
25 | function detachParticipantTracks(participant) {
26 | var tracks = Array.from(participant.tracks.values());
27 | detachTracks(tracks);
28 | }
29 |
30 | // Check for WebRTC
31 | if (!navigator.webkitGetUserMedia && !navigator.mozGetUserMedia) {
32 | alert('WebRTC is not available in your browser.');
33 | }
34 |
35 | // When we are about to transition away from this page, disconnect
36 | // from the room, if joined.
37 | window.addEventListener('beforeunload', leaveRoomIfJoined);
38 |
39 | $.getJSON('/token', function(data) {
40 | identity = data.identity;
41 |
42 | document.getElementById('room-controls').style.display = 'block';
43 |
44 | // Bind button to join room
45 | document.getElementById('button-join').onclick = function () {
46 | roomName = document.getElementById('room-name').value;
47 | if (roomName) {
48 | log("Joining room '" + roomName + "'...");
49 |
50 | var connectOptions = { name: roomName, logLevel: 'debug' };
51 | if (previewTracks) {
52 | connectOptions.tracks = previewTracks;
53 | }
54 |
55 | Twilio.Video.connect(data.token, connectOptions).then(roomJoined, function(error) {
56 | log('Could not connect to Twilio: ' + error.message);
57 | });
58 | } else {
59 | alert('Please enter a room name.');
60 | }
61 | };
62 |
63 | // Bind button to leave room
64 | document.getElementById('button-leave').onclick = function () {
65 | log('Leaving room...');
66 | activeRoom.disconnect();
67 | };
68 | });
69 |
70 | // Successfully connected!
71 | function roomJoined(room) {
72 | activeRoom = room;
73 |
74 | log("Joined as '" + identity + "'");
75 | document.getElementById('button-join').style.display = 'none';
76 | document.getElementById('button-leave').style.display = 'inline';
77 |
78 | // Draw local video, if not already previewing
79 | var previewContainer = document.getElementById('local-media');
80 | if (!previewContainer.querySelector('video')) {
81 | attachParticipantTracks(room.localParticipant, previewContainer);
82 | }
83 |
84 | room.participants.forEach(function(participant) {
85 | log("Already in Room: '" + participant.identity + "'");
86 | var previewContainer = document.getElementById('remote-media');
87 | attachParticipantTracks(participant, previewContainer);
88 | });
89 |
90 | // When a participant joins, draw their video on screen
91 | room.on('participantConnected', function(participant) {
92 | log("Joining: '" + participant.identity + "'");
93 | });
94 |
95 | room.on('trackAdded', function(track, participant) {
96 | log(participant.identity + " added track: " + track.kind);
97 | var previewContainer = document.getElementById('remote-media');
98 | attachTracks([track], previewContainer);
99 | });
100 |
101 | room.on('trackRemoved', function(track, participant) {
102 | log(participant.identity + " removed track: " + track.kind);
103 | detachTracks([track]);
104 | });
105 |
106 | // When a participant disconnects, note in log
107 | room.on('participantDisconnected', function(participant) {
108 | log("Participant '" + participant.identity + "' left the room");
109 | detachParticipantTracks(participant);
110 | });
111 |
112 | // When we are disconnected, stop capturing local video
113 | // Also remove media for all remote participants
114 | room.on('disconnected', function() {
115 | log('Left');
116 | detachParticipantTracks(room.localParticipant);
117 | room.participants.forEach(detachParticipantTracks);
118 | activeRoom = null;
119 | document.getElementById('button-join').style.display = 'inline';
120 | document.getElementById('button-leave').style.display = 'none';
121 | });
122 | }
123 |
124 | // Local video preview
125 | document.getElementById('button-preview').onclick = function() {
126 | var localTracksPromise = previewTracks
127 | ? Promise.resolve(previewTracks)
128 | : Twilio.Video.createLocalTracks();
129 |
130 | localTracksPromise.then(function(tracks) {
131 | previewTracks = tracks;
132 | var previewContainer = document.getElementById('local-media');
133 | if (!previewContainer.querySelector('video')) {
134 | attachTracks(tracks, previewContainer);
135 | }
136 | }, function(error) {
137 | console.error('Unable to access local media', error);
138 | log('Unable to access Camera and Microphone');
139 | });
140 | };
141 |
142 | // Activity log
143 | function log(message) {
144 | var logDiv = document.getElementById('log');
145 | logDiv.innerHTML += '
> ' + message + '
';
146 | logDiv.scrollTop = logDiv.scrollHeight;
147 | }
148 |
149 | function leaveRoomIfJoined() {
150 | if (activeRoom) {
151 | activeRoom.disconnect();
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | import os
2 | from flask import Flask, jsonify, request
3 | from faker import Faker
4 | from twilio.rest import Client
5 | from twilio.jwt.access_token import AccessToken
6 | from twilio.jwt.access_token.grants import (
7 | SyncGrant,
8 | VideoGrant,
9 | ChatGrant
10 | )
11 | from dotenv import load_dotenv
12 | from os.path import join, dirname
13 | from inflection import underscore
14 |
15 | # Convert keys to snake_case to conform with the twilio-python api definition contract
16 | def snake_case_keys(somedict):
17 | snake_case_dict = {}
18 | for key, value in somedict.items():
19 | snake_case_dict[underscore(key)] = value
20 | return snake_case_dict
21 |
22 | app = Flask(__name__)
23 | fake = Faker()
24 | dotenv_path = join(dirname(__file__), '.env')
25 | load_dotenv(dotenv_path)
26 |
27 | @app.route('/')
28 | def index():
29 | return app.send_static_file('index.html')
30 |
31 | @app.route('/video/')
32 | def video():
33 | return app.send_static_file('video/index.html')
34 |
35 | @app.route('/sync/')
36 | def sync():
37 | return app.send_static_file('sync/index.html')
38 |
39 | @app.route('/notify/')
40 | def notify():
41 | return app.send_static_file('notify/index.html')
42 |
43 | @app.route('/chat/')
44 | def chat():
45 | return app.send_static_file('chat/index.html')
46 |
47 | # Basic health check - check environment variables have been configured
48 | # correctly
49 | @app.route('/config')
50 | def config():
51 | return jsonify(
52 | TWILIO_ACCOUNT_SID=os.environ['TWILIO_ACCOUNT_SID'],
53 | TWILIO_NOTIFICATION_SERVICE_SID=os.environ.get('TWILIO_NOTIFICATION_SERVICE_SID', None),
54 | TWILIO_API_KEY=os.environ['TWILIO_API_KEY'],
55 | TWILIO_API_SECRET=bool(os.environ['TWILIO_API_SECRET']),
56 | TWILIO_CHAT_SERVICE_SID=os.environ.get('TWILIO_CHAT_SERVICE_SID', None),
57 | TWILIO_SYNC_SERVICE_SID=os.environ.get('TWILIO_SYNC_SERVICE_SID', 'default'),
58 | )
59 |
60 | @app.route('/token', methods=['GET'])
61 | def randomToken():
62 | return generateToken(fake.user_name())
63 |
64 |
65 | @app.route('/token', methods=['POST'])
66 | def createToken():
67 | # Get the request json or form data
68 | content = request.get_json() or request.form
69 | # get the identity from the request, or make one up
70 | identity = content.get('identity', fake.user_name())
71 | return generateToken(identity)
72 |
73 | @app.route('/token/
', methods=['POST', 'GET'])
74 | def token(identity):
75 | return generateToken(identity)
76 |
77 | def generateToken(identity):
78 | # get credentials for environment variables
79 | account_sid = os.environ['TWILIO_ACCOUNT_SID']
80 | api_key = os.environ['TWILIO_API_KEY']
81 | api_secret = os.environ['TWILIO_API_SECRET']
82 | sync_service_sid = os.environ.get('TWILIO_SYNC_SERVICE_SID', 'default')
83 | chat_service_sid = os.environ.get('TWILIO_CHAT_SERVICE_SID', None)
84 |
85 | # Create access token with credentials
86 | token = AccessToken(account_sid, api_key, api_secret, identity=identity)
87 |
88 | # Create a Sync grant and add to token
89 | if sync_service_sid:
90 | sync_grant = SyncGrant(service_sid=sync_service_sid)
91 | token.add_grant(sync_grant)
92 |
93 | # Create a Video grant and add to token
94 | video_grant = VideoGrant()
95 | token.add_grant(video_grant)
96 |
97 | # Create an Chat grant and add to token
98 | if chat_service_sid:
99 | chat_grant = ChatGrant(service_sid=chat_service_sid)
100 | token.add_grant(chat_grant)
101 |
102 | # Return token info as JSON
103 | return jsonify(identity=identity, token=token.to_jwt())
104 |
105 |
106 |
107 |
108 | # Notify - create a device binding from a POST HTTP request
109 | @app.route('/register', methods=['POST'])
110 | def register():
111 | # get credentials for environment variables
112 | account_sid = os.environ['TWILIO_ACCOUNT_SID']
113 | api_key = os.environ['TWILIO_API_KEY']
114 | api_secret = os.environ['TWILIO_API_SECRET']
115 | service_sid = os.environ['TWILIO_NOTIFICATION_SERVICE_SID']
116 |
117 | # Initialize the Twilio client
118 | client = Client(api_key, api_secret, account_sid)
119 |
120 | # Body content
121 | content = request.get_json()
122 |
123 | content = snake_case_keys(content)
124 |
125 | # Get a reference to the notification service
126 | service = client.notify.services(service_sid)
127 |
128 | # Create the binding
129 | binding = service.bindings.create(**content)
130 |
131 | print(binding)
132 |
133 | # Return success message
134 | return jsonify(message="Binding created!")
135 |
136 | # Notify - send a notification from a POST HTTP request
137 | @app.route('/send-notification', methods=['POST'])
138 | def send_notification():
139 | # get credentials for environment variables
140 | account_sid = os.environ['TWILIO_ACCOUNT_SID']
141 | api_key = os.environ['TWILIO_API_KEY']
142 | api_secret = os.environ['TWILIO_API_SECRET']
143 | service_sid = os.environ['TWILIO_NOTIFICATION_SERVICE_SID']
144 |
145 | # Initialize the Twilio client
146 | client = Client(api_key, api_secret, account_sid)
147 |
148 | service = client.notify.services(service_sid)
149 |
150 | # Get the request json or form data
151 | content = request.get_json() if request.get_json() else request.form
152 |
153 | content = snake_case_keys(content)
154 |
155 | # Create a notification with the given form data
156 | notification = service.notifications.create(**content)
157 |
158 | return jsonify(message="Notification created!")
159 |
160 | @app.route('/')
161 | def static_file(path):
162 | return app.send_static_file(path)
163 |
164 | # Ensure that the Sync Default Service is provisioned
165 | def provision_sync_default_service():
166 | client = Client(os.environ['TWILIO_API_KEY'], os.environ['TWILIO_API_SECRET'], os.environ['TWILIO_ACCOUNT_SID'])
167 | client.sync.services('default').fetch()
168 |
169 | if __name__ == '__main__':
170 | provision_sync_default_service()
171 | app.run(debug=os.environ['DEBUG'], host='0.0.0.0')
172 |
--------------------------------------------------------------------------------
/static/video/site.css:
--------------------------------------------------------------------------------
1 | @import url(https://fonts.googleapis.com/css?family=Share+Tech+Mono);
2 |
3 | body,
4 | p {
5 | padding: 0;
6 | margin: 0;
7 | }
8 |
9 | body {
10 | background: #272726;
11 | }
12 |
13 | div#remote-media {
14 | height: 43%;
15 | width: 100%;
16 | background-color: #fff;
17 | text-align: center;
18 | margin: auto;
19 | }
20 |
21 | div#remote-media video {
22 | border: 1px solid #272726;
23 | margin: 3em 2em;
24 | height: 70%;
25 | max-width: 27% !important;
26 | background-color: #272726;
27 | background-repeat: no-repeat;
28 | }
29 |
30 | div#controls {
31 | padding: 3em;
32 | max-width: 1200px;
33 | margin: 0 auto;
34 | }
35 |
36 | div#controls div {
37 | float: left;
38 | }
39 |
40 | div#controls div#room-controls,
41 | div#controls div#preview {
42 | width: 16em;
43 | margin: 0 1.5em;
44 | text-align: center;
45 | }
46 |
47 | div#controls p.instructions {
48 | text-align: left;
49 | margin-bottom: 1em;
50 | font-family: Helvetica-LightOblique, Helvetica, sans-serif;
51 | font-style: oblique;
52 | font-size: 1.25em;
53 | color: #777776;
54 | }
55 |
56 | div#controls button {
57 | width: 15em;
58 | height: 2.5em;
59 | margin-top: 1.75em;
60 | border-radius: 1em;
61 | font-family: "Helvetica Light", Helvetica, sans-serif;
62 | font-size: .8em;
63 | font-weight: lighter;
64 | outline: 0;
65 | }
66 |
67 | div#controls div#room-controls input {
68 | font-family: Helvetica-LightOblique, Helvetica, sans-serif;
69 | font-style: oblique;
70 | font-size: 1em;
71 | }
72 |
73 | div#controls button:active {
74 | position: relative;
75 | top: 1px;
76 | }
77 |
78 | div#controls div#preview div#local-media {
79 | width: 270px;
80 | height: 202px;
81 | border: 1px solid #cececc;
82 | box-sizing: border-box;
83 | background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjgwcHgiIGhlaWdodD0iODBweCIgdmlld0JveD0iMCAwIDgwIDgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbG5zOnNrZXRjaD0iaHR0cDovL3d3dy5ib2hlbWlhbmNvZGluZy5jb20vc2tldGNoL25zIj4KICAgIDwhLS0gR2VuZXJhdG9yOiBTa2V0Y2ggMy4zLjEgKDEyMDAyKSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5GaWxsIDUxICsgRmlsbCA1MjwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxkZWZzPjwvZGVmcz4KICAgIDxnIGlkPSJQYWdlLTEiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHNrZXRjaDp0eXBlPSJNU1BhZ2UiPgogICAgICAgIDxnIGlkPSJjdW1tYWNrIiBza2V0Y2g6dHlwZT0iTVNMYXllckdyb3VwIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTU5LjAwMDAwMCwgLTE3NDYuMDAwMDAwKSIgZmlsbD0iI0ZGRkZGRiI+CiAgICAgICAgICAgIDxnIGlkPSJGaWxsLTUxLSstRmlsbC01MiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTU5LjAwMDAwMCwgMTc0Ni4wMDAwMDApIiBza2V0Y2g6dHlwZT0iTVNTaGFwZUdyb3VwIj4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0zOS42ODYsMC43MyBDMTcuODUsMC43MyAwLjA4NSwxOC41IDAuMDg1LDQwLjMzIEMwLjA4NSw2Mi4xNyAxNy44NSw3OS45MyAzOS42ODYsNzkuOTMgQzYxLjUyMiw3OS45MyA3OS4yODcsNjIuMTcgNzkuMjg3LDQwLjMzIEM3OS4yODcsMTguNSA2MS41MjIsMC43MyAzOS42ODYsMC43MyBMMzkuNjg2LDAuNzMgWiBNMzkuNjg2LDEuNzMgQzYxLjAwNSwxLjczIDc4LjI4NywxOS4wMiA3OC4yODcsNDAuMzMgQzc4LjI4Nyw2MS42NSA2MS4wMDUsNzguOTMgMzkuNjg2LDc4LjkzIEMxOC4zNjcsNzguOTMgMS4wODUsNjEuNjUgMS4wODUsNDAuMzMgQzEuMDg1LDE5LjAyIDE4LjM2NywxLjczIDM5LjY4NiwxLjczIEwzOS42ODYsMS43MyBaIiBpZD0iRmlsbC01MSI+PC9wYXRoPgogICAgICAgICAgICAgICAgPHBhdGggZD0iTTQ3Ljk2LDUzLjMzNSBMNDcuOTYsNTIuODM1IEwyMC4wOTMsNTIuODM1IEwyMC4wOTMsMjcuODI1IEw0Ny40NiwyNy44MjUgTDQ3LjQ2LDM4LjI1NSBMNTkuMjc5LDMwLjgwNSBMNTkuMjc5LDQ5Ljg1NSBMNDcuNDYsNDIuNDA1IEw0Ny40Niw1My4zMzUgTDQ3Ljk2LDUzLjMzNSBMNDcuOTYsNTIuODM1IEw0Ny45Niw1My4zMzUgTDQ4LjQ2LDUzLjMzNSBMNDguNDYsNDQuMjE1IEw2MC4yNzksNTEuNjY1IEw2MC4yNzksMjguOTk1IEw0OC40NiwzNi40NDUgTDQ4LjQ2LDI2LjgyNSBMMTkuMDkzLDI2LjgyNSBMMTkuMDkzLDUzLjgzNSBMNDguNDYsNTMuODM1IEw0OC40Niw1My4zMzUgTDQ3Ljk2LDUzLjMzNSIgaWQ9IkZpbGwtNTIiPjwvcGF0aD4KICAgICAgICAgICAgPC9nPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+);
84 | background-position: center;
85 | background-repeat: no-repeat;
86 | margin: 0 auto;
87 | }
88 |
89 | div#controls div#preview div#local-media video {
90 | max-width: 100%;
91 | max-height: 100%;
92 | border: none;
93 | }
94 |
95 | div#controls div#preview button#button-preview {
96 | background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjE3cHgiIGhlaWdodD0iMTJweCIgdmlld0JveD0iMCAwIDE3IDEyIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbG5zOnNrZXRjaD0iaHR0cDovL3d3dy5ib2hlbWlhbmNvZGluZy5jb20vc2tldGNoL25zIj4KICAgIDwhLS0gR2VuZXJhdG9yOiBTa2V0Y2ggMy4zLjEgKDEyMDAyKSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5GaWxsIDM0PC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGRlZnM+PC9kZWZzPgogICAgPGcgaWQ9IlBhZ2UtMSIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc2tldGNoOnR5cGU9Ik1TUGFnZSI+CiAgICAgICAgPGcgaWQ9ImN1bW1hY2siIHNrZXRjaDp0eXBlPSJNU0xheWVyR3JvdXAiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xMjUuMDAwMDAwLCAtMTkwOS4wMDAwMDApIiBmaWxsPSIjMEEwQjA5Ij4KICAgICAgICAgICAgPHBhdGggZD0iTTEzNi40NzEsMTkxOS44NyBMMTM2LjQ3MSwxOTE5LjYyIEwxMjUuNzY3LDE5MTkuNjIgTDEyNS43NjcsMTkxMC4wOCBMMTM2LjIyMSwxOTEwLjA4IEwxMzYuMjIxLDE5MTQuMTUgTDE0MC43ODUsMTkxMS4yNyBMMTQwLjc4NSwxOTE4LjQyIEwxMzYuMjIxLDE5MTUuNTUgTDEzNi4yMjEsMTkxOS44NyBMMTM2LjQ3MSwxOTE5Ljg3IEwxMzYuNDcxLDE5MTkuNjIgTDEzNi40NzEsMTkxOS44NyBMMTM2LjcyMSwxOTE5Ljg3IEwxMzYuNzIxLDE5MTYuNDUgTDE0MS4yODUsMTkxOS4zMyBMMTQxLjI4NSwxOTEwLjM3IEwxMzYuNzIxLDE5MTMuMjQgTDEzNi43MjEsMTkwOS41OCBMMTI1LjI2NywxOTA5LjU4IEwxMjUuMjY3LDE5MjAuMTIgTDEzNi43MjEsMTkyMC4xMiBMMTM2LjcyMSwxOTE5Ljg3IEwxMzYuNDcxLDE5MTkuODciIGlkPSJGaWxsLTM0IiBza2V0Y2g6dHlwZT0iTVNTaGFwZUdyb3VwIj48L3BhdGg+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4=)1em center no-repeat #fff;
97 | border: none;
98 | padding-left: 1.5em;
99 | }
100 |
101 | div#controls div#log {
102 | border: 1px solid #686865;
103 | }
104 |
105 | div#controls div#room-controls {
106 | display: none;
107 | }
108 |
109 | div#controls div#room-controls input {
110 | width: 100%;
111 | height: 2.5em;
112 | padding: .5em;
113 | display: block;
114 | }
115 |
116 | div#controls div#room-controls button {
117 | color: #fff;
118 | background: 0 0;
119 | border: 1px solid #686865;
120 | }
121 |
122 | div#controls div#room-controls button#button-leave {
123 | display: none;
124 | }
125 |
126 | div#controls div#log {
127 | width: 35%;
128 | height: 9.5em;
129 | margin-top: 2.75em;
130 | text-align: left;
131 | padding: 1.5em;
132 | float: right;
133 | overflow-y: scroll;
134 | }
135 |
136 | div#controls div#log p {
137 | color: #686865;
138 | font-family: 'Share Tech Mono', 'Courier New', Courier, fixed-width;
139 | font-size: 1.25em;
140 | line-height: 1.25em;
141 | margin-left: 1em;
142 | text-indent: -1.25em;
143 | width: 90%;
144 | }
145 |
--------------------------------------------------------------------------------