├── example.config.json ├── public ├── images │ ├── get-link.png │ ├── camera.svg │ ├── clippy.svg │ ├── no-camera.svg │ ├── mic.svg │ ├── no-mic.svg │ └── sgonline.svg ├── js │ ├── util │ │ ├── http.js │ │ ├── es6-promise.auto.min.js │ │ └── clipboard.min.js │ ├── index.js │ ├── broadcast.js │ ├── guest.js │ ├── viewer.js │ └── host.js └── css │ └── styles.css ├── views ├── partials │ ├── branding.ejs │ ├── video-player.ejs │ ├── live-chat.ejs │ └── common-head.ejs └── pages │ ├── guest.ejs │ ├── viewer.ejs │ ├── broadcast.ejs │ ├── host.ejs │ └── index.ejs ├── src ├── scss │ ├── _settings.scss │ └── styles.scss └── js │ ├── index.js │ ├── broadcast.js │ ├── guest.js │ ├── viewer.js │ └── host.js ├── package.json ├── .gitignore ├── server.js ├── services ├── opentok-api.js └── broadcast-api.js ├── gulpfile.js └── README.md /example.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiKey" : "", 3 | "apiSecret": "" 4 | } -------------------------------------------------------------------------------- /public/images/get-link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huijing/sgtechonline/HEAD/public/images/get-link.png -------------------------------------------------------------------------------- /views/partials/branding.ejs: -------------------------------------------------------------------------------- 1 |
2 | 3 |

SG Tech Online

4 |
-------------------------------------------------------------------------------- /views/partials/video-player.ejs: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
-------------------------------------------------------------------------------- /views/partials/live-chat.ejs: -------------------------------------------------------------------------------- 1 |
2 |

Live chat

3 |
4 |
5 | 6 | 7 |
8 |
-------------------------------------------------------------------------------- /src/scss/_settings.scss: -------------------------------------------------------------------------------- 1 | $accent-colour: mediumslateblue; 2 | $warning-colour: crimson; 3 | $disabled-colour: gray; 4 | $light-bg: rgba(255, 255, 255, 0.7); 5 | $dark-bg: rgba(0, 0, 0, 0.2); 6 | 7 | @function text-color($color) { 8 | @if (lightness($color) > 50) { 9 | @return #000000; // Lighter backgorund, return dark color 10 | } @else { 11 | @return #ffffff; // Darker background, return light color 12 | } 13 | } -------------------------------------------------------------------------------- /public/images/camera.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/images/clippy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /views/pages/guest.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%- include ('../partials/common-head') %> 6 | 7 | 8 | 9 | 10 | 11 | <%- include ('../partials/branding') %> 12 | 13 |
14 | <%- include ('../partials/video-player') %> 15 |
16 | 17 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /views/partials/common-head.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SGTech Goes Online 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /views/pages/viewer.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%- include ('../partials/common-head') %> 6 | 7 | 8 | 9 | 10 | 11 | <%- include ('../partials/branding') %> 12 | 13 |
14 | 17 | <%- include ('../partials/video-player') %> 18 |
19 | 20 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /public/images/no-camera.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/images/mic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /views/pages/broadcast.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%- include ('../partials/common-head') %> 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 17 |
18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /public/js/util/http.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | var http = { 3 | post: function (url, data) { 4 | var requestHeaders = { 5 | 'Accept': 'application/json', 6 | 'Content-Type': 'application/json' 7 | }; 8 | 9 | var parseJSON = function (response) { 10 | return response.json(); 11 | }; 12 | 13 | var params = { 14 | method: 'POST', 15 | headers: requestHeaders, 16 | body: JSON.stringify(data) 17 | }; 18 | 19 | return new Promise(function (resolve, reject) { 20 | fetch(url, params) 21 | .then(parseJSON) 22 | .then(function (json) { 23 | resolve(json); 24 | }) 25 | .catch(function (error) { 26 | reject(error); 27 | }); 28 | }); 29 | } 30 | }; 31 | window.http = http; 32 | }()); 33 | -------------------------------------------------------------------------------- /src/js/index.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | const formToJSON = elements => [].reduce.call(elements, (data, element) => { 3 | if (isValidElement(element) && isValidValue(element)) { 4 | data[element.name] = element.value; 5 | } 6 | return data; 7 | }, {}); 8 | 9 | const isValidElement = element => { 10 | return element.name && element.value; 11 | }; 12 | 13 | const isValidValue = element => { 14 | return (!['checkbox', 'radio'].includes(element.type) || element.checked); 15 | }; 16 | 17 | const init = function () { 18 | const userForm = document.getElementById('registration'); 19 | const handleFormSubmit = event => { 20 | event.preventDefault(); 21 | 22 | const formBody = formToJSON(userForm.elements); 23 | location.assign(`/${formBody['user-type']}?name=${formBody['user-name']}`) 24 | }; 25 | userForm.addEventListener('submit', handleFormSubmit, false); 26 | }; 27 | 28 | document.addEventListener('DOMContentLoaded', init); 29 | }()); 30 | -------------------------------------------------------------------------------- /public/js/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | (function () { 4 | var formToJSON = function formToJSON(elements) { 5 | return [].reduce.call(elements, function (data, element) { 6 | if (isValidElement(element) && isValidValue(element)) { 7 | data[element.name] = element.value; 8 | } 9 | 10 | return data; 11 | }, {}); 12 | }; 13 | 14 | var isValidElement = function isValidElement(element) { 15 | return element.name && element.value; 16 | }; 17 | 18 | var isValidValue = function isValidValue(element) { 19 | return !['checkbox', 'radio'].includes(element.type) || element.checked; 20 | }; 21 | 22 | var init = function init() { 23 | var userForm = document.getElementById('registration'); 24 | 25 | var handleFormSubmit = function handleFormSubmit(event) { 26 | event.preventDefault(); 27 | var formBody = formToJSON(userForm.elements); 28 | location.assign("/".concat(formBody['user-type'], "?name=").concat(formBody['user-name'])); 29 | }; 30 | 31 | userForm.addEventListener('submit', handleFormSubmit, false); 32 | }; 33 | 34 | document.addEventListener('DOMContentLoaded', init); 35 | })(); -------------------------------------------------------------------------------- /public/images/no-mic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sgtechonline", 3 | "version": "0.1.0", 4 | "description": "Customisable OpenTok broadcast application for Singapore's online meetups", 5 | "main": "server.js", 6 | "dependencies": { 7 | "bent": "^7.1.1", 8 | "bluebird": "^3.7.2", 9 | "deepmerge": "^4.2.2", 10 | "ejs": "^3.1.7", 11 | "express": "^4.18.2", 12 | "jsonwebtoken": "^9.0.0", 13 | "opentok": "^2.9.2", 14 | "request": "^2.88.2" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.8.7", 18 | "@babel/preset-env": "^7.8.7", 19 | "browser-sync": "^2.27.11", 20 | "gulp": "^4.0.2", 21 | "gulp-autoprefixer": "^7.0.1", 22 | "gulp-babel": "^8.0.0", 23 | "gulp-cssnano": "^2.1.3", 24 | "gulp-sass": "^5.1.0", 25 | "gulp-uglify": "^3.0.2", 26 | "node-sass": "^7.0.3" 27 | }, 28 | "scripts": { 29 | "test": "echo \"Error: no test specified\" && exit 1", 30 | "start": "node server.js" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/huijing/sgtechonline.git" 35 | }, 36 | "keywords": [ 37 | "opentok", 38 | "broadcast", 39 | "webrtc" 40 | ], 41 | "author": "kakyou_tensai@yahoo.com", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/huijing/sgtechonline/issues" 45 | }, 46 | "homepage": "https://github.com/huijing/sgtechonline#readme" 47 | } 48 | -------------------------------------------------------------------------------- /views/pages/host.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%- include ('../partials/common-head') %> 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <%- include ('../partials/branding') %> 14 | 15 |
16 | <%- include ('../partials/video-player') %> 17 |
18 | 19 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/js/broadcast.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 3 | /** 4 | * Get our OpenTok API Key, Session ID, and Token from the JSON embedded 5 | * in the HTML. 6 | */ 7 | const getBroadcastData = function () { 8 | const el = document.getElementById('broadcast'); 9 | const credentials = JSON.parse(el.getAttribute('data')); 10 | el.remove(); 11 | return credentials; 12 | }; 13 | 14 | /** 15 | * Update the banner based on the status of the broadcast (active or ended) 16 | */ 17 | const updateBanner = function (status) { 18 | const banner = document.getElementById('banner'); 19 | const bannerText = document.getElementById('bannerText'); 20 | 21 | if (status === 'active') { 22 | banner.classList.add('hidden'); 23 | } else if (status === 'ended') { 24 | bannerText.classList.add('red'); 25 | bannerText.innerHTML = 'The Broadcast is Over'; 26 | banner.classList.remove('hidden'); 27 | } 28 | }; 29 | 30 | const play = function (source) { 31 | updateBanner('active'); 32 | 33 | flowplayer('#videoContainer', { 34 | splash: false, 35 | embed: false, 36 | ratio: 9 / 16, 37 | autoplay: true, 38 | clip: { 39 | autoplay: true, 40 | live: true, 41 | hlsjs: { 42 | // listen to hls.js ERROR 43 | listeners: ['hlsError'], 44 | }, 45 | sources: [{ 46 | type: 'application/x-mpegurl', 47 | src: source 48 | }] 49 | } 50 | }).on('hlsError', function (e, api, error) { 51 | 52 | // Broadcast end 53 | if (error.type === 'networkError' && error.details === 'levelLoadError') { 54 | api.stop(); 55 | updateBanner('ended'); 56 | document.getElementById('videoContainer').classList.add('hidden'); 57 | } 58 | }); 59 | }; 60 | 61 | const init = function () { 62 | const broadcast = getBroadcastData(); 63 | if (broadcast.availableAt <= Date.now()) { 64 | play(broadcast.url); 65 | } else { 66 | setTimeout(function () { play(broadcast.url); }, 67 | broadcast.availableAt - Date.now()); 68 | } 69 | }; 70 | 71 | document.addEventListener('DOMContentLoaded', init); 72 | }()); 73 | -------------------------------------------------------------------------------- /public/js/broadcast.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | (function () { 4 | /** 5 | * Get our OpenTok API Key, Session ID, and Token from the JSON embedded 6 | * in the HTML. 7 | */ 8 | var getBroadcastData = function getBroadcastData() { 9 | var el = document.getElementById('broadcast'); 10 | var credentials = JSON.parse(el.getAttribute('data')); 11 | el.remove(); 12 | return credentials; 13 | }; 14 | /** 15 | * Update the banner based on the status of the broadcast (active or ended) 16 | */ 17 | 18 | 19 | var updateBanner = function updateBanner(status) { 20 | var banner = document.getElementById('banner'); 21 | var bannerText = document.getElementById('bannerText'); 22 | 23 | if (status === 'active') { 24 | banner.classList.add('hidden'); 25 | } else if (status === 'ended') { 26 | bannerText.classList.add('red'); 27 | bannerText.innerHTML = 'The Broadcast is Over'; 28 | banner.classList.remove('hidden'); 29 | } 30 | }; 31 | 32 | var play = function play(source) { 33 | updateBanner('active'); 34 | flowplayer('#videoContainer', { 35 | splash: false, 36 | embed: false, 37 | ratio: 9 / 16, 38 | autoplay: true, 39 | clip: { 40 | autoplay: true, 41 | live: true, 42 | hlsjs: { 43 | // listen to hls.js ERROR 44 | listeners: ['hlsError'] 45 | }, 46 | sources: [{ 47 | type: 'application/x-mpegurl', 48 | src: source 49 | }] 50 | } 51 | }).on('hlsError', function (e, api, error) { 52 | // Broadcast end 53 | if (error.type === 'networkError' && error.details === 'levelLoadError') { 54 | api.stop(); 55 | updateBanner('ended'); 56 | document.getElementById('videoContainer').classList.add('hidden'); 57 | } 58 | }); 59 | }; 60 | 61 | var init = function init() { 62 | var broadcast = getBroadcastData(); 63 | 64 | if (broadcast.availableAt <= Date.now()) { 65 | play(broadcast.url); 66 | } else { 67 | setTimeout(function () { 68 | play(broadcast.url); 69 | }, broadcast.availableAt - Date.now()); 70 | } 71 | }; 72 | 73 | document.addEventListener('DOMContentLoaded', init); 74 | })(); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and not Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Stores VSCode versions used for testing VSCode extensions 107 | .vscode-test 108 | 109 | # Other files 110 | config.json -------------------------------------------------------------------------------- /views/pages/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | SGTech Goes Online 10 | 11 | 12 | 13 | 62 | 63 | 64 | 65 | 66 | 67 | 68 |
69 | <%- include ('../partials/branding') %> 70 |
71 | 75 |

Select your role:

76 |
77 | 81 | 85 | 89 |
90 | 91 |
92 |
93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const bodyParser = require('body-parser'); 3 | 4 | const app = express(); 5 | const port = process.env.PORT || 8080; 6 | app.use(express.static(`${__dirname}/public`)); 7 | app.use(express.json()); 8 | app.set('views', `${__dirname}/views`); 9 | app.set('view engine', 'ejs'); 10 | 11 | const opentok = require('./services/opentok-api'); 12 | const broadcast = require('./services/broadcast-api'); 13 | 14 | let layoutz = [2, 1, 3, 4] 15 | 16 | app.get('/', (req, res) => { 17 | res.render('pages/index') 18 | }); 19 | 20 | app.get('/viewer', (req, res) => { 21 | opentok.getCredentials('viewer', req.query.name) 22 | .then(credentials => res.render('pages/viewer', { credentials: JSON.stringify(credentials) })) 23 | .catch(error => res.status(500).send(error)); 24 | }); 25 | 26 | app.get('/host', (req, res) => { 27 | opentok.getCredentials('host', req.query.name) 28 | .then(credentials => res.render('pages/host', { credentials: JSON.stringify(credentials) })) 29 | .catch(error => res.status(500).send(error)); 30 | }); 31 | 32 | app.get('/guest', (req, res) => { 33 | opentok.getCredentials('guest', req.query.name, true) 34 | .then(credentials => res.render('pages/guest', { credentials: JSON.stringify(credentials) })) 35 | .catch(error => res.status(500).send(error)); 36 | }); 37 | 38 | app.get('/broadcast', (req, res) => { 39 | const url = req.query.url; 40 | const availableAt = req.query.availableAt; 41 | res.render('pages/broadcast', { broadcast: JSON.stringify({ url, availableAt }) }); 42 | }); 43 | 44 | app.get('*', (req, res) => { 45 | res.redirect('/'); 46 | }); 47 | 48 | app.post('/broadcast/start', (req, res) => { 49 | const sessionId = req.body.sessionId; 50 | const streams = req.body.streams; 51 | const rtmp = req.body.rtmp; 52 | broadcast.start(sessionId, streams, rtmp, {layout: {type: 'horizontalPresentation'}}) 53 | .then(data => res.send(data)) 54 | .catch(error => res.status(500).send(error)); 55 | }); 56 | 57 | app.get('/layout', (req, res) => { 58 | res.send({layout: layoutz}) 59 | }) 60 | 61 | app.post('/layout', (req, res) => { 62 | layoutz = req.body.layout 63 | res.send({layout: layoutz}) 64 | }) 65 | 66 | app.post('/broadcast/layout', (req, res) => { 67 | const streams = req.body.streams; 68 | broadcast.updateLayout(streams) 69 | .then(data => res.send(data)) 70 | .catch(error => res.status(500).send(error)); 71 | }); 72 | 73 | app.post('/broadcast/end', (req, res) => { 74 | broadcast.end() 75 | .then(data => res.send(data)) 76 | .catch(error => res.status(500).send(error)); 77 | }); 78 | 79 | app.listen(process.env.PORT || port, () => console.log(`app listening on port ${port}`)); 80 | -------------------------------------------------------------------------------- /services/opentok-api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* eslint-env es6 */ 4 | 5 | /** Config */ 6 | const { apiKey, apiSecret } = require('../config'); 7 | 8 | /** Imports */ 9 | const Promise = require('bluebird'); 10 | const OpenTok = require('opentok'); 11 | 12 | // http://bluebirdjs.com/docs/api/promisification.html 13 | const OT = Promise.promisifyAll(new OpenTok(apiKey, apiSecret)); 14 | 15 | /** Private */ 16 | const defaultSessionOptions = { mediaMode: 'routed' }; 17 | 18 | /** 19 | * Returns options for token creation based on user type 20 | * @param {String} userType Host, guest, or viewer 21 | */ 22 | const tokenOptions = userType => { 23 | const role = { 24 | host: 'moderator', 25 | guest: 'publisher', 26 | viewer: 'subscriber', 27 | }[userType]; 28 | 29 | return { role }; 30 | }; 31 | 32 | /** 33 | * Create an OpenTok session 34 | * @param {Object} [options] 35 | * @returns {Promise} {Object}, Reject => {Error}> 36 | */ 37 | let activeSession; 38 | const createSession = options => 39 | new Promise((resolve, reject) => { 40 | const setActiveSession = (session) => { 41 | activeSession = session; 42 | return Promise.resolve(session); 43 | }; 44 | 45 | options = (typeof options === 'undefined') ? defaultSessionOptions : options; 46 | 47 | OT.createSessionAsync(options) 48 | .then(setActiveSession) 49 | .then(resolve) 50 | .catch(reject); 51 | }); 52 | 53 | /** 54 | * Create an OpenTok token 55 | * @param {String} userType Host, guest, or viewer 56 | * @param [String] name Name to display in chat 57 | * @returns {String} 58 | */ 59 | const createToken = (userType, name, isFocused) => { 60 | let options = tokenOptions(userType) 61 | options.data = `username=${name}` 62 | if (isFocused) { 63 | options.initialLayoutClassList = ['focus'] 64 | } 65 | return OT.generateToken(activeSession.sessionId, options) 66 | }; 67 | 68 | /** Exports */ 69 | 70 | /** 71 | * Creates an OpenTok session and generates an associated token 72 | * @returns {Promise} {Object}, Reject => {Error}> 73 | */ 74 | const getCredentials = (userType, name, isFocused) => 75 | new Promise((resolve, reject) => { 76 | isFocused = !!isFocused; 77 | if (activeSession) { 78 | const token = createToken(userType, name, isFocused); 79 | resolve({ apiKey, sessionId: activeSession.sessionId, token }); 80 | } else { 81 | const addToken = session => { 82 | const token = createToken(userType, name); 83 | return Promise.resolve({ apiKey, sessionId: session.sessionId, token }); 84 | }; 85 | 86 | createSession() 87 | .then(addToken) 88 | .then(resolve) 89 | .catch(reject); 90 | } 91 | }); 92 | 93 | module.exports = { 94 | getCredentials 95 | }; 96 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var browserSync = require('browser-sync'); 3 | var sass = require('gulp-sass'); 4 | var prefix = require('gulp-autoprefixer'); 5 | var cssnano = require('gulp-cssnano'); 6 | var uglify = require('gulp-uglify'); 7 | var babel = require('gulp-babel'); 8 | 9 | /** 10 | * Compile files from _scss into both _site/css (for live injecting) and site (for future Jekyll builds) 11 | */ 12 | function styles() { 13 | return gulp.src(['src/scss/styles.scss']) 14 | .pipe(sass({ 15 | includePaths: ['scss'], 16 | onError: browserSync.notify 17 | })) 18 | .pipe(prefix(['last 3 versions'], { cascade: true })) 19 | .pipe(gulp.dest('public/css/')) 20 | .pipe(browserSync.reload({ stream: true })) 21 | } 22 | 23 | function stylesProd() { 24 | return gulp.src(['src/scss/styles.scss']) 25 | .pipe(sass({ 26 | includePaths: ['scss'], 27 | onError: browserSync.notify 28 | })) 29 | .pipe(prefix(['last 3 versions'], { cascade: true })) 30 | .pipe(cssnano()) 31 | .pipe(gulp.dest('public/css/')) 32 | } 33 | 34 | function scripts() { 35 | return gulp.src([ 36 | 'src/js/broadcast.js', 37 | 'src/js/guest.js', 38 | 'src/js/host.js', 39 | 'src/js/index.js', 40 | 'src/js/viewer.js' 41 | ]) 42 | .pipe(babel({ 43 | 'presets': [ '@babel/preset-env' ] 44 | })) 45 | .pipe(gulp.dest('public/js/')) 46 | .pipe(browserSync.reload({ stream: true })) 47 | } 48 | 49 | function scriptsProd() { 50 | return gulp.src([ 51 | 'src/js/broadcast.js', 52 | 'src/js/guest.js', 53 | 'src/js/host.js', 54 | 'src/js/index.js', 55 | 'src/js/viewer.js' 56 | ]) 57 | .pipe(babel({ 58 | 'presets': [ '@babel/preset-env' ] 59 | })) 60 | .pipe(uglify()) 61 | .pipe(gulp.dest('public/js/')) 62 | .pipe(browserSync.reload({ stream: true })) 63 | } 64 | 65 | /** 66 | * Server functionality handled by BrowserSync 67 | */ 68 | function browserSyncServe(done) { 69 | browserSync.init({ 70 | proxy: 'localhost:8080' 71 | }) 72 | done(); 73 | } 74 | 75 | function browserSyncReload(done) { 76 | browserSync.reload(); 77 | done(); 78 | } 79 | 80 | /** 81 | * Watch source files for changes & recompile 82 | * Watch html/md files, run Jekyll & reload BrowserSync 83 | */ 84 | function watchMarkup() { 85 | gulp.watch(['views/**/*.ejs'], browserSyncReload); 86 | } 87 | 88 | function watchScripts() { 89 | gulp.watch(['src/js'], scripts); 90 | } 91 | 92 | function watchStyles() { 93 | gulp.watch(['src/scss/styles.scss'], styles) 94 | } 95 | 96 | var compile = gulp.parallel(styles, scripts) 97 | var serve = gulp.series(compile, browserSyncServe) 98 | var watch = gulp.parallel(watchMarkup, watchStyles, watchScripts) 99 | 100 | /** 101 | * Default task, running just `gulp` will compile the sass, 102 | * compile the Jekyll site, launch BrowserSync & watch files. 103 | */ 104 | gulp.task('default', gulp.parallel(serve, watch)) 105 | gulp.task('build', stylesProd) -------------------------------------------------------------------------------- /services/broadcast-api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* eslint-env es6 */ 4 | 5 | /** Config */ 6 | const { apiKey, apiSecret } = require('../config'); 7 | 8 | /** Imports */ 9 | const merge = require('deepmerge'); 10 | const Promise = require('bluebird'); 11 | const request = Promise.promisify(require('request')); 12 | const jwt = require('jsonwebtoken'); 13 | 14 | // http://bluebirdjs.com/docs/api/promisification.html 15 | Promise.promisifyAll(request); 16 | 17 | /** Constants */ 18 | const broadcastURL = `https://api.opentok.com/v2/project/${apiKey}/broadcast`; 19 | const updateLayoutURL = id => `https://api.opentok.com/v2/project/${apiKey}/broadcast/${id}/layout`; 20 | const stopBroadcastURL = id => `${broadcastURL}/${id}/stop`; 21 | 22 | /** 23 | * There is currently a ~15 second delay between the interactive session due to the 24 | * encoding process and the time it takes to upload the video to the CDN. Currently 25 | * using a 20-second delay to be safe. 26 | */ 27 | const broadcastDelay = 20 * 1000; 28 | 29 | /** Let's store the active broadcast */ 30 | let activeBroadcast = {}; 31 | 32 | 33 | // https://tokbox.com/developer/guides/broadcast/#custom-layouts 34 | const horizontalLayout = { 35 | layout: { 36 | type: 'custom', 37 | stylesheet: `stream { 38 | float: left; 39 | height: 100%; 40 | width: 33.33%; 41 | }` 42 | } 43 | }; 44 | 45 | // https://tokbox.com/developer/guides/broadcast/#predefined-layout-types 46 | const bestFitLayout = { 47 | layout: { 48 | type: 'bestFit' 49 | } 50 | }; 51 | 52 | /** 53 | * Get auth header 54 | * @returns {Object} 55 | */ 56 | const headers = () => { 57 | const createToken = () => { 58 | const options = { 59 | issuer: apiKey, 60 | expiresIn: '1m', 61 | }; 62 | return jwt.sign({ ist: 'project' }, apiSecret, options); 63 | }; 64 | 65 | return { 'X-OPENTOK-AUTH': createToken() }; 66 | }; 67 | 68 | /** Exports */ 69 | 70 | /** 71 | * Start the broadcast and keep the active broadcast in memory 72 | * @param {String} broadcastSessionId - Spotlight host session id 73 | * @param {Number} streams - The current number of published streams 74 | * @param {String} [rtmpUrl] - The (optional) RTMP stream url 75 | * @returns {Promise} {Object} Broadcast data, Reject => {Error}> 76 | */ 77 | const start = (broadcastSessionId, streams, rtmp, layout) => 78 | new Promise((resolve, reject) => { 79 | if (activeBroadcast.session === broadcastSessionId) { 80 | resolve(activeBroadcast); 81 | } else { 82 | layout = layout || (streams > 3 ? bestFitLayout : horizontalLayout); 83 | console.log(layout); 84 | /** 85 | * This outputs property must be included in the request body 86 | * in order to broadcast to RTMP streams 87 | */ 88 | const { serverUrl, streamName } = rtmp; 89 | const outputs = (!!serverUrl && !!streamName) ? 90 | { outputs: { hls: {}, rtmp: { serverUrl, streamName } } } : 91 | {}; 92 | 93 | const requestConfig = { 94 | headers: headers(), 95 | url: broadcastURL, 96 | json: true, 97 | body: merge.all([{ sessionId: broadcastSessionId }, layout, outputs]), 98 | }; 99 | 100 | // Parse the response from the broadcast api 101 | const setActiveBroadcast = ({ body }) => { 102 | const broadcastData = { 103 | id: body.id, 104 | session: broadcastSessionId, 105 | rtmp: !!body.broadcastUrls.rtmp, 106 | url: body.broadcastUrls.hls, 107 | apiKey: body.partnerId, 108 | availableAt: body.createdAt + broadcastDelay 109 | }; 110 | activeBroadcast = broadcastData; 111 | console.log(broadcastData); 112 | 113 | return Promise.resolve(broadcastData); 114 | }; 115 | 116 | request.postAsync(requestConfig) 117 | .then(setActiveBroadcast) 118 | .then(resolve) 119 | .catch(reject); 120 | } 121 | }); 122 | 123 | 124 | /** 125 | * Dynamically update the broadcast layout 126 | * @param {Number} streams - The number of active streams in the broadcast session 127 | * @returns {Promise} {Object} Broadcast data, Reject => {Error}> 128 | */ 129 | const updateLayout = streams => 130 | new Promise((resolve, reject) => { 131 | const id = activeBroadcast.id; 132 | 133 | if (!id) { 134 | reject({ error: 'No active broadcast session found' }); 135 | } 136 | 137 | const layout = streams > 3 ? bestFitLayout : horizontalLayout; 138 | 139 | const requestConfig = { 140 | headers: headers(), 141 | url: updateLayoutURL(id), 142 | json: true, 143 | body: (({ type, stylesheet }) => ({ type, stylesheet }))(layout.layout), 144 | }; 145 | 146 | request.putAsync(requestConfig) 147 | .then(({ body }) => resolve(body)) 148 | .catch(reject); 149 | }); 150 | 151 | /** 152 | * End the broadcast 153 | * @returns {Promise} {Object}, Reject => {Error}> 154 | */ 155 | const end = () => 156 | new Promise((resolve, reject) => { 157 | const id = activeBroadcast.id; 158 | if (!id) { 159 | return reject({ error: 'No active broadcast session found' }); 160 | } 161 | const requestConfig = () => ({ headers: headers(), url: stopBroadcastURL(id) }); 162 | request.postAsync(requestConfig(id)) 163 | .then(({ body }) => resolve(body)) 164 | .catch(reject) 165 | .finally(() => { activeBroadcast = null; }); 166 | }); 167 | 168 | module.exports = { 169 | start, 170 | updateLayout, 171 | end, 172 | }; 173 | -------------------------------------------------------------------------------- /src/js/guest.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 3 | /** 4 | * Options for adding OpenTok publisher and subscriber video elements 5 | */ 6 | const insertOptions = { 7 | width: '100%', 8 | height: '100%', 9 | showControls: false 10 | }; 11 | 12 | /** 13 | * Get our OpenTok API Key, Session ID, and Token from the JSON embedded 14 | * in the HTML. 15 | */ 16 | const getCredentials = function () { 17 | const el = document.getElementById('credentials'); 18 | const credentials = JSON.parse(el.getAttribute('data')); 19 | el.remove(); 20 | return credentials; 21 | }; 22 | 23 | /** 24 | * Create an OpenTok publisher object 25 | */ 26 | const initPublisher = function () { 27 | let query = new URLSearchParams(window.location.search) 28 | const properties = Object.assign({ 29 | name: query.get('name'), 30 | insertMode: 'after' 31 | }, insertOptions); 32 | return OT.initPublisher('hostDivider', properties); 33 | }; 34 | 35 | /** 36 | * Subscribe to a stream 37 | */ 38 | const subscribe = function (session, stream) { 39 | const name = stream.name; 40 | const insertMode = name === 'host' ? 'before' : 'after'; 41 | const properties = Object.assign({ 42 | name: name, 43 | insertMode: insertMode 44 | }, insertOptions); 45 | session.subscribe(stream, 'hostDivider', properties, function (error) { 46 | if (error) { 47 | console.log(error); 48 | } 49 | }); 50 | }; 51 | 52 | /** 53 | * Toggle publishing audio/video to allow host to mute 54 | * their video (publishVideo) or audio (publishAudio) 55 | * @param {Object} publisher The OpenTok publisher object 56 | * @param {Object} el The DOM element of the control whose id corresponds to the action 57 | */ 58 | const toggleMedia = function (publisher, el) { 59 | const enabled = el.classList.contains('disabled'); 60 | el.classList.toggle('disabled'); 61 | publisher[el.id](enabled); 62 | }; 63 | 64 | const addPublisherControls = function (publisher) { 65 | const publisherContainer = document.getElementById(publisher.element.id); 66 | const el = document.createElement('div'); 67 | const controls = [ 68 | '
', 69 | '
', 70 | '
', 71 | '
', 72 | ].join('\n'); 73 | el.innerHTML = controls; 74 | publisherContainer.appendChild(el.firstChild); 75 | }; 76 | 77 | /** 78 | * Receive a message and append it to the message history 79 | */ 80 | const updateChat = function (content, className) { 81 | const msgHistory = document.getElementById('chatHistory'); 82 | const msg = document.createElement('p'); 83 | msg.textContent = content; 84 | msg.className = className; 85 | msgHistory.appendChild(msg); 86 | msgHistory.scroll({ 87 | top: msgHistory.scrollHeight, 88 | behavior: 'smooth' 89 | }); 90 | }; 91 | 92 | /** 93 | * Start publishing our audio and video to the session. Also, start 94 | * subscribing to other streams as they are published. 95 | * @param {Object} session The OpenTok session 96 | * @param {Object} publisher The OpenTok publisher object 97 | */ 98 | const publishAndSubscribe = function (session, publisher) { 99 | let streams = 1; 100 | 101 | session.publish(publisher); 102 | addPublisherControls(publisher); 103 | 104 | session.on('streamCreated', function (event) { 105 | subscribe(session, event.stream); 106 | streams++; 107 | if (streams > 3) { 108 | document.getElementById('videoContainer').classList.add('wrap'); 109 | } 110 | }); 111 | 112 | session.on('streamDestroyed', function (event) { 113 | subscribe(session, event.stream); 114 | streams--; 115 | if (streams < 4) { 116 | document.getElementById('videoContainer').classList.remove('wrap'); 117 | } 118 | }); 119 | 120 | /** Listen for msg type signal events and update chat log display */ 121 | session.on('signal:msg', function signalCallback(event) { 122 | const content = event.data; 123 | const className = event.from.connectionId === session.connection.connectionId ? 'self' : 'others'; 124 | updateChat(content, className); 125 | }); 126 | 127 | const chat = document.getElementById('chatForm'); 128 | const msgTxt = document.getElementById('chatInput'); 129 | chat.addEventListener('submit', function(event) { 130 | event.preventDefault(); 131 | session.signal({ 132 | type: 'msg', 133 | data: `${session.connection.data.split('=')[1]}: ${msgTxt.value}` 134 | }, function signalCallback(error) { 135 | if (error) { 136 | console.error('Error sending signal:', error.name, error.message); 137 | } else { 138 | msgTxt.value = ''; 139 | } 140 | }) 141 | }, false); 142 | 143 | document.getElementById('publishVideo').addEventListener('click', function () { 144 | toggleMedia(publisher, this); 145 | }); 146 | 147 | document.getElementById('publishAudio').addEventListener('click', function () { 148 | toggleMedia(publisher, this); 149 | }); 150 | }; 151 | 152 | const init = function () { 153 | const credentials = getCredentials(); 154 | const props = { connectionEventsSuppressed: true }; 155 | const session = OT.initSession(credentials.apiKey, credentials.sessionId, props); 156 | const publisher = initPublisher(); 157 | 158 | session.connect(credentials.token, function (error) { 159 | if (error) { 160 | console.log(error); 161 | } else { 162 | publishAndSubscribe(session, publisher); 163 | } 164 | }); 165 | }; 166 | 167 | document.addEventListener('DOMContentLoaded', init); 168 | }()); 169 | -------------------------------------------------------------------------------- /public/js/guest.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | (function () { 4 | /** 5 | * Options for adding OpenTok publisher and subscriber video elements 6 | */ 7 | var insertOptions = { 8 | width: '100%', 9 | height: '100%', 10 | showControls: false 11 | }; 12 | /** 13 | * Get our OpenTok API Key, Session ID, and Token from the JSON embedded 14 | * in the HTML. 15 | */ 16 | 17 | var getCredentials = function getCredentials() { 18 | var el = document.getElementById('credentials'); 19 | var credentials = JSON.parse(el.getAttribute('data')); 20 | el.remove(); 21 | return credentials; 22 | }; 23 | /** 24 | * Create an OpenTok publisher object 25 | */ 26 | 27 | 28 | var initPublisher = function initPublisher() { 29 | var query = new URLSearchParams(window.location.search); 30 | var properties = Object.assign({ 31 | name: query.get('name'), 32 | insertMode: 'after' 33 | }, insertOptions); 34 | return OT.initPublisher('hostDivider', properties); 35 | }; 36 | /** 37 | * Subscribe to a stream 38 | */ 39 | 40 | 41 | var subscribe = function subscribe(session, stream) { 42 | var name = stream.name; 43 | var insertMode = name === 'host' ? 'before' : 'after'; 44 | var properties = Object.assign({ 45 | name: name, 46 | insertMode: insertMode 47 | }, insertOptions); 48 | session.subscribe(stream, 'hostDivider', properties, function (error) { 49 | if (error) { 50 | console.log(error); 51 | } 52 | }); 53 | }; 54 | /** 55 | * Toggle publishing audio/video to allow host to mute 56 | * their video (publishVideo) or audio (publishAudio) 57 | * @param {Object} publisher The OpenTok publisher object 58 | * @param {Object} el The DOM element of the control whose id corresponds to the action 59 | */ 60 | 61 | 62 | var toggleMedia = function toggleMedia(publisher, el) { 63 | var enabled = el.classList.contains('disabled'); 64 | el.classList.toggle('disabled'); 65 | publisher[el.id](enabled); 66 | }; 67 | 68 | var addPublisherControls = function addPublisherControls(publisher) { 69 | var publisherContainer = document.getElementById(publisher.element.id); 70 | var el = document.createElement('div'); 71 | var controls = ['
', '
', '
', '
'].join('\n'); 72 | el.innerHTML = controls; 73 | publisherContainer.appendChild(el.firstChild); 74 | }; 75 | /** 76 | * Receive a message and append it to the message history 77 | */ 78 | 79 | 80 | var updateChat = function updateChat(content, className) { 81 | var msgHistory = document.getElementById('chatHistory'); 82 | var msg = document.createElement('p'); 83 | msg.textContent = content; 84 | msg.className = className; 85 | msgHistory.appendChild(msg); 86 | msgHistory.scroll({ 87 | top: msgHistory.scrollHeight, 88 | behavior: 'smooth' 89 | }); 90 | }; 91 | /** 92 | * Start publishing our audio and video to the session. Also, start 93 | * subscribing to other streams as they are published. 94 | * @param {Object} session The OpenTok session 95 | * @param {Object} publisher The OpenTok publisher object 96 | */ 97 | 98 | 99 | var publishAndSubscribe = function publishAndSubscribe(session, publisher) { 100 | var streams = 1; 101 | session.publish(publisher); 102 | addPublisherControls(publisher); 103 | session.on('streamCreated', function (event) { 104 | subscribe(session, event.stream); 105 | streams++; 106 | 107 | if (streams > 3) { 108 | document.getElementById('videoContainer').classList.add('wrap'); 109 | } 110 | }); 111 | session.on('streamDestroyed', function (event) { 112 | subscribe(session, event.stream); 113 | streams--; 114 | 115 | if (streams < 4) { 116 | document.getElementById('videoContainer').classList.remove('wrap'); 117 | } 118 | }); 119 | /** Listen for msg type signal events and update chat log display */ 120 | 121 | session.on('signal:msg', function signalCallback(event) { 122 | var content = event.data; 123 | var className = event.from.connectionId === session.connection.connectionId ? 'self' : 'others'; 124 | updateChat(content, className); 125 | }); 126 | var chat = document.getElementById('chatForm'); 127 | var msgTxt = document.getElementById('chatInput'); 128 | chat.addEventListener('submit', function (event) { 129 | event.preventDefault(); 130 | session.signal({ 131 | type: 'msg', 132 | data: "".concat(session.connection.data.split('=')[1], ": ").concat(msgTxt.value) 133 | }, function signalCallback(error) { 134 | if (error) { 135 | console.error('Error sending signal:', error.name, error.message); 136 | } else { 137 | msgTxt.value = ''; 138 | } 139 | }); 140 | }, false); 141 | document.getElementById('publishVideo').addEventListener('click', function () { 142 | toggleMedia(publisher, this); 143 | }); 144 | document.getElementById('publishAudio').addEventListener('click', function () { 145 | toggleMedia(publisher, this); 146 | }); 147 | }; 148 | 149 | var init = function init() { 150 | var credentials = getCredentials(); 151 | var props = { 152 | connectionEventsSuppressed: true 153 | }; 154 | var session = OT.initSession(credentials.apiKey, credentials.sessionId, props); 155 | var publisher = initPublisher(); 156 | session.connect(credentials.token, function (error) { 157 | if (error) { 158 | console.log(error); 159 | } else { 160 | publishAndSubscribe(session, publisher); 161 | } 162 | }); 163 | }; 164 | 165 | document.addEventListener('DOMContentLoaded', init); 166 | })(); -------------------------------------------------------------------------------- /public/js/util/es6-promise.auto.min.js: -------------------------------------------------------------------------------- 1 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):t.ES6Promise=e()}(this,function(){"use strict";function t(t){var e=typeof t;return null!==t&&("object"===e||"function"===e)}function e(t){return"function"==typeof t}function n(t){W=t}function r(t){z=t}function o(){return function(){return process.nextTick(a)}}function i(){return"undefined"!=typeof U?function(){U(a)}:c()}function s(){var t=0,e=new H(a),n=document.createTextNode("");return e.observe(n,{characterData:!0}),function(){n.data=t=++t%2}}function u(){var t=new MessageChannel;return t.port1.onmessage=a,function(){return t.port2.postMessage(0)}}function c(){var t=setTimeout;return function(){return t(a,1)}}function a(){for(var t=0;t { 79 | const msg = document.createElement('p'); 80 | msg.textContent = message.doc.content; 81 | msg.className = message.doc.className; 82 | msgHistory.appendChild(msg); 83 | }) 84 | msgHistory.scroll({ 85 | top: msgHistory.scrollHeight, 86 | behavior: 'smooth' 87 | }); 88 | }).catch(function (err) { 89 | console.log(err); 90 | }); 91 | } else if (status === 'ended') { 92 | document.getElementById('chatForm').classList.add('disabled'); 93 | document.getElementById('chatInput').setAttribute('disabled'); 94 | db.destroy().then(function (response) { 95 | console.log(response) 96 | }).catch(function (err) { 97 | console.log(err); 98 | }); 99 | } 100 | }; 101 | 102 | const trackChat = function (content, className, db) { 103 | const message = { 104 | '_id': Date.now().toString(), 105 | 'content': content, 106 | 'className': className 107 | } 108 | db.put(message).then(function () { 109 | return db.allDocs({include_docs: true}); 110 | }).then(function (response) { 111 | console.log(response); 112 | }).catch(function (err) { 113 | console.log(err); 114 | }); 115 | } 116 | 117 | /** 118 | * Receive a message and append it to the message history 119 | */ 120 | const updateChat = function (content, className) { 121 | const msgHistory = document.getElementById('chatHistory'); 122 | const msg = document.createElement('p'); 123 | msg.textContent = content; 124 | msg.className = className; 125 | msgHistory.appendChild(msg); 126 | msgHistory.scroll({ 127 | top: msgHistory.scrollHeight, 128 | behavior: 'smooth' 129 | }); 130 | }; 131 | 132 | /** 133 | * Listen for events on the OpenTok session 134 | */ 135 | const setEventListeners = function (session, db) { 136 | let streams = []; 137 | let subscribers = []; 138 | let broadcastActive = false; 139 | 140 | /** Subscribe to new streams as they are published */ 141 | session.on('streamCreated', function (event) { 142 | streams.push(event.stream); 143 | if (broadcastActive) { 144 | subscribers.push(subscribe(session, event.stream)); 145 | } 146 | if (streams.length > 3) { 147 | document.getElementById('videoContainer').classList.add('wrap'); 148 | } 149 | }); 150 | 151 | session.on('streamDestroyed', function (event) { 152 | const index = streams.indexOf(event.stream); 153 | streams.splice(index, 1); 154 | if (streams.length < 4) { 155 | document.getElementById('videoContainer').classList.remove('wrap'); 156 | } 157 | }); 158 | 159 | /** Listen for a broadcast status update from the host */ 160 | session.on('signal:broadcast', function (event) { 161 | const status = event.data; 162 | broadcastActive = status === 'active'; 163 | 164 | if (status === 'active') { 165 | streams.forEach(function (stream) { 166 | subscribers.push(subscribe(session, stream)); 167 | }); 168 | } else if (status === 'ended') { 169 | subscribers.forEach(function (subscriber) { 170 | session.unsubscribe(subscriber); 171 | }); 172 | } 173 | initChat(db, status); 174 | updateBanner(status); 175 | }); 176 | 177 | /** Listen for msg type signal events and update chat log display */ 178 | session.on('signal:msg', function signalCallback(event) { 179 | const content = event.data; 180 | const className = event.from.connectionId === session.connection.connectionId ? 'self' : 'others'; 181 | updateChat(content, className); 182 | trackChat(content, className, db); 183 | }); 184 | 185 | const chat = document.getElementById('chatForm'); 186 | const msgTxt = document.getElementById('chatInput'); 187 | chat.addEventListener('submit', function(event) { 188 | event.preventDefault(); 189 | if (broadcastActive) { 190 | session.signal({ 191 | type: 'msg', 192 | data: `${session.connection.data.split('=')[1]}: ${msgTxt.value}` 193 | }, function signalCallback(error) { 194 | if (error) { 195 | console.error('Error sending signal:', error.name, error.message); 196 | } else { 197 | msgTxt.value = ''; 198 | } 199 | }) 200 | } 201 | }, false); 202 | }; 203 | 204 | const init = function () { 205 | const credentials = getCredentials(); 206 | const props = { connectionEventsSuppressed: true }; 207 | const session = OT.initSession(credentials.apiKey, credentials.sessionId, props); 208 | const db = new PouchDB('chatLog'); 209 | 210 | session.connect(credentials.token, function (error) { 211 | if (error) { 212 | console.log(error); 213 | } else { 214 | console.log(session.connection); 215 | setEventListeners(session, db); 216 | checkBroadcastStatus(session); 217 | } 218 | }); 219 | }; 220 | 221 | document.addEventListener('DOMContentLoaded', init); 222 | }()); 223 | -------------------------------------------------------------------------------- /public/js/viewer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | (function () { 4 | /** 5 | * Options for adding OpenTok publisher and subscriber video elements 6 | */ 7 | var insertOptions = { 8 | width: '100%', 9 | height: '100%', 10 | showControls: false 11 | }; 12 | /** 13 | * Get our OpenTok API Key, Session ID, and Token from the JSON embedded 14 | * in the HTML. 15 | */ 16 | 17 | var getCredentials = function getCredentials() { 18 | var el = document.getElementById('credentials'); 19 | var credentials = JSON.parse(el.getAttribute('data')); 20 | el.remove(); 21 | return credentials; 22 | }; 23 | /** 24 | * Subscribe to a stream 25 | * @returns {Object} A subscriber object 26 | */ 27 | 28 | 29 | var subscribe = function subscribe(session, stream) { 30 | console.log(stream); 31 | var name = stream.name; 32 | var insertMode = name === 'Host' ? 'before' : 'after'; 33 | var properties = Object.assign({ 34 | name: name, 35 | insertMode: insertMode 36 | }, insertOptions); 37 | return session.subscribe(stream, 'hostDivider', properties, function (error) { 38 | if (error) { 39 | console.log(error); 40 | } 41 | }); 42 | }; 43 | /** Ping the host to see if the broadcast has started */ 44 | 45 | 46 | var checkBroadcastStatus = function checkBroadcastStatus(session) { 47 | session.signal({ 48 | type: 'broadcast', 49 | data: 'status' 50 | }); 51 | }; 52 | /** 53 | * Update the banner based on the status of the broadcast (active or ended) 54 | */ 55 | 56 | 57 | var updateBanner = function updateBanner(status) { 58 | var banner = document.getElementById('banner'); 59 | var bannerText = document.getElementById('bannerText'); 60 | 61 | if (status === 'active') { 62 | banner.classList.add('hidden'); 63 | } else if (status === 'ended') { 64 | bannerText.classList.add('ended'); 65 | bannerText.innerHTML = 'The broadcast has ended.'; 66 | banner.classList.remove('hidden'); 67 | } 68 | }; 69 | /** 70 | * Load previous chat history, if available 71 | */ 72 | 73 | 74 | var initChat = function initChat(db, status) { 75 | if (status === 'active') { 76 | document.getElementById('chatForm').classList.remove('disabled'); 77 | document.getElementById('chatInput').removeAttribute('disabled'); 78 | db.allDocs({ 79 | include_docs: true, 80 | attachments: true 81 | }).then(function (result) { 82 | var messagesArray = result.rows; 83 | var msgHistory = document.getElementById('chatHistory'); 84 | messagesArray.forEach(function (message) { 85 | var msg = document.createElement('p'); 86 | msg.textContent = message.doc.content; 87 | msg.className = message.doc.className; 88 | msgHistory.appendChild(msg); 89 | }); 90 | msgHistory.scroll({ 91 | top: msgHistory.scrollHeight, 92 | behavior: 'smooth' 93 | }); 94 | })["catch"](function (err) { 95 | console.log(err); 96 | }); 97 | } else if (status === 'ended') { 98 | document.getElementById('chatForm').classList.add('disabled'); 99 | document.getElementById('chatInput').setAttribute('disabled'); 100 | db.destroy().then(function (response) { 101 | console.log(response); 102 | })["catch"](function (err) { 103 | console.log(err); 104 | }); 105 | } 106 | }; 107 | 108 | var trackChat = function trackChat(content, className, db) { 109 | var message = { 110 | '_id': Date.now().toString(), 111 | 'content': content, 112 | 'className': className 113 | }; 114 | db.put(message).then(function () { 115 | return db.allDocs({ 116 | include_docs: true 117 | }); 118 | }).then(function (response) { 119 | console.log(response); 120 | })["catch"](function (err) { 121 | console.log(err); 122 | }); 123 | }; 124 | /** 125 | * Receive a message and append it to the message history 126 | */ 127 | 128 | 129 | var updateChat = function updateChat(content, className) { 130 | var msgHistory = document.getElementById('chatHistory'); 131 | var msg = document.createElement('p'); 132 | msg.textContent = content; 133 | msg.className = className; 134 | msgHistory.appendChild(msg); 135 | msgHistory.scroll({ 136 | top: msgHistory.scrollHeight, 137 | behavior: 'smooth' 138 | }); 139 | }; 140 | /** 141 | * Listen for events on the OpenTok session 142 | */ 143 | 144 | 145 | var setEventListeners = function setEventListeners(session, db) { 146 | var streams = []; 147 | var subscribers = []; 148 | var broadcastActive = false; 149 | /** Subscribe to new streams as they are published */ 150 | 151 | session.on('streamCreated', function (event) { 152 | streams.push(event.stream); 153 | 154 | if (broadcastActive) { 155 | subscribers.push(subscribe(session, event.stream)); 156 | } 157 | 158 | if (streams.length > 3) { 159 | document.getElementById('videoContainer').classList.add('wrap'); 160 | } 161 | }); 162 | session.on('streamDestroyed', function (event) { 163 | var index = streams.indexOf(event.stream); 164 | streams.splice(index, 1); 165 | 166 | if (streams.length < 4) { 167 | document.getElementById('videoContainer').classList.remove('wrap'); 168 | } 169 | }); 170 | /** Listen for a broadcast status update from the host */ 171 | 172 | session.on('signal:broadcast', function (event) { 173 | var status = event.data; 174 | broadcastActive = status === 'active'; 175 | 176 | if (status === 'active') { 177 | streams.forEach(function (stream) { 178 | subscribers.push(subscribe(session, stream)); 179 | }); 180 | } else if (status === 'ended') { 181 | subscribers.forEach(function (subscriber) { 182 | session.unsubscribe(subscriber); 183 | }); 184 | } 185 | 186 | initChat(db, status); 187 | updateBanner(status); 188 | }); 189 | /** Listen for msg type signal events and update chat log display */ 190 | 191 | session.on('signal:msg', function signalCallback(event) { 192 | var content = event.data; 193 | var className = event.from.connectionId === session.connection.connectionId ? 'self' : 'others'; 194 | updateChat(content, className); 195 | trackChat(content, className, db); 196 | }); 197 | var chat = document.getElementById('chatForm'); 198 | var msgTxt = document.getElementById('chatInput'); 199 | chat.addEventListener('submit', function (event) { 200 | event.preventDefault(); 201 | 202 | if (broadcastActive) { 203 | session.signal({ 204 | type: 'msg', 205 | data: "".concat(session.connection.data.split('=')[1], ": ").concat(msgTxt.value) 206 | }, function signalCallback(error) { 207 | if (error) { 208 | console.error('Error sending signal:', error.name, error.message); 209 | } else { 210 | msgTxt.value = ''; 211 | } 212 | }); 213 | } 214 | }, false); 215 | }; 216 | 217 | var init = function init() { 218 | var credentials = getCredentials(); 219 | var props = { 220 | connectionEventsSuppressed: true 221 | }; 222 | var session = OT.initSession(credentials.apiKey, credentials.sessionId, props); 223 | var db = new PouchDB('chatLog'); 224 | session.connect(credentials.token, function (error) { 225 | if (error) { 226 | console.log(error); 227 | } else { 228 | console.log(session.connection); 229 | setEventListeners(session, db); 230 | checkBroadcastStatus(session); 231 | } 232 | }); 233 | }; 234 | 235 | document.addEventListener('DOMContentLoaded', init); 236 | })(); -------------------------------------------------------------------------------- /public/css/styles.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | html { 3 | -webkit-box-sizing: border-box; 4 | box-sizing: border-box; 5 | height: 100%; } 6 | 7 | * { 8 | margin: 0; 9 | padding: 0; } 10 | 11 | body { 12 | display: grid; 13 | grid-template-columns: 1fr 20em; 14 | grid-template-rows: -webkit-min-content 1fr; 15 | grid-template-rows: min-content 1fr; 16 | grid-template-areas: 'video header' 'video aside'; 17 | height: 100vh; } 18 | 19 | header { 20 | grid-area: header; 21 | justify-self: center; 22 | padding: 1em; 23 | text-align: center; 24 | border-bottom: 2px solid rgba(0, 0, 0, 0.2); } 25 | 26 | main { 27 | grid-area: video; 28 | display: grid; 29 | grid-template-column: auto; 30 | grid-template-row: auto; 31 | -webkit-box-shadow: inset -2px -1px 2px rgba(0, 0, 0, 0.2); 32 | box-shadow: inset -2px -1px 2px rgba(0, 0, 0, 0.2); } 33 | 34 | aside { 35 | grid-area: aside; 36 | padding: 1em; 37 | display: -webkit-box; 38 | display: -ms-flexbox; 39 | display: flex; 40 | -webkit-box-orient: vertical; 41 | -webkit-box-direction: normal; 42 | -ms-flex-direction: column; 43 | flex-direction: column; 44 | overflow: hidden; } 45 | 46 | p { 47 | line-height: 1.3; 48 | margin-bottom: 1em; } 49 | 50 | input { 51 | font-size: 85%; 52 | padding: 0.5em; 53 | border: 1px solid mediumslateblue; 54 | border-radius: 2px; } 55 | 56 | button { 57 | background-color: mediumslateblue; 58 | color: #000000; 59 | cursor: pointer; 60 | border: 0; 61 | border-radius: 2px; 62 | -webkit-transition: color .3s ease, background-color .3s ease; 63 | -o-transition: color .3s ease, background-color .3s ease; 64 | transition: color .3s ease, background-color .3s ease; 65 | font-size: inherit; 66 | padding: 0.5em 1em; } 67 | 68 | .banner { 69 | grid-row: 1; 70 | grid-column: 1; 71 | display: -webkit-box; 72 | display: -ms-flexbox; 73 | display: flex; } 74 | 75 | .banner .text { 76 | margin: auto; 77 | font-size: 2em; 78 | font-style: italic; 79 | color: mediumslateblue; 80 | border: 1px solid mediumslateblue; 81 | padding: 0.5em 1em; } 82 | 83 | .banner .ended { 84 | color: crimson; } 85 | 86 | .video-container { 87 | grid-row: 1; 88 | grid-column: 1; 89 | width: 100%; 90 | height: 100%; 91 | display: -webkit-box; 92 | display: -ms-flexbox; 93 | display: flex; 94 | -ms-flex-pack: distribute; 95 | justify-content: space-around; } 96 | 97 | .video-container.wrap { 98 | display: -webkit-box; 99 | display: -ms-flexbox; 100 | display: flex; 101 | -ms-flex-wrap: wrap; 102 | flex-wrap: wrap; 103 | position: relative; 104 | -webkit-box-align: end; 105 | -ms-flex-align: end; 106 | align-items: flex-end; } 107 | 108 | .video-container:empty { 109 | width: 0; } 110 | 111 | .fp-player { 112 | display: -webkit-box; 113 | display: -ms-flexbox; 114 | display: flex; 115 | -webkit-box-pack: center; 116 | -ms-flex-pack: center; 117 | justify-content: center; 118 | -ms-flex-line-pack: center; 119 | align-content: center; } 120 | 121 | .fp-ratio, 122 | .fp-ui, 123 | .fp-help, 124 | .fp-subtitle.audio-control, 125 | .fp-context-menu { 126 | display: none; } 127 | 128 | .video-container .OT_publisher, 129 | .video-container .OT_subscriber { 130 | position: relative; } 131 | 132 | .video-container.wrap .OT_publisher, 133 | .video-container.wrap .OT_subscriber { 134 | -webkit-box-flex: 1; 135 | -ms-flex: 1 1 0px; 136 | flex: 1 1 0; 137 | height: 25% !important; } 138 | 139 | .video-container.wrap .OT_publisher.focused, 140 | .video-container.wrap .OT_subscriber.focused { 141 | height: 75% !important; 142 | position: absolute; 143 | -ms-flex-item-align: start; 144 | align-self: start; } 145 | 146 | .OT_publisher .OT_name.OT_edge-bar-item.OT_mode-off, 147 | .OT_subscriber .OT_name.OT_edge-bar-item.OT_mode-off { 148 | position: absolute; 149 | left: 0; 150 | right: 0; 151 | top: auto; 152 | bottom: 20px; 153 | margin: 0 auto; 154 | opacity: 1; 155 | text-align: center; 156 | width: 125px; 157 | border-radius: 20px; } 158 | 159 | .OT_publisher .OT_edge-bar-item.OT_mode-off, .OT_subscriber .OT_edge-bar-item.OT_mode-off { 160 | display: inline-block; } 161 | 162 | .OT_widget-container .OT_video-element { 163 | border-radius: 8px; } 164 | 165 | .publisher-controls-container { 166 | position: absolute; 167 | display: -webkit-box; 168 | display: -ms-flexbox; 169 | display: flex; 170 | -ms-flex-pack: distribute; 171 | justify-content: space-around; 172 | -webkit-box-align: center; 173 | -ms-flex-align: center; 174 | align-items: center; 175 | bottom: 1.5em; 176 | z-index: 2; 177 | left: 50%; 178 | -webkit-transform: translateX(-50%); 179 | -ms-transform: translateX(-50%); 180 | transform: translateX(-50%); } 181 | 182 | .publisher-controls-container .control { 183 | background-size: contain; 184 | background-repeat: no-repeat; 185 | background-position: center; 186 | height: 1.5em; 187 | width: 1.5em; 188 | cursor: pointer; 189 | margin: 0 1em; } 190 | 191 | .publisher-controls-container .video-control { 192 | background-image: url("../images/camera.svg"); } 193 | 194 | .publisher-controls-container .video-control.disabled { 195 | background-image: url("../images/no-camera.svg"); } 196 | 197 | .publisher-controls-container .audio-control { 198 | background-image: url("../images/mic.svg"); } 199 | 200 | .publisher-controls-container .audio-control.disabled { 201 | background-image: url("../images/no-mic.svg"); } 202 | 203 | .rtmp-container p { 204 | font-style: italic; } 205 | 206 | .rtmp-container p.error { 207 | color: crimson; } 208 | 209 | .rtmp-container .input-container input { 210 | width: calc(100% - 2em); 211 | margin-bottom: 1em; } 212 | 213 | .url-container { 214 | position: relative; 215 | text-align: center; 216 | margin: 1em 0; 217 | display: -webkit-box; 218 | display: -ms-flexbox; 219 | display: flex; 220 | -webkit-box-orient: vertical; 221 | -webkit-box-direction: normal; 222 | -ms-flex-direction: column; 223 | flex-direction: column; 224 | -webkit-box-align: center; 225 | -ms-flex-align: center; 226 | align-items: center; } 227 | 228 | .copy-link { 229 | cursor: pointer; } 230 | 231 | .copy-txt { 232 | position: absolute; 233 | bottom: -2.25em; 234 | border-radius: 4px; 235 | border: 1px solid mediumslateblue; 236 | background-color: rgba(255, 255, 255, 0.7); 237 | padding: 0.25em 0.5em; } 238 | 239 | .hidden { 240 | display: none !important; } 241 | 242 | .invisible { 243 | opacity: 0; 244 | position: absolute; 245 | z-index: -1; } 246 | 247 | .btn-broadcast { 248 | -ms-flex-item-align: center; 249 | align-self: center; 250 | margin-bottom: 1em; } 251 | 252 | .btn-broadcast:hover { 253 | text-decoration: none; 254 | background-color: #8f7ff1; } 255 | 256 | .btn-broadcast.active { 257 | background-color: crimson; } 258 | 259 | .btn-broadcast.active:hover { 260 | background-color: #ed365b; } 261 | 262 | .btn-broadcast:disabled { 263 | background-color: gray; 264 | cursor: default; } 265 | 266 | .chat-container { 267 | -webkit-box-flex: 1; 268 | -ms-flex: 1; 269 | flex: 1; 270 | display: -webkit-box; 271 | display: -ms-flexbox; 272 | display: flex; 273 | -webkit-box-orient: vertical; 274 | -webkit-box-direction: normal; 275 | -ms-flex-direction: column; 276 | flex-direction: column; 277 | overflow: hidden; } 278 | 279 | .chat-log { 280 | -webkit-box-flex: 1; 281 | -ms-flex: 1; 282 | flex: 1; 283 | overflow-y: scroll; 284 | display: -webkit-box; 285 | display: -ms-flexbox; 286 | display: flex; 287 | -webkit-box-orient: vertical; 288 | -webkit-box-direction: normal; 289 | -ms-flex-direction: column; 290 | flex-direction: column; } 291 | 292 | .chat-log p:first-child { 293 | margin-top: auto; } 294 | 295 | .chat-log p::before { 296 | margin-right: 0.25em; } 297 | 298 | .chat-log .self::before { 299 | content: '☀️'; } 300 | 301 | .chat-log .others::before { 302 | content: '🪐'; } 303 | 304 | .chat-container form { 305 | display: -webkit-box; 306 | display: -ms-flexbox; 307 | display: flex; } 308 | 309 | .chat-container input { 310 | width: 100%; 311 | border-radius: 2px 0 0 2px; } 312 | 313 | .chat-container button { 314 | border-radius: 0 2px 2px 0; } 315 | 316 | .disabled { 317 | pointer-events: none; 318 | opacity: 0.4; } 319 | -------------------------------------------------------------------------------- /public/js/util/clipboard.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * clipboard.js v2.0.5 3 | * https://clipboardjs.com/ 4 | * 5 | * Licensed MIT © Zeno Rocha 6 | */ 7 | !function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return o={},r.m=n=[function(t,e){t.exports=function(t){var e;if("SELECT"===t.nodeName)t.focus(),e=t.value;else if("INPUT"===t.nodeName||"TEXTAREA"===t.nodeName){var n=t.hasAttribute("readonly");n||t.setAttribute("readonly",""),t.select(),t.setSelectionRange(0,t.value.length),n||t.removeAttribute("readonly"),e=t.value}else{t.hasAttribute("contenteditable")&&t.focus();var o=window.getSelection(),r=document.createRange();r.selectNodeContents(t),o.removeAllRanges(),o.addRange(r),e=o.toString()}return e}},function(t,e){function n(){}n.prototype={on:function(t,e,n){var o=this.e||(this.e={});return(o[t]||(o[t]=[])).push({fn:e,ctx:n}),this},once:function(t,e,n){var o=this;function r(){o.off(t,r),e.apply(n,arguments)}return r._=e,this.on(t,r,n)},emit:function(t){for(var e=[].slice.call(arguments,1),n=((this.e||(this.e={}))[t]||[]).slice(),o=0,r=n.length;oFlowplayer, that provide cross-browser support (using Flash 15 | Player in browsers that do not provide direct HLS support). 16 | 17 | **NOTE**: The viewer limits do not apply to HLS, since all publishing streams are transcoded to a single HLS stream that can be accessed from an HLS player. The expected latency for HLS is 10-15 seconds. When the host clicks the broadcast button, a link is provided, which the host can then share with all prospective viewers. The link directs the viewer to another page within the application that streams the broadcast feed. 18 | 19 | This guide has the following sections: 20 | 21 | * [Prerequisites](#prerequisites): A checklist of everything you need to get started. 22 | * [Exploring the code](#exploring-the-code): This describes the application code design, which uses recommended best practices to implement the OpenTok Broadcast app features. 23 | 24 | ## Prerequisites 25 | 26 | To be prepared to develop your OpenTok Broadcast app: 27 | 28 | 1. Review the [OpenTok.js](https://tokbox.com/developer/sdks/js/) requirements. 29 | 2. Your app will need an OpenTok **API Key** and **API Secret**, which you can get from the [OpenTok Developer Dashboard](https://dashboard.tokbox.com/). Set the API Key and API Secret in `config.json`, which can be renamed from [example.config.json](./example.config.json). 30 | 31 | To run the application, run the following commands: 32 | 33 | ``` 34 | $ npm i 35 | $ node server.js 36 | ``` 37 | 38 | _**IMPORTANT:** In order to deploy an OpenTok Broadcast app, your web domain must use HTTPS._ 39 | 40 | The web page that loads the application must be served over HTTP/HTTPS. Browser security limitations prevent you from publishing video using a `file://` path, as discussed in the OpenTok.js [Release Notes](https://www.tokbox.com/developer/sdks/js/release-notes.html#knownIssues). 41 | 42 | To support clients running [Chrome 47 or later](https://groups.google.com/forum/#!topic/discuss-webrtc/sq5CVmY69sc), HTTPS is required. A web server such as [MAMP](https://www.mamp.info/) or [XAMPP](https://www.apachefriends.org/index.html) will work, or you can use a cloud service such as [Heroku](https://www.heroku.com/) to host the application. 43 | 44 | ## Exploring the code 45 | 46 | While TokBox hosts [OpenTok.js](https://tokbox.com/developer/sdks/js/), you must host the application yourself. This allows you to customize the app as desired. For more details about the APIs used to develop this application, see the [OpenTok.js Reference](https://tokbox.com/developer/sdks/js/reference/). 47 | 48 | ### Main application files 49 | 50 | * **[server.js](./server.js)**: The server configures the routes for the host, guests, and viewers. 51 | 52 | * **[opentok-api.js](./services/opentok-api.js)**: Configures the **Session ID**, **Token**, and **API Key**, creates the OpenTok session, and generates tokens for hosts, guests, and viewers. Set the API Key and API Secret in [config.json](./config.json). 53 | 54 | * **[broadcast-api.js](./services/broadcast-api.js)**: Starts and ends the broadcast. 55 | 56 | * **[host.js](./public/js/host.js)**: The host is the individual who controls and publishes the broadcast, but does not control audio or video for guests or viewers. The host uses the OpenTok [Signaling API](https://www.tokbox.com/developer/guides/signaling/js/) to send the signals to all clients in the session. 57 | 58 | * **[guest.js](./public/js/guest.js)**: Guests can publish in the broadcast. They can control their own audio and video. The application does not include the ability for the host to control whether guests are broadcasting, though the host does have a moderator token that can be used for that purpose. 59 | 60 | * **[viewer.js](./public/js/viewer.js)**: Viewers can only view the broadcast. 61 | 62 | * **[broadcast.js](./public/js/broadcast.js)**: Plays the broadcast feed. 63 | 64 | * **[CSS files](./public/css)**: Defines the client UI style. 65 | 66 | ### Server 67 | 68 | The methods in [server.js](./server.js) include the host, guest, and viewer routes, as well as the broadcast start and end routes. Each of the host, guest, and viewer routes retrieves the credentials and creates the token for each user type (moderator, publisher, subscriber) defined in [opentok-api.js](./services/opentok-api.js): 69 | 70 | ```javascript 71 | const tokenOptions = userType => { 72 | 73 | const role = { 74 | host: 'moderator', 75 | guest: 'publisher', 76 | viewer: 'subscriber', 77 | }[userType]; 78 | 79 | return { role }; 80 | }; 81 | ``` 82 | 83 | The credentials are embedded in an EJS template as JSON. For example, the following host route is configured in server.js: 84 | 85 | ```javascript 86 | app.get('/host', (req, res) => { 87 | api.getCredentials('host') 88 | .then(credentials => res.render('pages/host', { 89 | credentials: JSON.stringify(credentials) 90 | })) 91 | .catch(error => res.status(500).send(error)); 92 | }); 93 | ``` 94 | 95 | The credentials are then retrieved in [host.js](./public/js/host.js) and used to connect to the host to the session: 96 | 97 | ```javascript 98 | var getCredentials = function () { 99 | var el = document.getElementById('credentials'); 100 | var credentials = JSON.parse(el.getAttribute('data')); 101 | el.remove(); 102 | return credentials; 103 | }; 104 | 105 | . . . 106 | 107 | var init = function () { 108 | 109 | . . . 110 | 111 | var credentials = getCredentials(); 112 | var session = OT.initSession(credentials.apiKey, credentials.sessionId); 113 | var publisher = initPublisher(); 114 | 115 | session.connect(credentials.token, function (error) { 116 | 117 | . . . 118 | 119 | }); 120 | }; 121 | ``` 122 | 123 | When the web page is loaded, those credentials are retrieved from the HTML and are used to initialize the session. 124 | 125 | 126 | ### Guest 127 | 128 | The functions in [guest.js](./public/js/guest.js) retrieve the credentials from the HTML, subscribe to the host stream and other guest streams, and publish audio and video to the session. 129 | 130 | 131 | ### Viewer 132 | 133 | The functions in [viewer.js](./public/js/viewer.js) retrieve the credentials from the HTML, connect to the session and subscribe after receiving a signal from the host indicating the broadcast has started, and monitor broadcast status. Once the broadcast begins, the viewer can see the host and guests. Each viewer uses the OpenTok [Signaling API](https://www.tokbox.com/developer/guides/signaling/js/) to receive the signals sent in the broadcast. 134 | 135 | 136 | ### Host 137 | 138 | The methods in [host.js](./public/js/host.js) retrieve the credentials from the HTML, set the state of the broadcast and update the UI, control the broadcast stream, subscribe to the guest streams, create the URL for viewers to watch the broadcast, and signal broadcast status. 139 | 140 | The host UI includes an input field to add an [RTMP stream](https://tokbox.com/developer/beta/rtmp-broadcast/), a button to start and end the broadcast, as well as a control to get a sharable link that can be distributed to all potential viewers to watch the CDN stream. 141 | 142 | The host makes calls to the server, which calls the OpenTok API to start and end the broadcast. Once the broadcast ends, the client player will recognize an error event and display a message that the broadcast is over. 143 | 144 | For more information, see [Initialize, Connect, and Publish to a Session](https://tokbox.com/developer/concepts/connect-and-publish/). 145 | 146 | The following line in host.js creates a control that allows the host to copy the URL of the CDN stream to the clipboard for distribution to potential viewers: 147 | 148 | 149 | ```javascript 150 | var init = function () { 151 | var clipboard = new Clipboard('#copyURL'); 152 | 153 | . . . 154 | 155 | }); 156 | }; 157 | 158 | ``` 159 | 160 | The following method in host.js sets up the publisher session for the host, configures a custom UI with controls for the publisher role associated with the host, and sets up event listeners for the broadcast button. 161 | 162 | ```javascript 163 | var publishAndSubscribe = function (session, publisher) { 164 | session.publish(publisher); 165 | addPublisherControls(publisher); 166 | setEventListeners(session, publisher); 167 | }; 168 | ``` 169 | 170 | When the broadcast button is clicked, the `startBroadcast()` method is invoked and submits a request to the server endpoint to begin the broadcast. The server endpoint relays the session ID to the [OpenTok HLS Broadcast REST](https://tokbox.com/developer/rest/#start_broadcast) `/broadcast/start` endpoint, which returns broadcast data to the host. The broadcast data includes the broadcast URL in its JSON-encoded HTTP response: 171 | 172 | ```javascript 173 | var startBroadcast = function (session) { 174 | 175 | . . . 176 | 177 | http.post('/broadcast/start', { sessionId: session.sessionId }) 178 | .then(function (broadcastData) { 179 | broadcast = R.merge(broadcast, broadcastData); 180 | updateStatus(session, 'active'); 181 | 182 | . . . 183 | 184 | } 185 | }; 186 | ``` 187 | 188 | 189 | The `startBroadcast()` method subsequently calls the `updateStatus()` method with the broadcast status. The `updateStatus()` method uses the [OpenTok Signaling API](https://www.tokbox.com/developer/guides/signaling/js/) to notify the live viewers who are subscribed to the session that the broadcast has started: 190 | 191 | ```javascript 192 | var updateStatus = function (session, status) { 193 | 194 | . . . 195 | 196 | signal(session, broadcast.status); 197 | }; 198 | ``` 199 | 200 | 201 | The broadcast data includes both the URL for the CDN stream and a timestamp indicating when the video should begin playing. The `init()` method in [broadcast-api.js](./services/broadcast-api.js) compares this timestamp to the current time to determine when to play the video. It either begins to play immediately, or sets a timeout to play at the appropriate future time: 202 | 203 | ```javascript 204 | var init = function () { 205 | 206 | var broadcast = getBroadcastData(); 207 | if (broadcast.availableAt <= Date.now()) { 208 | play(broadcast.url); 209 | } else { 210 | setTimeout(function () { play(broadcast.url); }, 211 | broadcast.availableAt - Date.now()); 212 | } 213 | 214 | }; 215 | ``` 216 | 217 | 218 | When the broadcast is over, the `endBroadcast()` method in host.js submits a request to the server, which invokes the [OpenTok Broadcast API](https://tokbox.com/developer/rest/#stop_broadcast) `/broadcast/stop` endpoint, which terminates the CDN stream. This is a recommended best practice, as the default is that broadcasts remain active until a 120-minute timeout period has completed. 219 | -------------------------------------------------------------------------------- /src/js/host.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 3 | /** The state of things */ 4 | let broadcast = { status: 'waiting', streams: 1, rtmp: false }; 5 | 6 | /** 7 | * Options for adding OpenTok publisher and subscriber video elements 8 | */ 9 | const insertOptions = { 10 | width: '100%', 11 | height: '100%', 12 | showControls: false 13 | }; 14 | 15 | /** 16 | * Get our OpenTok http Key, Session ID, and Token from the JSON embedded 17 | * in the HTML. 18 | */ 19 | const getCredentials = function () { 20 | const el = document.getElementById('credentials'); 21 | const credentials = JSON.parse(el.getAttribute('data')); 22 | el.remove(); 23 | return credentials; 24 | }; 25 | 26 | /** 27 | * Create an OpenTok publisher object 28 | */ 29 | const initPublisher = function () { 30 | let query = new URLSearchParams(window.location.search) 31 | const properties = Object.assign({ 32 | name: query.get('name'), 33 | style: { nameDisplayMode: "on" }, 34 | insertMode: 'before' 35 | }, insertOptions); 36 | return OT.initPublisher('hostDivider', properties); 37 | }; 38 | 39 | /** 40 | * Send the broadcast status to everyone connected to the session using 41 | * the OpenTok signaling API 42 | * @param {Object} session 43 | * @param {String} status 44 | * @param {Object} [to] - An OpenTok connection object 45 | */ 46 | const signal = function (session, status, to) { 47 | const signalData = Object.assign({}, { type: 'broadcast', data: status }, to ? { to } : {}); 48 | session.signal(signalData, function (error) { 49 | if (error) { 50 | console.log(['signal error (', error.code, '): ', error.message].join('')); 51 | } else { 52 | console.log('signal sent'); 53 | } 54 | }); 55 | }; 56 | 57 | /** 58 | * Construct the url for viewers to view the broadcast stream 59 | * @param {Object} params 60 | * @param {String} params.url The CDN url for the m3u8 video stream 61 | * @param {Number} params.availableAt The time (ms since epoch) at which the stream is available 62 | */ 63 | const getBroadcastUrl = function (params) { 64 | const buildQueryString = function (query, key) { 65 | return [query, key, '=', params[key], '&'].join(''); 66 | }; 67 | const queryString = Object.keys(params).reduce(buildQueryString, '?').slice(0, -1); 68 | return [window.location.host, '/broadcast', queryString].join(''); 69 | }; 70 | 71 | /** 72 | * Set the state of the broadcast and update the UI 73 | */ 74 | const updateStatus = function (session, status) { 75 | const startStopButton = document.getElementById('startStop'); 76 | const playerUrl = getBroadcastUrl({url: broadcast.url, availableAt: broadcast.availableAt}); 77 | const displayUrl = document.getElementById('broadcastURL'); 78 | const rtmpActive = document.getElementById('rtmpActive'); 79 | 80 | broadcast.status = status; 81 | 82 | if (status === 'active') { 83 | startStopButton.classList.add('active'); 84 | startStopButton.innerHTML = 'End Broadcast'; 85 | document.getElementById('urlContainer').classList.remove('hidden'); 86 | displayUrl.innerHTML = playerUrl; 87 | displayUrl.setAttribute('value', playerUrl); 88 | if (broadcast.rtmp) { 89 | rtmpActive.classList.remove('hidden'); 90 | } 91 | } else { 92 | startStopButton.classList.remove('active'); 93 | startStopButton.innerHTML = 'Broadcast ended'; 94 | startStopButton.disabled = true; 95 | rtmpActive.classList.add('hidden'); 96 | } 97 | 98 | signal(session, broadcast.status); 99 | }; 100 | 101 | /** 102 | * Let the user know that the url has been copied to the clipboard 103 | */ 104 | const urlCopied = function () { 105 | const notice = document.getElementById('copyNotice'); 106 | notice.classList.remove('invisible'); 107 | setTimeout(function () { 108 | notice.classList.add('invisible'); 109 | }, 1500); 110 | }; 111 | 112 | const validRtmp = function () { 113 | const server = document.getElementById('rtmpServer'); 114 | const stream = document.getElementById('rtmpStream'); 115 | 116 | const serverDefined = !!server.value; 117 | const streamDefined = !!stream.value; 118 | const invalidServerMessage = 'The RTMP server url is invalid. Please update the value and try again.'; 119 | const invalidStreamMessage = 'The RTMP stream name must be defined. Please update the value and try again.'; 120 | 121 | if (serverDefined && !server.checkValidity()) { 122 | document.getElementById('rtmpLabel').classList.add('hidden'); 123 | document.getElementById('rtmpError').innerHTML = invalidServerMessage; 124 | document.getElementById('rtmpError').classList.remove('hidden'); 125 | return null; 126 | } 127 | 128 | if (serverDefined && !streamDefined) { 129 | document.getElementById('rtmpLabel').classList.add('hidden'); 130 | document.getElementById('rtmpError').innerHTML = invalidStreamMessage; 131 | document.getElementById('rtmpError').classList.remove('hidden'); 132 | return null; 133 | } 134 | 135 | document.getElementById('rtmpLabel').classList.remove('hidden'); 136 | document.getElementById('rtmpError').classList.add('hidden'); 137 | return { serverUrl: server.value, streamName: stream.value }; 138 | }; 139 | 140 | const hideRtmpInput = function () { 141 | ['rtmpLabel', 'rtmpError', 'rtmpServer', 'rtmpStream'].forEach(function (id) { 142 | document.getElementById(id).classList.add('hidden'); 143 | }); 144 | }; 145 | 146 | /** 147 | * Make a request to the server to start the broadcast 148 | * @param {String} sessionId 149 | */ 150 | const startBroadcast = function (session) { 151 | const rtmp = validRtmp(); 152 | if (!rtmp) { 153 | return; 154 | } 155 | 156 | hideRtmpInput(); 157 | 158 | http.post('/broadcast/start', { sessionId: session.sessionId, streams: broadcast.streams, rtmp: rtmp }) 159 | .then(function (broadcastData) { 160 | broadcast = {...broadcast, ...broadcastData}; 161 | updateStatus(session, 'active'); 162 | }).catch(function (error) { 163 | console.log(error); 164 | }); 165 | }; 166 | 167 | /** 168 | * Make a request to the server to stop the broadcast 169 | * @param {String} sessionId 170 | */ 171 | const endBroadcast = function (session) { 172 | http.post('/broadcast/end') 173 | .then(function () { 174 | updateStatus(session, 'ended'); 175 | }) 176 | .catch(function (error) { 177 | console.log(error); 178 | }); 179 | }; 180 | 181 | /** 182 | * Subscribe to a stream 183 | */ 184 | const subscribe = function (session, stream) { 185 | const properties = Object.assign({ 186 | name: stream.name, 187 | insertMode: 'after' 188 | }, insertOptions); 189 | session.subscribe(stream, 'hostDivider', properties, function (error) { 190 | if (error) { 191 | console.log(error); 192 | } 193 | }); 194 | }; 195 | 196 | /** 197 | * Toggle publishing audio/video to allow host to mute 198 | * their video (publishVideo) or audio (publishAudio) 199 | * @param {Object} publisher The OpenTok publisher object 200 | * @param {Object} el The DOM element of the control whose id corresponds to the action 201 | */ 202 | const toggleMedia = function (publisher, el) { 203 | const enabled = el.classList.contains('disabled'); 204 | el.classList.toggle('disabled'); 205 | publisher[el.id](enabled); 206 | }; 207 | 208 | const updateBroadcastLayout = function () { 209 | http.post('/broadcast/layout', { streams: broadcast.streams }) 210 | .then(function (result) { console.log(result); }) 211 | .catch(function (error) { console.log(error); }); 212 | }; 213 | 214 | /** 215 | * Receive a message and append it to the message history 216 | */ 217 | const updateChat = function (content, className) { 218 | const msgHistory = document.getElementById('chatHistory'); 219 | const msg = document.createElement('p'); 220 | msg.textContent = content; 221 | msg.className = className; 222 | msgHistory.appendChild(msg); 223 | msgHistory.scroll({ 224 | top: msgHistory.scrollHeight, 225 | behavior: 'smooth' 226 | }); 227 | }; 228 | 229 | const setEventListeners = function (session, publisher) { 230 | /** Add click handler to the start/stop button */ 231 | const startStopButton = document.getElementById('startStop'); 232 | startStopButton.classList.remove('hidden'); 233 | startStopButton.addEventListener('click', function () { 234 | if (broadcast.status === 'waiting') { 235 | startBroadcast(session); 236 | } else if (broadcast.status === 'active') { 237 | endBroadcast(session); 238 | } 239 | }); 240 | 241 | /** Subscribe to new streams as they're published */ 242 | session.on('streamCreated', function (event) { 243 | const currentStreams = broadcast.streams; 244 | subscribe(session, event.stream); 245 | broadcast.streams++; 246 | if (broadcast.streams > 3) { 247 | document.getElementById('videoContainer').classList.add('wrap'); 248 | if (broadcast.status === 'active' && currentStreams <= 3) { 249 | updateBroadcastLayout(); 250 | } 251 | } 252 | }); 253 | 254 | session.on('streamDestroyed', function () { 255 | const currentStreams = broadcast.streams; 256 | broadcast.streams--; 257 | if (broadcast.streams < 4) { 258 | document.getElementById('videoContainer').classList.remove('wrap'); 259 | if (broadcast.status === 'active' && currentStreams >= 4) { 260 | updateBroadcastLayout(); 261 | } 262 | } 263 | }); 264 | 265 | /** Signal the status of the broadcast when requested */ 266 | session.on('signal:broadcast', function (event) { 267 | if (event.data === 'status') { 268 | signal(session, broadcast.status, event.from); 269 | } 270 | }); 271 | 272 | /** Listen for msg type signal events and update chat log display */ 273 | session.on('signal:msg', function signalCallback(event) { 274 | const content = event.data; 275 | const className = event.from.connectionId === session.connection.connectionId ? 'self' : 'others'; 276 | updateChat(content, className); 277 | }); 278 | 279 | const chat = document.getElementById('chatForm'); 280 | const msgTxt = document.getElementById('chatInput'); 281 | chat.addEventListener('submit', function(event) { 282 | event.preventDefault(); 283 | session.signal({ 284 | type: 'msg', 285 | data: `${session.connection.data.split('=')[1]}: ${msgTxt.value}` 286 | }, function signalCallback(error) { 287 | if (error) { 288 | console.error('Error sending signal:', error.name, error.message); 289 | } else { 290 | msgTxt.value = ''; 291 | } 292 | }) 293 | }, false); 294 | 295 | document.getElementById('copyURL').addEventListener('click', function () { 296 | urlCopied(); 297 | }); 298 | 299 | document.getElementById('publishVideo').addEventListener('click', function () { 300 | toggleMedia(publisher, this); 301 | }); 302 | 303 | document.getElementById('publishAudio').addEventListener('click', function () { 304 | toggleMedia(publisher, this); 305 | }); 306 | }; 307 | 308 | const addPublisherControls = function (publisher) { 309 | const publisherContainer = document.getElementById(publisher.element.id); 310 | const el = document.createElement('div'); 311 | const controls = [ 312 | '
', 313 | '
', 314 | '
', 315 | '
', 316 | ].join('\n'); 317 | el.innerHTML = controls; 318 | publisherContainer.appendChild(el.firstChild); 319 | }; 320 | 321 | /** 322 | * The host starts publishing and signals everyone else connected to the 323 | * session so that they can start publishing and/or subscribing. 324 | * @param {Object} session The OpenTok session 325 | * @param {Object} publisher The OpenTok publisher object 326 | */ 327 | const publishAndSubscribe = function (session, publisher) { 328 | session.publish(publisher); 329 | addPublisherControls(publisher); 330 | setEventListeners(session, publisher); 331 | }; 332 | 333 | const init = function () { 334 | const clipboard = new ClipboardJS('#copyURL'); 335 | const credentials = getCredentials(); 336 | const props = { connectionEventsSuppressed: true }; 337 | const session = OT.initSession(credentials.apiKey, credentials.sessionId, props); 338 | const publisher = initPublisher(); 339 | 340 | session.connect(credentials.token, function (error) { 341 | if (error) { 342 | console.log(error); 343 | } else { 344 | publishAndSubscribe(session, publisher); 345 | } 346 | }); 347 | }; 348 | 349 | document.addEventListener('DOMContentLoaded', init); 350 | }()); 351 | -------------------------------------------------------------------------------- /public/images/sgonline.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/js/host.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } 4 | 5 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } 6 | 7 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 8 | 9 | (function () { 10 | /** The state of things */ 11 | var broadcast = { 12 | status: 'waiting', 13 | streams: 1, 14 | rtmp: false 15 | }; 16 | /** 17 | * Options for adding OpenTok publisher and subscriber video elements 18 | */ 19 | 20 | var insertOptions = { 21 | width: '100%', 22 | height: '100%', 23 | showControls: false 24 | }; 25 | /** 26 | * Get our OpenTok http Key, Session ID, and Token from the JSON embedded 27 | * in the HTML. 28 | */ 29 | 30 | var getCredentials = function getCredentials() { 31 | var el = document.getElementById('credentials'); 32 | var credentials = JSON.parse(el.getAttribute('data')); 33 | el.remove(); 34 | return credentials; 35 | }; 36 | /** 37 | * Create an OpenTok publisher object 38 | */ 39 | 40 | 41 | var initPublisher = function initPublisher() { 42 | var query = new URLSearchParams(window.location.search); 43 | var properties = Object.assign({ 44 | name: query.get('name'), 45 | style: { 46 | nameDisplayMode: "on" 47 | }, 48 | insertMode: 'before' 49 | }, insertOptions); 50 | return OT.initPublisher('hostDivider', properties); 51 | }; 52 | /** 53 | * Send the broadcast status to everyone connected to the session using 54 | * the OpenTok signaling API 55 | * @param {Object} session 56 | * @param {String} status 57 | * @param {Object} [to] - An OpenTok connection object 58 | */ 59 | 60 | 61 | var signal = function signal(session, status, to) { 62 | var signalData = Object.assign({}, { 63 | type: 'broadcast', 64 | data: status 65 | }, to ? { 66 | to: to 67 | } : {}); 68 | session.signal(signalData, function (error) { 69 | if (error) { 70 | console.log(['signal error (', error.code, '): ', error.message].join('')); 71 | } else { 72 | console.log('signal sent'); 73 | } 74 | }); 75 | }; 76 | /** 77 | * Construct the url for viewers to view the broadcast stream 78 | * @param {Object} params 79 | * @param {String} params.url The CDN url for the m3u8 video stream 80 | * @param {Number} params.availableAt The time (ms since epoch) at which the stream is available 81 | */ 82 | 83 | 84 | var getBroadcastUrl = function getBroadcastUrl(params) { 85 | var buildQueryString = function buildQueryString(query, key) { 86 | return [query, key, '=', params[key], '&'].join(''); 87 | }; 88 | 89 | var queryString = Object.keys(params).reduce(buildQueryString, '?').slice(0, -1); 90 | return [window.location.host, '/broadcast', queryString].join(''); 91 | }; 92 | /** 93 | * Set the state of the broadcast and update the UI 94 | */ 95 | 96 | 97 | var updateStatus = function updateStatus(session, status) { 98 | var startStopButton = document.getElementById('startStop'); 99 | var playerUrl = getBroadcastUrl({ 100 | url: broadcast.url, 101 | availableAt: broadcast.availableAt 102 | }); 103 | var displayUrl = document.getElementById('broadcastURL'); 104 | var rtmpActive = document.getElementById('rtmpActive'); 105 | broadcast.status = status; 106 | 107 | if (status === 'active') { 108 | startStopButton.classList.add('active'); 109 | startStopButton.innerHTML = 'End Broadcast'; 110 | document.getElementById('urlContainer').classList.remove('hidden'); 111 | displayUrl.innerHTML = playerUrl; 112 | displayUrl.setAttribute('value', playerUrl); 113 | 114 | if (broadcast.rtmp) { 115 | rtmpActive.classList.remove('hidden'); 116 | } 117 | } else { 118 | startStopButton.classList.remove('active'); 119 | startStopButton.innerHTML = 'Broadcast ended'; 120 | startStopButton.disabled = true; 121 | rtmpActive.classList.add('hidden'); 122 | } 123 | 124 | signal(session, broadcast.status); 125 | }; 126 | /** 127 | * Let the user know that the url has been copied to the clipboard 128 | */ 129 | 130 | 131 | var urlCopied = function urlCopied() { 132 | var notice = document.getElementById('copyNotice'); 133 | notice.classList.remove('invisible'); 134 | setTimeout(function () { 135 | notice.classList.add('invisible'); 136 | }, 1500); 137 | }; 138 | 139 | var validRtmp = function validRtmp() { 140 | var server = document.getElementById('rtmpServer'); 141 | var stream = document.getElementById('rtmpStream'); 142 | var serverDefined = !!server.value; 143 | var streamDefined = !!stream.value; 144 | var invalidServerMessage = 'The RTMP server url is invalid. Please update the value and try again.'; 145 | var invalidStreamMessage = 'The RTMP stream name must be defined. Please update the value and try again.'; 146 | 147 | if (serverDefined && !server.checkValidity()) { 148 | document.getElementById('rtmpLabel').classList.add('hidden'); 149 | document.getElementById('rtmpError').innerHTML = invalidServerMessage; 150 | document.getElementById('rtmpError').classList.remove('hidden'); 151 | return null; 152 | } 153 | 154 | if (serverDefined && !streamDefined) { 155 | document.getElementById('rtmpLabel').classList.add('hidden'); 156 | document.getElementById('rtmpError').innerHTML = invalidStreamMessage; 157 | document.getElementById('rtmpError').classList.remove('hidden'); 158 | return null; 159 | } 160 | 161 | document.getElementById('rtmpLabel').classList.remove('hidden'); 162 | document.getElementById('rtmpError').classList.add('hidden'); 163 | return { 164 | serverUrl: server.value, 165 | streamName: stream.value 166 | }; 167 | }; 168 | 169 | var hideRtmpInput = function hideRtmpInput() { 170 | ['rtmpLabel', 'rtmpError', 'rtmpServer', 'rtmpStream'].forEach(function (id) { 171 | document.getElementById(id).classList.add('hidden'); 172 | }); 173 | }; 174 | /** 175 | * Make a request to the server to start the broadcast 176 | * @param {String} sessionId 177 | */ 178 | 179 | 180 | var startBroadcast = function startBroadcast(session) { 181 | var rtmp = validRtmp(); 182 | 183 | if (!rtmp) { 184 | return; 185 | } 186 | 187 | hideRtmpInput(); 188 | http.post('/broadcast/start', { 189 | sessionId: session.sessionId, 190 | streams: broadcast.streams, 191 | rtmp: rtmp 192 | }).then(function (broadcastData) { 193 | broadcast = _objectSpread({}, broadcast, {}, broadcastData); 194 | updateStatus(session, 'active'); 195 | })["catch"](function (error) { 196 | console.log(error); 197 | }); 198 | }; 199 | /** 200 | * Make a request to the server to stop the broadcast 201 | * @param {String} sessionId 202 | */ 203 | 204 | 205 | var endBroadcast = function endBroadcast(session) { 206 | http.post('/broadcast/end').then(function () { 207 | updateStatus(session, 'ended'); 208 | })["catch"](function (error) { 209 | console.log(error); 210 | }); 211 | }; 212 | /** 213 | * Subscribe to a stream 214 | */ 215 | 216 | 217 | var subscribe = function subscribe(session, stream) { 218 | var properties = Object.assign({ 219 | name: stream.name, 220 | insertMode: 'after' 221 | }, insertOptions); 222 | session.subscribe(stream, 'hostDivider', properties, function (error) { 223 | if (error) { 224 | console.log(error); 225 | } 226 | }); 227 | }; 228 | /** 229 | * Toggle publishing audio/video to allow host to mute 230 | * their video (publishVideo) or audio (publishAudio) 231 | * @param {Object} publisher The OpenTok publisher object 232 | * @param {Object} el The DOM element of the control whose id corresponds to the action 233 | */ 234 | 235 | 236 | var toggleMedia = function toggleMedia(publisher, el) { 237 | var enabled = el.classList.contains('disabled'); 238 | el.classList.toggle('disabled'); 239 | publisher[el.id](enabled); 240 | }; 241 | 242 | var updateBroadcastLayout = function updateBroadcastLayout() { 243 | http.post('/broadcast/layout', { 244 | streams: broadcast.streams 245 | }).then(function (result) { 246 | console.log(result); 247 | })["catch"](function (error) { 248 | console.log(error); 249 | }); 250 | }; 251 | /** 252 | * Receive a message and append it to the message history 253 | */ 254 | 255 | 256 | var updateChat = function updateChat(content, className) { 257 | var msgHistory = document.getElementById('chatHistory'); 258 | var msg = document.createElement('p'); 259 | msg.textContent = content; 260 | msg.className = className; 261 | msgHistory.appendChild(msg); 262 | msgHistory.scroll({ 263 | top: msgHistory.scrollHeight, 264 | behavior: 'smooth' 265 | }); 266 | }; 267 | 268 | var setEventListeners = function setEventListeners(session, publisher) { 269 | /** Add click handler to the start/stop button */ 270 | var startStopButton = document.getElementById('startStop'); 271 | startStopButton.classList.remove('hidden'); 272 | startStopButton.addEventListener('click', function () { 273 | if (broadcast.status === 'waiting') { 274 | startBroadcast(session); 275 | } else if (broadcast.status === 'active') { 276 | endBroadcast(session); 277 | } 278 | }); 279 | /** Subscribe to new streams as they're published */ 280 | 281 | session.on('streamCreated', function (event) { 282 | var currentStreams = broadcast.streams; 283 | subscribe(session, event.stream); 284 | broadcast.streams++; 285 | 286 | if (broadcast.streams > 3) { 287 | document.getElementById('videoContainer').classList.add('wrap'); 288 | 289 | if (broadcast.status === 'active' && currentStreams <= 3) { 290 | updateBroadcastLayout(); 291 | } 292 | } 293 | }); 294 | session.on('streamDestroyed', function () { 295 | var currentStreams = broadcast.streams; 296 | broadcast.streams--; 297 | 298 | if (broadcast.streams < 4) { 299 | document.getElementById('videoContainer').classList.remove('wrap'); 300 | 301 | if (broadcast.status === 'active' && currentStreams >= 4) { 302 | updateBroadcastLayout(); 303 | } 304 | } 305 | }); 306 | /** Signal the status of the broadcast when requested */ 307 | 308 | session.on('signal:broadcast', function (event) { 309 | if (event.data === 'status') { 310 | signal(session, broadcast.status, event.from); 311 | } 312 | }); 313 | /** Listen for msg type signal events and update chat log display */ 314 | 315 | session.on('signal:msg', function signalCallback(event) { 316 | var content = event.data; 317 | var className = event.from.connectionId === session.connection.connectionId ? 'self' : 'others'; 318 | updateChat(content, className); 319 | }); 320 | var chat = document.getElementById('chatForm'); 321 | var msgTxt = document.getElementById('chatInput'); 322 | chat.addEventListener('submit', function (event) { 323 | event.preventDefault(); 324 | session.signal({ 325 | type: 'msg', 326 | data: "".concat(session.connection.data.split('=')[1], ": ").concat(msgTxt.value) 327 | }, function signalCallback(error) { 328 | if (error) { 329 | console.error('Error sending signal:', error.name, error.message); 330 | } else { 331 | msgTxt.value = ''; 332 | } 333 | }); 334 | }, false); 335 | document.getElementById('copyURL').addEventListener('click', function () { 336 | urlCopied(); 337 | }); 338 | document.getElementById('publishVideo').addEventListener('click', function () { 339 | toggleMedia(publisher, this); 340 | }); 341 | document.getElementById('publishAudio').addEventListener('click', function () { 342 | toggleMedia(publisher, this); 343 | }); 344 | }; 345 | 346 | var addPublisherControls = function addPublisherControls(publisher) { 347 | var publisherContainer = document.getElementById(publisher.element.id); 348 | var el = document.createElement('div'); 349 | var controls = ['
', '
', '
', '
'].join('\n'); 350 | el.innerHTML = controls; 351 | publisherContainer.appendChild(el.firstChild); 352 | }; 353 | /** 354 | * The host starts publishing and signals everyone else connected to the 355 | * session so that they can start publishing and/or subscribing. 356 | * @param {Object} session The OpenTok session 357 | * @param {Object} publisher The OpenTok publisher object 358 | */ 359 | 360 | 361 | var publishAndSubscribe = function publishAndSubscribe(session, publisher) { 362 | session.publish(publisher); 363 | addPublisherControls(publisher); 364 | setEventListeners(session, publisher); 365 | }; 366 | 367 | var init = function init() { 368 | var clipboard = new ClipboardJS('#copyURL'); 369 | var credentials = getCredentials(); 370 | var props = { 371 | connectionEventsSuppressed: true 372 | }; 373 | var session = OT.initSession(credentials.apiKey, credentials.sessionId, props); 374 | var publisher = initPublisher(); 375 | session.connect(credentials.token, function (error) { 376 | if (error) { 377 | console.log(error); 378 | } else { 379 | publishAndSubscribe(session, publisher); 380 | } 381 | }); 382 | }; 383 | 384 | document.addEventListener('DOMContentLoaded', init); 385 | })(); --------------------------------------------------------------------------------