├── .babelrc ├── .gitignore ├── README.md ├── examples └── animated-toast.js ├── index.js ├── lib ├── client │ ├── overlay-client.jsx │ ├── overlay.css │ ├── overlay.jsx │ ├── overlaylist.jsx │ └── types │ │ ├── audiooverlay.jsx │ │ ├── htmloverlay.jsx │ │ ├── textoverlay.jsx │ │ └── videooverlay.jsx ├── static │ ├── css │ │ ├── HarryP.woff │ │ ├── HarryP.woff2 │ │ └── min.css │ └── js │ │ ├── bundle.js │ │ ├── bundle.js.map │ │ ├── overlays.js │ │ └── overlays.test.js ├── twitch-overlay.js ├── twitch-overlay.spec.js ├── types │ ├── audio.js │ ├── audio.spec.js │ ├── html.js │ ├── html.spec.js │ ├── html_test.html │ ├── text.js │ ├── text.spec.js │ ├── video.js │ └── video.spec.js └── views │ └── index.pug ├── package-lock.json ├── package.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # dotenv config file 40 | .env 41 | 42 | # Redis db 43 | dump.rdb 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # twitch-overlay 2 | Animated overlays for Twitch streamers that can be loaded into OBS or XSplit 3 | 4 | This library will add an express-based server that listens for events (via a simple EventEmitter) and plays overlays when it receives an event. You can load the default URL '/' into your OBS/Xsplit client which will render overlays with a transparent background. 5 | 6 | You can pair this with lots of other modules such as [hpc-bot](https://github.com/bdickason/hpc-bot) to integrate with a chat bot or [twitch-soundboard]() to play overlays manually via your browser. 7 | 8 | # Installation 9 | 10 | 1. Include the module in your codebase: `var Overlays = require('twitch-overlay');` 11 | 1. Define any optional settings `var options = {};` (see **Config** below) 12 | 1. Start the overlay server: `overlays = new Overlays(options);` 13 | 14 | ## Config 15 | 16 | The `constructor()` function accepts the following optional parameters via a single json object: 17 | ```` 18 | var options = { 19 | hostname: 'localhost', // Binds server to this host (optional) 20 | port: 3000, // Binds server to this port 21 | directory: '/', // URL you want to point OBS/Xsplit at (optional, default: '/') 22 | events: new Events.EventEmitter() // Listens to events to trigger overlays 23 | }; 24 | ```` 25 | 26 | # Usage 27 | 28 | ## Types of Overlays 29 | 30 | First, you should decide what type of overlay you want to display on your stream. The basic types are `text`, `video`, or `html` (see 'Modules' below for more details). The easier overlay to get started with is video which just takes a single mp4 file and a name as a parameter. 31 | 32 | ## Adding Overlays `add(overlay)` or event: `overlays:add` 33 | 34 | To add an overlay, you need to pass a json object (or array of objects) with the following parameters: 35 | 36 | **Example video overlay:** 37 | ```` 38 | var overlay = { 39 | name: 'butterbeer', 40 | type: 'video', 41 | file: 'events/beer.mp4'' 42 | } 43 | overlays.add(overlay); 44 | 45 | events.emit('overlay:butterbeer:show'); 46 | ```` 47 | 48 | **Example text overlay** 49 | ```` 50 | var overlay = { 51 | name: 'text', 52 | type: 'text' 53 | } 54 | overlays.add(overlay); 55 | 56 | events.emit('overlay:text:show', 'text to say goes here!'); 57 | ```` 58 | 59 | **Example html overlay:** 60 | ```` 61 | var overlay = { 62 | name: 'quidditch', 63 | type: 'html', 64 | view: './views/quidditch.html', 65 | static: './js/' 66 | } 67 | overlays.add(overlay); 68 | 69 | events.emit('overlay:quidditch:show'); 70 | ```` 71 | 72 | Overlays will be added to an array and stored with a set of event listeners (See below: Firing Overlays). Each type has an associated React client-side template that is rendered when you want to display the overlay. The overlay server maintains a persistent state variable that is passed to react which keeps track of what is on screen and off screen at any given time. 73 | 74 | See a fully functional example here: https://github.com/bdickason/dumbledore 75 | 76 | ## Firing Overlays `show()` or `overlays:name:show` 77 | 78 | When an overlay is added to the server, it automatically has an event listener created in the format: `overlay:(name):show` where (name) is the name of the overlay that you passed in. 79 | 80 | To trigger an overlay, just emit this event to the EventEmitter you passed in and the overlay will play on the server. Most overlays end automatically when they complete (i.e. video) but some such as html events do not have a fixed endpoint and listen for custom events from the client. If you have a custom event, use `io.socket.emit('endOverlay', null, (name), (payload))` where (name) is the name of your overlay and (payload) is any additional data you want to pass along. When the server retrieves this, it will relay the event to your EventEmitter in the format: `overlay:(name):end(payload)` so you can listen for this and act accordinly. 81 | 82 | ## Removing Overlays `remove(overlay)` or `overlays:name:remove` 83 | 84 | If you want to remove an existing overlay entirely, you can issue the remove() command. 85 | 86 | To add an overlay, you need to pass a json object (or array of objects) with the `name` parameter. This allows you to pass in an existing overlay object (like you would with `add()`) or create a new object that specifies the name. 87 | 88 | *Note: This does not remove the overlay from state in case one is currently playing* 89 | 90 | ## Hiding Overlays `hide()` or `overlays:name:hide` 91 | 92 | If you want to manually hide an overlay rather than waiting for it to complete, you can issue the hide() command. 93 | 94 | 95 | ## Displaying Overlays on your Stream (Adding to OBS/Xsplit) 96 | 97 | In order for overlays to display in your OBS or Xsplit client, you need to do the following: 98 | 99 | **OBS** 100 | 1. Click the '+' under Sources 101 | 2. Add a BrowserSource 102 | 3. Point it at http://(yourhostname):(yourport)/(yourdirectory). The default is http://localhost:3000 103 | 4. Adjust the width/height to be 16:9 104 | 5. Click 'Ok' to Save 105 | 106 | After you've added the source (and made sure it's visible and on top of your stream), you should start seeing overlays show up. They show up with a transparent background so you won't see anything visible until you start triggering overlays. 107 | 108 | ## Running Tests 109 | 110 | You can run tests to verify that everything is working with the command `npm test`. This requires **mocha** to be installed with `npm install -g mocha`. 111 | 112 | If you plan to submit pull requests, please ensure that the request includes proper test coverage of your feature. 113 | -------------------------------------------------------------------------------- /examples/animated-toast.js: -------------------------------------------------------------------------------- 1 | animated-toast.js -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* Index.js - Main startup file for twitch-overlay */ 2 | 3 | module.exports = require('./lib/twitch-overlay'); 4 | -------------------------------------------------------------------------------- /lib/client/overlay-client.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render} from 'react-dom'; 3 | 4 | // Detect hostname for socket.io to connect 5 | const hostname = window && window.location && window.location.hostname; 6 | 7 | // Libraries 8 | import openSocket from 'socket.io-client'; 9 | const socket = openSocket(hostname + ':3000'); // Connect to the server to get client updates 10 | 11 | // Components 12 | import OverlayList from './overlaylist.jsx' 13 | 14 | class OverlayPlayer extends React.Component { 15 | constructor(props) { 16 | super(props); 17 | this.state = { 18 | overlays: { 19 | fullscreen: [], 20 | center: [], 21 | left: [], 22 | right: [] 23 | } 24 | }; 25 | 26 | this.updateState = this.updateState.bind(this); 27 | this.end = this.end.bind(this); 28 | 29 | /* Handle updates from server */ 30 | socket.on('overlays:state', this.updateState); // Receive state updates from server 31 | } 32 | 33 | render () { 34 | return( 35 |
36 |
37 |
38 |
39 |
40 |
 
41 |
 
42 |
 
43 |
44 |
45 | ); 46 | } 47 | 48 | updateState(state) { 49 | this.setState(state); 50 | } 51 | 52 | end(id) { 53 | socket.emit('endOverlay', id); 54 | } 55 | } 56 | 57 | 58 | render(, document.getElementById('overlays')); 59 | -------------------------------------------------------------------------------- /lib/client/overlay.css: -------------------------------------------------------------------------------- 1 | /* overlay.css styling */ 2 | .responsive { 3 | max-width: 100%; 4 | height: auto; 5 | } 6 | 7 | /* Custom harryp1 font */ 8 | .text { 9 | font-family: 'Harry P'; 10 | font-weight: normal; 11 | font-style: normal; 12 | font-size: -webkit-xxx-large; 13 | color: yellow; 14 | } 15 | 16 | @font-face { 17 | font-family: 'Harry P'; 18 | src: url('/css/HarryP.woff2') format('woff2'), 19 | url('/css/HarryP.woff') format('woff'); 20 | font-weight: normal; 21 | font-style: normal; 22 | } 23 | 24 | video { 25 | width: 100% !important; 26 | height: auto !important; 27 | } 28 | -------------------------------------------------------------------------------- /lib/client/overlay.jsx: -------------------------------------------------------------------------------- 1 | /* Overlay - Render a single overlay */ 2 | import React from 'react'; 3 | import {render} from 'react-dom'; 4 | 5 | import css from './overlay.css' 6 | 7 | import VideoOverlay from './types/videooverlay.jsx' 8 | import AudioOverlay from './types/audiooverlay.jsx' 9 | import HtmlOverlay from './types/htmloverlay.jsx' 10 | import TextOverlay from './types/textoverlay.jsx' 11 | 12 | class Overlay extends React.Component { 13 | constructor(props) { 14 | super(props); 15 | 16 | this.end = this.end.bind(this); 17 | } 18 | 19 | render () { 20 | switch(this.props.type) { 21 | case 'video': 22 | return(); 23 | break; 24 | case 'audio': 25 | return(); 26 | break; 27 | case 'html': 28 | return() 29 | break; 30 | case 'text': 31 | return(); 32 | break; 33 | } 34 | } 35 | 36 | end() { 37 | this.props.end(this.props.id); 38 | } 39 | } 40 | 41 | export default Overlay 42 | -------------------------------------------------------------------------------- /lib/client/overlaylist.jsx: -------------------------------------------------------------------------------- 1 | /* OverlayList - Display a list of overlays */ 2 | 3 | import React from 'react'; 4 | import {render} from 'react-dom'; 5 | 6 | // Components 7 | import Overlay from './overlay.jsx' 8 | 9 | class OverlayList extends React.Component { 10 | constructor(props) { 11 | super(props); 12 | } 13 | 14 | render () { 15 | let overlayList = this.props.list.map((overlay, index) => 16 | 28 | ); 29 | 30 | return( 31 |
{overlayList}
32 | ); 33 | } 34 | } 35 | 36 | export default OverlayList 37 | -------------------------------------------------------------------------------- /lib/client/types/audiooverlay.jsx: -------------------------------------------------------------------------------- 1 | /* AudioOverlay - Template for playing audio clips */ 2 | import React from 'react'; 3 | import {render} from 'react-dom'; 4 | 5 | class AudioOverlay extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | } 9 | 10 | render () { 11 | return( 12 |
13 |
14 | 15 |
16 | 17 |

{this.props.text}

18 |
19 |
20 | 23 |
24 | ); 25 | } 26 | } 27 | 28 | export default AudioOverlay 29 | -------------------------------------------------------------------------------- /lib/client/types/htmloverlay.jsx: -------------------------------------------------------------------------------- 1 | /* HtmlOverlay - Template for rendering arbitrary html */ 2 | import React from 'react'; 3 | import {render} from 'react-dom'; 4 | 5 | import ReactHtmlParser, { processNodes, convertNodeToElement, htmlparser2 } from 'react-html-parser'; 6 | 7 | class HtmlOverlay extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | 11 | this.transform = this.transform.bind(this); 12 | } 13 | 14 | componentDidMount() { 15 | ReactHtmlParser(this.props.html, {transform: this.transform}); 16 | } 17 | render() { 18 | return
; 19 | } 20 | 21 | transform(node) { 22 | // HACK - Manually load/run script tags 23 | if (node.type === 'script') { 24 | const script = document.createElement("script"); 25 | script.id = this.props.id; 26 | script.src = node.attribs.src; 27 | script.onEnd = this.end; 28 | document.body.appendChild(script); 29 | return null; 30 | } 31 | } 32 | 33 | end() { 34 | console.log('ended!'); 35 | } 36 | } 37 | 38 | export default HtmlOverlay 39 | -------------------------------------------------------------------------------- /lib/client/types/textoverlay.jsx: -------------------------------------------------------------------------------- 1 | /* TextOverlay - Template for speaking to stream */ 2 | import React from 'react'; 3 | import {render} from 'react-dom'; 4 | 5 | class TextOverlay extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | } 9 | 10 | render () { 11 | window.responsiveVoice.speak(this.props.text, 'US English Female', {onend: this.props.onEnd()}); 12 | 13 | return( 14 |
15 |
16 | {this.props.text} 17 |
18 |
19 | ); 20 | } 21 | } 22 | 23 | export default TextOverlay 24 | -------------------------------------------------------------------------------- /lib/client/types/videooverlay.jsx: -------------------------------------------------------------------------------- 1 | /* VideoOverlay - Template for displaying videos */ 2 | import React from 'react'; 3 | import {render} from 'react-dom'; 4 | 5 | class VideoOverlay extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | } 9 | 10 | componentDidMount() { 11 | // Grab the video object once it's available so we can adjust the volume 12 | this.refs.video.volume = this.props.volume 13 | } 14 | render () { 15 | return( 16 |
17 |
18 | 21 |

{this.props.text}

22 |
23 |
24 |
25 | ); 26 | } 27 | } 28 | 29 | export default VideoOverlay 30 | -------------------------------------------------------------------------------- /lib/static/css/HarryP.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpcbot/twitch-overlay/600d81ab8f3f1b6b22782ef6bc010bc249a561f8/lib/static/css/HarryP.woff -------------------------------------------------------------------------------- /lib/static/css/HarryP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpcbot/twitch-overlay/600d81ab8f3f1b6b22782ef6bc010bc249a561f8/lib/static/css/HarryP.woff2 -------------------------------------------------------------------------------- /lib/static/css/min.css: -------------------------------------------------------------------------------- 1 | /* Copyright 2014 Owen Versteeg; MIT licensed */body,textarea,input,select{background:0;border-radius:0;font:16px sans-serif;margin:0}.smooth{transition:all .2s}.btn,.nav a{text-decoration:none}.container{margin:0 20px;width:auto}label>*{display:inline}form>*{display:block;margin-bottom:10px}.btn{background:#999;border-radius:6px;border:0;color:#fff;cursor:pointer;display:inline-block;margin:2px 0;padding:12px 30px 14px}.btn:hover{background:#888}.btn:active,.btn:focus{background:#777}.btn-a{background:#0ae}.btn-a:hover{background:#09d}.btn-a:active,.btn-a:focus{background:#08b}.btn-b{background:#3c5}.btn-b:hover{background:#2b4}.btn-b:active,.btn-b:focus{background:#2a4}.btn-c{background:#d33}.btn-c:hover{background:#c22}.btn-c:active,.btn-c:focus{background:#b22}.btn-sm{border-radius:4px;padding:10px 14px 11px}.row{margin:1% 0;overflow:auto}.col{float:left}.table,.c12{width:100%}.c11{width:91.66%}.c10{width:83.33%}.c9{width:75%}.c8{width:66.66%}.c7{width:58.33%}.c6{width:50%}.c5{width:41.66%}.c4{width:33.33%}.c3{width:25%}.c2{width:16.66%}.c1{width:8.33%}h1{font-size:3em}.btn,h2{font-size:2em}.ico{font:33px Arial Unicode MS,Lucida Sans Unicode}.addon,.btn-sm,.nav,textarea,input,select{outline:0;font-size:14px}textarea,input,select{padding:8px;border:1px solid #ccc}textarea:focus,input:focus,select:focus{border-color:#5ab}textarea,input[type=text]{-webkit-appearance:none;width:13em}.addon{padding:8px 12px;box-shadow:0 0 0 1px #ccc}.nav,.nav .current,.nav a:hover{background:#000;color:#fff}.nav{height:24px;padding:11px 0 15px}.nav a{color:#aaa;padding-right:1em;position:relative;top:-1px}.nav .pagename{font-size:22px;top:1px}.btn.btn-close{background:#000;float:right;font-size:25px;margin:-54px 7px;display:none}@media(min-width:1310px){.container{margin:auto;width:1270px}}@media(max-width:870px){.row .col{width:100%}}@media(max-width:500px){.btn.btn-close{display:block}.nav{overflow:hidden}.pagename{margin-top:-11px}.nav:active,.nav:focus{height:auto}.nav div:before{background:#000;border-bottom:10px double;border-top:3px solid;content:'';float:right;height:4px;position:relative;right:3px;top:14px;width:20px}.nav a{padding:.5em 0;display:block;width:50%}}.table th,.table td{padding:.5em;text-align:left}.msg{padding:1.5em;background:#def;border-left:5px solid #59d} 2 | -------------------------------------------------------------------------------- /lib/static/js/bundle.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack:///webpack/bootstrap 4a74d8f99d3c918b7b15"],"names":[],"mappings":";AAAA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;;;AAGA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,aAAK;AACL;AACA;;AAEA;AACA;AACA;AACA,mCAA2B,0BAA0B,EAAE;AACvD,yCAAiC,eAAe;AAChD;AACA;AACA;;AAEA;AACA,8DAAsD,+DAA+D;;AAErH;AACA;;AAEA;AACA","file":"bundle.js","sourcesContent":[" \t// The module cache\n \tvar installedModules = {};\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, {\n \t\t\t\tconfigurable: false,\n \t\t\t\tenumerable: true,\n \t\t\t\tget: getter\n \t\t\t});\n \t\t}\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"\";\n\n \t// Load entry module and return exports\n \treturn __webpack_require__(__webpack_require__.s = 0);\n\n\n\n// WEBPACK FOOTER //\n// webpack/bootstrap 4a74d8f99d3c918b7b15"],"sourceRoot":""} -------------------------------------------------------------------------------- /lib/static/js/overlays.js: -------------------------------------------------------------------------------- 1 | /* overlays.js - Contains basic logic for overlays */ 2 | 3 | $(document).ready(function() { 4 | var socket = io(); 5 | 6 | // Loads an overlay when a new browser session comes online 7 | socket.on('load:template', function(overlay) { 8 | // Allows text to fade in after the voice command is executed 9 | var delayFadeOut = function () { 10 | // Animation delay passed, fade back in 11 | $(overlay.selector).fadeOut(); 12 | }; 13 | 14 | $.get('/overlays/' + overlay.name, function(template) { 15 | $('.overlays').append(template); 16 | 17 | // Hide overlay when video finishes 18 | var videoPlayer = $(overlay.selector).find('video'); 19 | if(videoPlayer) { 20 | videoPlayer.on('ended', delayFadeOut); 21 | } 22 | 23 | // Hide overlay when audio/mp4 finishes 24 | var audioPlayer = $(overlay.selector).find('audio'); 25 | if(audioPlayer) { 26 | audioPlayer.on('ended', delayFadeOut); 27 | } 28 | }) 29 | }); 30 | 31 | // Fired when a specific overlay should be shown 32 | socket.on('show:overlay', function(template, payload) { 33 | addData(template.name, template.selector, payload); 34 | showOverlay(template.selector); 35 | }); 36 | 37 | // Fades the overlay in/out 38 | var showOverlay = function showOverlay(selector) { 39 | $(selector).fadeIn(650); 40 | }; 41 | 42 | // Loads the specific template data passed in for this event 43 | var addData = function addData(name, selector, data) { 44 | var count = 0; 45 | 46 | var callbackFadeOut = function () { 47 | // responsiveVoice places the audio callback on the 'speak' event so we need a separate fadeout function for them 48 | // Animation delay passed, fade back in 49 | $(selector).fadeOut(); 50 | }; 51 | 52 | for (var key in data) { 53 | var value = data[key]; 54 | 55 | switch(getType(value)) { 56 | case 'audio': 57 | // Load and play sound 58 | $(selector + ' #' + key).attr('src', value); 59 | $(selector + ' #' + key + '_player')[0].load(); 60 | $(selector + ' #' + key + '_player')[0].play(); 61 | break; 62 | case 'image': 63 | $(selector + ' #' + key).attr('src', value); 64 | break; 65 | case 'speech': 66 | // Handle text to speech 67 | value = value.substring(0, value.length-4); // Strip the .speech from the end 68 | $(selector + ' #' + key).text(value); // Display the text on screen 69 | 70 | responsiveVoice.speak(value, 'US English Female', {onend: callbackFadeOut}); // Say it out loud 71 | break; 72 | case 'text': 73 | $(selector + ' #' + key).text(value); 74 | break; 75 | case 'video': 76 | var source = $(selector).find('source')[0]; 77 | source.src = name + '/' + value; 78 | 79 | var player = $(selector).find('video')[0]; 80 | player.load(); 81 | player.play(); 82 | break; 83 | } 84 | } 85 | }; 86 | 87 | var getType = function getType(filename) { 88 | if(typeof(filename) == 'string') { 89 | var extension=(filename.substring(filename.length-4, filename.length)); 90 | 91 | if(extension == '.png') { 92 | return('image'); 93 | } 94 | else if(extension == '.m4a') { 95 | return('audio'); 96 | } 97 | else if(extension == '.mp4') { 98 | return('video'); 99 | } 100 | else if(extension == '.tts') { 101 | return('speech'); 102 | } 103 | else { 104 | return('text'); 105 | } 106 | } 107 | } 108 | }); 109 | -------------------------------------------------------------------------------- /lib/static/js/overlays.test.js: -------------------------------------------------------------------------------- 1 | /* overlays.spec.js - client-side Tests for Twitch Overlays */ 2 | casper.test.begin('Twitch Chat Overlay', 7, function(test) { 3 | // Connect to server 4 | casper.start('http://localhost:3000', function() { 5 | // Sortinghat Overlay 6 | casper.waitForSelector('.sortinghat', function() { 7 | // casper.log('test'); 8 | // Is .sortinghat rendered on page properly? 9 | test.assertExists('.sortinghat'); 10 | 11 | // overlay should be hidden by default 12 | var overlay = casper.getElementInfo('.sortinghat'); 13 | test.assertEqual(overlay.visible, false); 14 | test.assertExists('#house_logo'); 15 | test.assertExists('#username'); 16 | test.assertExists('#house_text'); 17 | test.assertExists('#house_audio'); 18 | test.assertExists('#house_audio_player'); 19 | }); 20 | }); 21 | 22 | casper.run(function() { 23 | test.done(); 24 | }); 25 | 26 | // Debugging 27 | casper.on("remote.message", function(msg) { 28 | this.echo("Console: " + msg); 29 | }); 30 | 31 | // http://docs.casperjs.org/en/latest/events-filters.html#page-error 32 | casper.on("page.error", function(msg, trace) { 33 | this.echo("Error: " + msg); 34 | // maybe make it a little fancier with the code from the PhantomJS equivalent 35 | }); 36 | 37 | // http://docs.casperjs.org/en/latest/events-filters.html#resource-error 38 | casper.on("resource.error", function(resourceError) { 39 | this.echo("ResourceError: " + JSON.stringify(resourceError, undefined, 4)); 40 | }); 41 | 42 | // http://docs.casperjs.org/en/latest/events-filters.html#page-initialized 43 | casper.on("page.initialized", function(page) { 44 | // CasperJS doesn't provide `onResourceTimeout`, so it must be set through 45 | // the PhantomJS means. This is only possible when the page is initialized 46 | page.onResourceTimeout = function(request) { 47 | console.log('Response Timeout (#' + request.id + '): ' + JSON.stringify(request)); 48 | }; 49 | }); 50 | 51 | }); 52 | -------------------------------------------------------------------------------- /lib/twitch-overlay.js: -------------------------------------------------------------------------------- 1 | /* twitch-overlay.js - Simple http server to serve overlays */ 2 | 3 | /* To add overlay: 4 | let video = { 5 | name: 'hpcwins', 6 | type: 'video', 7 | file: 'videos/events/hpcwins.mp4' 8 | } 9 | bot.overlays.add(video) 10 | // -OR- 11 | events.emit('overlays:add', video)*/ 12 | 13 | const express = require('express') 14 | const Events = require('events') 15 | const path = require('path') 16 | const extend = require('extend') 17 | const shortid = require('shortid') // Generate short unique identifiers 18 | 19 | const types = { 20 | text: require('./types/text.js'), 21 | video: require('./types/video.js'), 22 | html: require('./types/html.js'), 23 | audio: require('./types/audio.js') 24 | } 25 | 26 | let app 27 | let io 28 | let self 29 | 30 | let overlays = [] // List of overlays that have been loaded 31 | let clients = {} // List of clients that are connected (for cleaning events) 32 | 33 | let state = { 34 | overlays: [] 35 | } // Persistent state for the app 36 | 37 | 38 | module.exports = class TwitchOverlay { 39 | constructor(_options) { 40 | 41 | // config letiables w/ default values 42 | this.options = { 43 | hostname: 'localhost', // Binds server to this host (optional) 44 | port: 3000, // Binds server to this port 45 | directory: '/', // URL you want to point OBS/Xsplit at (optional, default: '/') 46 | viewEngine: 'pug', // Templating system you'd like to use (Express-compatible) (optional: defaults to pug) */ 47 | events: new Events.EventEmitter() // Listens to events to trigger overlays 48 | } 49 | 50 | const self = this // HACK for eventemitter listener scope 51 | 52 | // this.options for starting the overlay server: 53 | this.options = extend(this.options, _options) // Copy _this.options into this.options, overwriting defaults 54 | 55 | app = express() // Express should be accessible from all functions 56 | /* Start webapp */ 57 | app.use(express.static(path.join(__dirname, 'static'))) 58 | app.set('views', path.join(__dirname, 'views')) 59 | app.locals.basedir = app.get('views') 60 | app.set('view engine', this.options.viewEngine) 61 | 62 | app.get(this.options.directory, function (req, res, next) { 63 | try { 64 | res.render('index') 65 | } catch (e) { 66 | next(e) 67 | } 68 | }) 69 | 70 | this.options.events.on('overlays:add', (overlays) => { 71 | this.add(overlays) 72 | }) 73 | 74 | this.options.events.on('overlays:remove', (overlays) => { 75 | this.remove(overlays) 76 | }) 77 | 78 | // Start server 79 | const server = require('http').Server(app) 80 | const port = this.options.port 81 | server.listen(port) 82 | console.log('[Overlay Server] listening on port ' + port) 83 | 84 | // Start Socket IO (event handler) 85 | io = require('socket.io')(server) 86 | 87 | io.on('connection', (socket) => { 88 | clients[socket.id] = [] 89 | 90 | socket.on('disconnect', () => { 91 | // Clean up Overlay listener events on disconnect or reload to prevent memory leak 92 | this.unload(socket.id) 93 | }) 94 | 95 | socket.on('endOverlay', (id, name, payload) => { 96 | this.end(id, name, payload) 97 | }) 98 | 99 | this.update() // send latest state down to the client 100 | }) 101 | 102 | /* Create initial state when server starts */ 103 | this.update() 104 | } 105 | 106 | add(_overlays) { 107 | // Add overlay(s) to display them on stream. 108 | // Expects an Object or array of Objects w/ the following structure: 109 | // name: 'powermove', // String that will activate this in chat/events i.e. !powermove and powermove:show 110 | // type: 'text', // (text, video, html) 111 | // file: '../blah.mp4' // (optional) Filename for video w/ path 112 | // text: 'Blah has subscribed!' // (optional) Text to display/read 113 | // static: '../static/blah' // (optional) directory containing images, etc to serve via webserver 114 | // view: '../test.pug' // (optional) pug template to inject instead of the default 115 | 116 | let queue = [] // Overlays to be processed 117 | 118 | if(Array.isArray(_overlays)) { 119 | // Process multiple overlays 120 | queue = _overlays 121 | } else { 122 | queue.push(_overlays) 123 | } 124 | 125 | // Loop through each overlay and load it into our overlays array 126 | queue.forEach((item) => { 127 | let overlay 128 | let template 129 | 130 | switch(item.type) { 131 | case 'text': 132 | overlay = new types.text() 133 | break 134 | case 'video': 135 | overlay = new types.video(item.name, item.file, item.volume) 136 | break 137 | case 'html': 138 | overlay = new types.html(item.name, item.view, item.static) 139 | break 140 | case 'audio': 141 | overlay = new types.audio(item.name, item.directory) 142 | break 143 | } 144 | 145 | overlay.type = item.type // Pass template down to client 146 | if(item.layout) { 147 | overlay.layout = item.layout 148 | } else { 149 | overlay.layout = 'center' 150 | } 151 | 152 | 153 | overlays.push(overlay) // Keep track of all of the overlays 154 | 155 | if(overlay.directory) { 156 | // Add static assets (images, audio, etc) to server 157 | app.use('/' + overlay.name, express.static(overlay.directory)) 158 | } 159 | 160 | this.options.events.on('overlay:' + overlay.name + ':show', (payload) => { 161 | this.show(overlay, payload) 162 | }) 163 | 164 | this.options.events.on('overlay:' + overlay.name + ':hide', () => { 165 | this.hide(overlay.name) 166 | }) 167 | }) 168 | } 169 | 170 | remove(_overlays) { 171 | // Remove overlay(s) from the system 172 | // Expects an Object or array of Objects w/ the following structure: 173 | // name: 'powermove', 174 | // (Ignores all other parameters) 175 | 176 | let queue = [] // Overlays to be processed 177 | 178 | if(Array.isArray(_overlays)) { 179 | // Process multiple overlays 180 | queue = _overlays 181 | } else { 182 | queue.push(_overlays) 183 | } 184 | 185 | // Loop through each overlay and load it into our overlays array 186 | queue.forEach((item) => { 187 | if(item.name) { 188 | // Search overlays to see if item exists 189 | for(let i = 0; i < overlays.length; i++) { 190 | if(overlays[i].name == item.name) { 191 | // Remove item from the list of overlays 192 | overlays.splice(i, 1) 193 | 194 | // Remove dangling event listeners to prevent memory leaks or unintended calls 195 | this.options.events.removeAllListeners('overlay:' + item.name + ':show') 196 | this.options.events.removeAllListeners('overlay:' + item.name + ':hide') 197 | } 198 | } 199 | } 200 | }) 201 | 202 | } 203 | 204 | update() { 205 | // Called any time we update the state on the server 206 | 207 | // Transform state to filter by layout 208 | let _state = { 209 | overlays: { 210 | fullscreen: state.overlays.filter(overlay => overlay.layout == 'fullscreen'), 211 | center: state.overlays.filter(overlay => overlay.layout == 'center'), 212 | right: state.overlays.filter(overlay => overlay.layout == 'right'), 213 | left: state.overlays.filter(overlay => overlay.layout == 'left') 214 | } 215 | } 216 | io.sockets.emit('overlays:state', _state) // Send an update to all connected clients 217 | } 218 | 219 | show(overlay, payload) { 220 | let _overlay = { 221 | id: shortid.generate(), 222 | name: overlay.name, 223 | type: overlay.type, 224 | payload: overlay.payload, 225 | layout: 'center' 226 | } 227 | 228 | if(payload) { 229 | _overlay.payload = payload 230 | } 231 | 232 | _overlay = extend(_overlay, overlay) 233 | 234 | // Set overlay to 'showing' 235 | state.overlays.push(_overlay) 236 | 237 | this.update() 238 | } 239 | 240 | hide(name) { 241 | /* hide - Hides an overlay by name 242 | input: 'overlayName' 243 | output: n/a 244 | */ 245 | if(name) { 246 | // Ignore empty requests 247 | for(let i = 0; i < state.overlays.length; i++) { 248 | if(state.overlays[i].name == name) { 249 | state.overlays.splice(i, 1) 250 | } 251 | } 252 | 253 | this.update() 254 | } 255 | } 256 | 257 | end(id, name, payload) { 258 | // Overlay ended, remove it from visible state 259 | state.overlays.forEach((overlay, index) => { 260 | if(overlay.id == id) { 261 | state.overlays.splice(index, 1) 262 | this.update() 263 | } else if (overlay.name == name) { 264 | state.overlays.splice(index, 1) 265 | 266 | // Trigger custom event 267 | this.options.events.emit('overlays:' + name + ':end', payload) 268 | this.update() 269 | } 270 | }) 271 | } 272 | 273 | unload(socketId) { 274 | // remove listeners for each overlay event to prevent them sticking around 275 | for (let key in clients[socketId]) { 276 | this.options.events.removeListener(key, clients[socketId][key]) 277 | } 278 | 279 | // Delete item from client object so it doesn't show up any more 280 | delete clients[socketId] 281 | 282 | if(Object.keys(clients).length === 0 && clients.constructor === Object) { 283 | overlays = [] // Empty the overlays queue if no clients are connected 284 | } 285 | } 286 | 287 | getState() { 288 | return(state.overlays) 289 | } 290 | 291 | clearState() { 292 | state.overlays = [] 293 | } 294 | 295 | list() { 296 | // Returns the currently loaded overlays 297 | return(overlays) 298 | } 299 | 300 | clear() { 301 | overlays = [] 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /lib/twitch-overlay.spec.js: -------------------------------------------------------------------------------- 1 | /* Test for lib/twitch-overlay.js */ 2 | 3 | const assert = require('chai').assert 4 | const sinon = require('sinon') 5 | 6 | const EventEmitter = require('events') 7 | const eventbus = new EventEmitter // Temporary event bus to prevent events firing across files 8 | 9 | // let overlays 10 | 11 | describe('Server', function() { 12 | beforeEach(function() { 13 | this.sinon = sandbox = sinon.sandbox.create() 14 | Overlays = require('./twitch-overlay.js') 15 | }) 16 | describe('constructor', function() { 17 | it('Starts with no options (all defaults)', function() { 18 | let overlays = new Overlays() 19 | 20 | let _hostname = 'localhost' 21 | let _port = 3000 22 | let _directory = '/' 23 | let _viewEngine = 'pug' 24 | 25 | // Verify defaults 26 | assert.notEqual(overlays.options, null) 27 | assert.equal(overlays.options.hostname, _hostname) 28 | assert.equal(overlays.options.port, _port) 29 | assert.equal(overlays.options.directory, _directory) 30 | assert.equal(overlays.options.viewEngine, _viewEngine) 31 | assert.notEqual(overlays.options.events, null) 32 | }) 33 | 34 | it('Starts with all options (no defaults)', function() { 35 | let options = { 36 | hostname: 'test.com', 37 | port: 3500, 38 | directory: '/test', 39 | viewEngine: 'jade', 40 | events: eventbus 41 | } 42 | 43 | let overlays = new Overlays(options) 44 | 45 | let _hostname = 'test.com' 46 | let _port = 3500 47 | let _directory = '/test' 48 | let _viewEngine = 'jade' 49 | let _events = eventbus 50 | 51 | // Verify defaults 52 | assert.notEqual(overlays.options, null) 53 | assert.equal(overlays.options.hostname, _hostname) 54 | assert.equal(overlays.options.port, _port) 55 | assert.equal(overlays.options.directory, _directory) 56 | assert.equal(overlays.options.viewEngine, _viewEngine) 57 | assert.equal(typeof(overlays.options.events), typeof(_events)) 58 | }) 59 | }) 60 | describe('add() - Load Overlays', function() { 61 | it('Loads a single overlay', function() { 62 | overlays = new Overlays({events: eventbus}) 63 | 64 | let overlay = { 65 | name: 'hpcwins', 66 | type: 'video', 67 | file: 'overlays/events/hpcwins.mp4' 68 | } 69 | 70 | let _overlay = { 71 | name: 'hpcwins', 72 | type: 'video', 73 | directory: 'overlays/events', 74 | video: '/hpcwins/hpcwins.mp4', 75 | volume: 1 76 | } 77 | 78 | let _payload = { 79 | video: 'hpcwins.mp4' 80 | } 81 | 82 | overlays.add(overlay) 83 | let list = overlays.list() 84 | 85 | assert.equal(list.length, 1) 86 | assert.equal(list[0].name, _overlay.name) 87 | assert.include(list[0].type, _overlay.type) 88 | assert.equal(list[0].selector, _overlay.selector) 89 | assert.equal(list[0].directory, _overlay.directory) 90 | }) 91 | it('Loads an array of overlays', function() { 92 | overlays = new Overlays({events: eventbus}) 93 | 94 | let overlay = [{ 95 | name: 'hpcwins', 96 | type: 'video', 97 | file: 'overlays/events/hpcwins.mp4' 98 | }, { 99 | name: 'hpctest', 100 | type: 'audio', 101 | directory: '/test' 102 | }] 103 | 104 | let _overlays = [{ 105 | name: 'hpcwins', 106 | type: 'video', 107 | directory: 'overlays/events', 108 | video: '/hpcwins/hpcwins.mp4', 109 | layout: 'center', 110 | volume: 1 111 | }, { 112 | name: 'hpctest', 113 | layout: 'center', 114 | type: 'audio', 115 | directory: '/test' 116 | }] 117 | 118 | overlays.add(overlay) 119 | 120 | let list = overlays.list() 121 | 122 | assert.equal(list.length, 2) 123 | assert.deepEqual(list, _overlays) 124 | }) 125 | it('No payload: Calls show with no payload', function() { 126 | overlays = new Overlays({events: eventbus}) 127 | 128 | let overlay = { 129 | name: 'hpcwins', 130 | type: 'video', 131 | file: 'overlays/events/hpcwins.mp4' 132 | } 133 | 134 | // Add Overlay 135 | overlays.add(overlay) 136 | 137 | // Setup spy to monitor function 138 | let spy = sinon.spy(overlays, 'show') 139 | 140 | // Trigger event 141 | eventbus.emit('overlay:hpcwins:show') 142 | 143 | assert.isOk(spy.calledOnce) 144 | }) 145 | it('Payload: Calls show with a payload', function() { 146 | overlays = new Overlays({events: eventbus}) 147 | 148 | let overlay = { 149 | name: 'cupadd', 150 | type: 'video', 151 | file: 'overlays/events/hpcwins.mp4' 152 | } 153 | 154 | let _overlay = { 155 | name: 'cupadd', 156 | type: 'video', 157 | directory: 'overlays/events', 158 | video: '/cupadd/hpcwins.mp4', 159 | layout: 'center', 160 | volume: 1 161 | } 162 | 163 | let payload = "s 2" 164 | 165 | let _payload = "s 2" 166 | 167 | // Add Overlay 168 | overlays.add(overlay) 169 | 170 | // Setup spy to monitor function 171 | let spy = sinon.spy(overlays, 'show') 172 | 173 | // Trigger event 174 | eventbus.emit('overlay:cupadd:show', payload) 175 | 176 | assert.isOk(spy.calledOnce) 177 | assert.deepEqual(spy.getCall(0).args[0], _overlay) 178 | assert.equal(spy.getCall(0).args[1], _payload) 179 | }) 180 | afterEach(function() { 181 | overlays.clear() 182 | }) 183 | }) 184 | describe('remove() - Unload Overlays', function() { 185 | it('Overlay does not exist: Does nothing', function() { 186 | overlays = new Overlays({events: eventbus}) 187 | 188 | let overlay = { 189 | name: 'hpcwins', 190 | type: 'video', 191 | file: 'overlays/events/hpcwins.mp4' 192 | } 193 | 194 | let _overlay = { 195 | name: 'hpcwins', 196 | type: 'video', 197 | directory: 'overlays/events', 198 | video: '/hpcwins/hpcwins.mp4', 199 | volume: 1 200 | } 201 | 202 | let _payload = { 203 | video: 'hpcwins.mp4' 204 | } 205 | 206 | overlays.add(overlay) 207 | let list = overlays.list() 208 | 209 | assert.equal(list.length, 1) 210 | assert.equal(list[0].name, _overlay.name) 211 | 212 | let fakeOverlay = { 213 | name: 'doesnotexist' 214 | } 215 | overlays.remove(fakeOverlay) 216 | let updatedList = overlays.list() 217 | assert.equal(list.length, 1) 218 | }) 219 | it('Unloads a single overlay', function() { 220 | overlays = new Overlays({events: eventbus}) 221 | 222 | let overlay = { 223 | name: 'hpcwins', 224 | type: 'video', 225 | file: 'overlays/events/hpcwins.mp4' 226 | } 227 | 228 | let _overlay = { 229 | name: 'hpcwins', 230 | type: 'video', 231 | directory: 'overlays/events', 232 | video: '/hpcwins/hpcwins.mp4', 233 | volume: 1 234 | } 235 | 236 | let _payload = { 237 | video: 'hpcwins.mp4' 238 | } 239 | 240 | overlays.add(overlay) 241 | let list = overlays.list() 242 | 243 | assert.equal(list.length, 1) 244 | assert.equal(list[0].name, _overlay.name) 245 | 246 | overlays.remove(overlay) 247 | let updatedList = overlays.list() 248 | assert.equal(list.length, 0) 249 | }) 250 | it('Loads an array of overlays', function() { 251 | overlays = new Overlays({events: eventbus}) 252 | 253 | let overlay = [{ 254 | name: 'hpcwins', 255 | type: 'video', 256 | file: 'overlays/events/hpcwins.mp4' 257 | }, { 258 | name: 'hpctest', 259 | type: 'audio', 260 | directory: '/test' 261 | }] 262 | 263 | let _overlays = [{ 264 | name: 'hpcwins', 265 | type: 'video', 266 | directory: 'overlays/events', 267 | video: '/hpcwins/hpcwins.mp4', 268 | layout: 'center', 269 | volume: 1 270 | }, { 271 | name: 'hpctest', 272 | layout: 'center', 273 | type: 'audio', 274 | directory: '/test' 275 | }] 276 | 277 | overlays.add(overlay) 278 | 279 | let list = overlays.list() 280 | 281 | assert.equal(list.length, 2) 282 | assert.deepEqual(list, _overlays) 283 | 284 | overlays.remove(overlay) 285 | let updatedList = overlays.list() 286 | assert.equal(list.length, 0) 287 | }) 288 | afterEach(function() { 289 | overlays.clear() 290 | }) 291 | }) 292 | describe('end() - Stops an overlay', function() { 293 | it('No payload: Ends a single overlay by id', function(done) { 294 | const overlay = { 295 | name: 'cupadd', 296 | type: 'video', 297 | file: 'overlays/events/hpcwins.mp4' 298 | } 299 | 300 | overlays.add(overlay) 301 | 302 | let list = overlays.list() 303 | 304 | let id = list[0].id 305 | 306 | eventbus.on('overlays:cupadd:end', function(payload) { 307 | assert.equal(payload, null) 308 | done() 309 | }) 310 | 311 | overlays.end(id, overlay.name, null) 312 | }) 313 | afterEach(function() { 314 | overlays.clear() 315 | }) 316 | }) 317 | describe('hide() - Hides an overlay by name', function() { 318 | before(function() { 319 | overlays.clearState() 320 | }) 321 | it('No name: doesn\t hide an overlay', function() { 322 | const overlay = { 323 | name: 'cupadd', 324 | type: 'video', 325 | file: 'overlays/events/hpcwins.mp4' 326 | } 327 | 328 | overlays.add(overlay) 329 | let list = overlays.list() 330 | 331 | overlays.show(list[0], null) 332 | 333 | overlays.hide(null) 334 | 335 | let state = overlays.getState() 336 | 337 | assert.equal(state.length, 1) 338 | assert.equal(state[0].name, 'cupadd') 339 | }) 340 | it('Name but no overlays: does nothing', function() { 341 | overlays.hide('cupadd') 342 | 343 | let state = overlays.getState() 344 | 345 | assert.equal(state.length, 0) 346 | }) 347 | it('Name but no match: does nothing', function() { 348 | const overlay = { 349 | name: 'cupadd', 350 | type: 'video', 351 | file: 'overlays/events/hpcwins.mp4' 352 | } 353 | 354 | overlays.add(overlay) 355 | let list = overlays.list() 356 | 357 | overlays.show(list[0], null) 358 | 359 | overlays.hide('test') 360 | 361 | let state = overlays.getState() 362 | 363 | assert.equal(state.length, 1) 364 | assert.equal(state[0].name, 'cupadd') 365 | }) 366 | it('Name matches: removes overlay', function() { 367 | const overlay = { 368 | name: 'cupadd', 369 | type: 'video', 370 | file: 'overlays/events/hpcwins.mp4' 371 | } 372 | 373 | overlays.add(overlay) 374 | let list = overlays.list() 375 | 376 | overlays.show(list[0], null) 377 | 378 | overlays.hide('cupadd') 379 | 380 | let state = overlays.getState() 381 | 382 | assert.equal(state.length, 0) 383 | }) 384 | afterEach(function() { 385 | overlays.clearState() 386 | }) 387 | }) 388 | afterEach(function() { 389 | sandbox.restore() 390 | }) 391 | }) 392 | -------------------------------------------------------------------------------- /lib/types/audio.js: -------------------------------------------------------------------------------- 1 | /* audio.js - Re-usable module to create simple audio overlays */ 2 | 3 | var path = require('path'); 4 | 5 | var eventbus; // Global event bus attached to AudioOverlay object - used for passing events 6 | 7 | module.exports = AudioOverlay; 8 | 9 | function AudioOverlay(name, directory) { 10 | // this.name; 11 | // this.file; 12 | 13 | var self; // Used for scope when passing around events 14 | self = this; 15 | 16 | if(!name) { 17 | throw new Error("You must specify a name for this overlay") 18 | } 19 | 20 | // Required options 21 | this.name = name; // Unique identifier for event listeners 22 | 23 | this.directory = directory; 24 | this.type = 'audio'; 25 | }; 26 | -------------------------------------------------------------------------------- /lib/types/audio.spec.js: -------------------------------------------------------------------------------- 1 | /* Test for audio.js */ 2 | 3 | var assert = require('chai').assert; 4 | var sinon = require('sinon'); 5 | 6 | let audio 7 | 8 | describe('audio.js - Audio Overlay Module', function() { 9 | beforeEach(function() { 10 | this.sinon = sandbox = sinon.sandbox.create(); 11 | audio = require('./audio.js') 12 | }); 13 | it('Returns an overlay when given parameters', () => { 14 | const name = 'testOverlay' 15 | const directory = './test' 16 | 17 | const _name = 'testOverlay' 18 | const _directory = './test' 19 | const _type = 'audio' 20 | 21 | let overlay = new audio(name, directory) 22 | 23 | assert.equal(overlay.name, _name) 24 | assert.equal(overlay.directory, _directory) 25 | assert.equal(overlay.type, _type) 26 | }); 27 | it('Errors if no name is provided', () => { 28 | const directory = './test' 29 | 30 | assert.throws(() => { 31 | let overlay = new audio(name, directory) 32 | }, 'name is not defined') 33 | }) 34 | afterEach(function() { 35 | sandbox.restore(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /lib/types/html.js: -------------------------------------------------------------------------------- 1 | /* html.js - Re-usable module to display html on your stream */ 2 | 3 | var path = require('path'); 4 | var fs = require('fs'); 5 | 6 | var eventbus; // Global event bus attached to HtmlOverlay object - used for passing events 7 | 8 | module.exports = HtmlOverlay; 9 | 10 | function HtmlOverlay(name, view, static) { 11 | // name - name to reference the overlay 12 | // view - the .html file to render when requested 13 | // static - custom static directory to host videos, images, etc 14 | 15 | var self; // Used for scope when passing around events 16 | self = this; 17 | 18 | if(!name) { 19 | throw new Error("You must specify a name for this overlay") 20 | } 21 | if(!view) { 22 | throw new Error("You must specify an .html view"); 23 | } 24 | 25 | // Required parameters 26 | this.name = name; // Unique identifier for event listeners 27 | this.type = 'html'; 28 | this.html = fs.readFileSync(view).toString(); // Grab file from disk and pass it to the template 29 | this.directory = path.join(path.dirname(view)); 30 | 31 | // Optional parameters 32 | if(static) { 33 | // Custom directory to serve static assets from 34 | this.directory = static; 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /lib/types/html.spec.js: -------------------------------------------------------------------------------- 1 | /* Test for html.js */ 2 | 3 | var assert = require('chai').assert; 4 | var sinon = require('sinon'); 5 | 6 | let html 7 | 8 | describe('html.js - HTML Overlay Module', function() { 9 | beforeEach(function() { 10 | this.sinon = sandbox = sinon.sandbox.create(); 11 | html = require('./html.js') 12 | }); 13 | it('Returns an overlay when given parameters', () => { 14 | const name = 'testOverlay' 15 | const view = 'lib/types/html_test.html' 16 | 17 | const _name = 'testOverlay' 18 | const _html = '\n\n' 19 | const _type = 'html' 20 | const _directory = 'lib/types' 21 | 22 | let overlay = new html(name, view) 23 | 24 | assert.equal(overlay.name, _name) 25 | assert.equal(overlay.html, _html) 26 | assert.equal(overlay.type, _type) 27 | assert.equal(overlay.directory, _directory) 28 | }); 29 | it('Returns an static directory when provided', () => { 30 | const name = 'testOverlay' 31 | const view = 'lib/types/html_test.html' 32 | const static = '..' 33 | 34 | const _name = 'testOverlay' 35 | const _html = '\n\n' 36 | const _type = 'html' 37 | const _directory = '..' 38 | 39 | let overlay = new html(name, view, static) 40 | 41 | assert.equal(overlay.name, _name) 42 | assert.equal(overlay.html, _html) 43 | assert.equal(overlay.type, _type) 44 | assert.equal(overlay.directory, _directory) 45 | }); 46 | it('Errors if no name is provided', () => { 47 | const view = './html_test.html' 48 | 49 | const _err = 'You must specify a name for this overlay' 50 | 51 | assert.throws(() => { 52 | let overlay = new html(null, view) 53 | }, _err) 54 | 55 | }) 56 | it('Errors if no view is provided', () => { 57 | const name = 'testOverlay' 58 | 59 | const _err = "You must specify an .html view" 60 | 61 | assert.throws(() => { 62 | let overlay = new html(name, null) 63 | }, _err) 64 | }) 65 | 66 | afterEach(function() { 67 | sandbox.restore(); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /lib/types/html_test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /lib/types/text.js: -------------------------------------------------------------------------------- 1 | /* text.js - Re-usable module to read text on your stream */ 2 | 3 | var path = require('path'); 4 | 5 | var eventbus; // Global event bus attached to TextOverlay object - used for passing events 6 | 7 | module.exports = TextOverlay; 8 | 9 | function TextOverlay(text) { 10 | // text - text to display and read aloud 11 | 12 | // Required parameters 13 | this.name = 'text'; // Unique identifier for event listeners 14 | this.type = 'text'; 15 | this.directory = path.join(path.dirname('./static')); 16 | this.text = text; 17 | }; 18 | -------------------------------------------------------------------------------- /lib/types/text.spec.js: -------------------------------------------------------------------------------- 1 | /* Test for text.js */ 2 | 3 | var assert = require('chai').assert; 4 | var sinon = require('sinon'); 5 | 6 | let text 7 | 8 | describe('text.js - HTML Overlay Module', function() { 9 | beforeEach(function() { 10 | this.sinon = sandbox = sinon.sandbox.create(); 11 | text = require('./text.js') 12 | }); 13 | it('Returns an overlay when given parameters', () => { 14 | const input = 'whatever' 15 | 16 | const _name = 'text' 17 | const _type = 'text' 18 | const _text = 'whatever' 19 | const _directory = '.' 20 | 21 | let overlay = new text(text) 22 | 23 | assert.equal(overlay.name, _name) 24 | assert.equal(overlay.text, text) 25 | assert.equal(overlay.type, _type) 26 | assert.equal(overlay.directory, _directory) 27 | }); 28 | afterEach(function() { 29 | sandbox.restore(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /lib/types/video.js: -------------------------------------------------------------------------------- 1 | /* video.js - Re-usable module to create simple video overlays */ 2 | 3 | var path = require('path'); 4 | 5 | var eventbus; // Global event bus attached to AudioOverlay object - used for passing events 6 | 7 | module.exports = VideoOverlay; 8 | 9 | function VideoOverlay(name, file, volume) { 10 | 11 | if(!name) { 12 | throw new Error("You must specify a name for this overlay") 13 | } 14 | if(!file) { 15 | throw new Error("You must specify a Video URL"); 16 | } 17 | 18 | // Required options 19 | this.name = name; // Unique identifier for event listeners 20 | this.type = 'video'; 21 | 22 | // Assemble filename 23 | var filename = path.basename(file); 24 | var directory = path.join(path.dirname(file)); 25 | 26 | this.directory = directory; // Path to file will be added as a static endpoint 27 | 28 | this.video = '/' + this.name + '/' + filename; 29 | 30 | // Set volume (optional) 31 | if(volume) { 32 | this.volume = volume; 33 | } else { 34 | this.volume = 1.0; 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /lib/types/video.spec.js: -------------------------------------------------------------------------------- 1 | /* Test for video.js */ 2 | 3 | var assert = require('chai').assert; 4 | var sinon = require('sinon'); 5 | 6 | let video 7 | 8 | describe('video.js - video Overlay Module', function() { 9 | beforeEach(function() { 10 | this.sinon = sandbox = sinon.sandbox.create(); 11 | video = require('./video.js') 12 | }); 13 | it('Returns an overlay when given parameters', () => { 14 | const name = 'testOverlay' 15 | const file = './video_test.mp4' 16 | 17 | const _name = 'testOverlay' 18 | const _video = '/testOverlay/video_test.mp4' 19 | const _type = 'video' 20 | const _directory = '.' 21 | const _volume = 1.0 22 | 23 | let overlay = new video(name, file) 24 | 25 | assert.equal(overlay.name, _name) 26 | assert.equal(overlay.video, _video) 27 | assert.equal(overlay.type, _type) 28 | assert.equal(overlay.directory, _directory) 29 | assert.equal(overlay.volume, _volume) 30 | }); 31 | it('Returns a volume when provided', () => { 32 | const name = 'testOverlay' 33 | const file = './video_test.mp4' 34 | const volume = 0.3 35 | 36 | const _name = 'testOverlay' 37 | const _video = '/testOverlay/video_test.mp4' 38 | const _type = 'video' 39 | const _directory = '.' 40 | const _volume = 0.3 41 | 42 | let overlay = new video(name, file, volume) 43 | 44 | assert.equal(overlay.name, _name) 45 | assert.equal(overlay.video, _video) 46 | assert.equal(overlay.type, _type) 47 | assert.equal(overlay.directory, _directory) 48 | assert.equal(overlay.volume, _volume) 49 | }); 50 | it('Errors if no name is provided', () => { 51 | const videoInput = './video_test.video' 52 | 53 | const _err = 'You must specify a name for this overlay' 54 | 55 | assert.throws(() => { 56 | let overlay = new video(null, videoInput) 57 | }, _err) 58 | 59 | }) 60 | it('Errors if no view is provided', () => { 61 | const name = 'testOverlay' 62 | 63 | const _err = "You must specify a Video URL" 64 | 65 | assert.throws(() => { 66 | let overlay = new video(name, null) 67 | }, _err) 68 | }) 69 | 70 | afterEach(function() { 71 | sandbox.restore(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /lib/views/index.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang="en") 3 | head 4 | script(src='/socket.io/socket.io.js') 5 | script(src='http://code.responsivevoice.org/responsivevoice.js') 6 | link(href='/css/min.css' rel='stylesheet' type='text/css') 7 | body(bgcolor=transparent) 8 | #overlays 9 | 10 | // Load react client-side app 11 | script(src='/js/bundle.js') 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitch-overlay", 3 | "version": "1.5.0", 4 | "description": "Overlay for Twitch streamers that can be loaded into OBS or XSplit", 5 | "keywords": [ 6 | "twitch", 7 | "obs", 8 | "xsplit" 9 | ], 10 | "main": "index.js", 11 | "scripts": { 12 | "start": "node index.js", 13 | "build": "./node_modules/.bin/webpack", 14 | "dev": "./node_modules/.bin/webpack -d -w", 15 | "test": "mocha lib/*.spec.js lib/**/*.spec.js --exit", 16 | "watch": "mocha -w lib/*.spec.js lib/**/*.spec.js" 17 | }, 18 | "engines": { 19 | "node": ">=8.0.0" 20 | }, 21 | "author": "bdickason", 22 | "contributors": [ 23 | { 24 | "name": "bdickason", 25 | "email": "dickason@gmail.com" 26 | } 27 | ], 28 | "repository": { 29 | "type": "git", 30 | "url": "git://github.com/bdickason/twitch-overlay.git" 31 | }, 32 | "dependencies": { 33 | "babel-core": "^6.26.0", 34 | "babel-loader": "^7.1.2", 35 | "babel-preset-env": "^1.6.1", 36 | "babel-preset-react": "^6.24.1", 37 | "express": "4.15.2", 38 | "extend": "3.0.0", 39 | "pug": "2.0.0-beta11", 40 | "react": "^16.2.0", 41 | "react-dom": "^16.2.0", 42 | "react-html-parser": "^2.0.2", 43 | "shortid": "^2.2.8", 44 | "socket.io": "1.7.3", 45 | "webpack": "^3.8.1" 46 | }, 47 | "license": "GPL-3.0", 48 | "devDependencies": { 49 | "chai": "^4.1.2", 50 | "css-loader": "^0.28.7", 51 | "mocha": "^4.0.1", 52 | "sinon": "^4.1.3", 53 | "style-loader": "^0.19.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var path = require('path'); 3 | 4 | var BUILD_DIR = path.resolve(__dirname, 'lib/static/js'); 5 | var APP_DIR = path.resolve(__dirname, 'lib/client'); 6 | 7 | var config = { 8 | devtool: 'source-map', 9 | entry: APP_DIR + '/overlay-client.jsx', 10 | output: { 11 | path: BUILD_DIR, 12 | filename: 'bundle.js' 13 | }, 14 | module: { 15 | loaders: [ 16 | { 17 | test: /\.js?/, 18 | include: APP_DIR, 19 | loader: 'babel-loader' 20 | }, 21 | { 22 | test: /\.css$/, 23 | loader: 'style-loader!css-loader' 24 | } 25 | ] 26 | } 27 | }; 28 | 29 | module.exports = config; 30 | --------------------------------------------------------------------------------