├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.yml ├── .gitattributes ├── .github ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── lint.yml │ └── release-drafter.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── TODO.md ├── app.js ├── config.js.example ├── helpers.js ├── modules ├── companion-controller.js ├── http-controller.js ├── jm-live-event-controller.js ├── midi-controller.js ├── module.js ├── obs-controller.js ├── onyx-controller.js ├── osc-controller.js ├── pro.js ├── vmix-controller.js ├── web-logger.js └── x32-controller.js ├── package.json ├── start.bat ├── start.sh ├── test-7.9+.html ├── test-obs.js ├── test.html ├── test.js ├── ui ├── index-electron.html ├── index-old.html ├── index.html ├── lib │ ├── mdi │ │ ├── css │ │ │ ├── materialdesignicons.css │ │ │ ├── materialdesignicons.css.map │ │ │ ├── materialdesignicons.min.css │ │ │ └── materialdesignicons.min.css.map │ │ └── fonts │ │ │ ├── materialdesignicons-webfont.eot │ │ │ ├── materialdesignicons-webfont.ttf │ │ │ ├── materialdesignicons-webfont.woff │ │ │ └── materialdesignicons-webfont.woff2 │ ├── vue.js │ ├── vuetify.css │ └── vuetify.js ├── lower3.html ├── lower3.jpg ├── presenter.html ├── sd.html ├── side3.html └── style.css └── watcher.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env"], 3 | "plugins": [ 4 | "@babel/plugin-syntax-class-properties" 5 | ] 6 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [{*.cjs, *.html, *.js, *.json, *.mjs, *.rjson, *.ts}] 2 | end_of_line = lf 3 | trim_trailing_whitespace = true 4 | insert_final_newline = true 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | ui/lib/* 2 | node_modules/* -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | node: true 5 | # plugins: 6 | # - prettier 7 | # extends: 8 | # - prettier 9 | # - "plugin:prettier/recommended" 10 | parser: "@babel/eslint-parser" 11 | parserOptions: 12 | ecmaVersion: 12 13 | sourceType: module 14 | rules: { 15 | # "prettier/prettier": error 16 | } 17 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.eot -text diff 3 | *.woff -text diff 4 | *.woff2 -text diff 5 | *.ttf -text diff 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | open-pull-requests-limit: 10 8 | versioning-strategy: increase -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | change-template: "- #$NUMBER - $TITLE (@$AUTHOR)" 2 | categories: 3 | - title: "⚠ Breaking Changes" 4 | labels: 5 | - "breaking change" 6 | template: | 7 | ## What’s Changed 8 | 9 | $CHANGES 10 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Install Packages 13 | run: | 14 | sudo apt-get update 15 | sudo apt-get install libasound2-dev 16 | - name: Install modules 17 | run: npm install 18 | - name: Run ESLint 19 | run: npx eslint . 20 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | # Drafts your next Release notes as Pull Requests are merged into "master" 13 | - uses: release-drafter/release-drafter@v5 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | config.js 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | ui/lib/* 3 | # vscode 4 | .vscode/* 5 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "useTabs": false, 7 | "printWidth": 100 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode" 5 | ] 6 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.configPath": ".prettierrc.json", 3 | "editor.formatOnSave": true, 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "editor.formatOnPaste": true, 6 | "editor.rulers": [ 7 | 100 8 | ] 9 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This node application listens to ProPresenter's Stage Display and/or Control interfaces 2 | and will trigger javascript actions based upon updates from them. 3 | 4 | These modules are included: 5 | 6 | - ProPresenter -- for listening to and controlling ProPresenter 7 | - vMix -- for sending vMix API messages 8 | - LCC Live Event Controller -- for controlling the LCC Live Event System 9 | - Web Logger -- for sending log strings to a webserver with apikey authentication 10 | - MIDI -- for sending midi notes, program changes, control changes, and timecodes 11 | 12 | Any npm package can be installed and used. 13 | The recommended way to add new functionality is to include the package with 14 | 15 | `npm install --save package-name` 16 | 17 | and then to write a wrapper class in the modules directory. 18 | 19 | Import the class and create an instance of the class in the main app.js file so that the triggers can fire appropriately. 20 | 21 | Important documentation is contained in the app.js file. 22 | 23 | ## Installation 24 | 25 | ``` 26 | git clone https://github.com/jeffmikels/propresenter-watcher.git 27 | cd propresenter-watcher 28 | npm install 29 | ``` 30 | 31 | During installation, a number of node modules will be downloaded. 32 | 33 | However, the MIDI component of this system relies on the `node-midi` module which requires compiling `rtmidi`, and therefore, a compiler must be installed. 34 | 35 | The easiest way to do that is to follow the `node-gyp` instructions for your operating system here [https://github.com/nodejs/node-gyp]. 36 | 37 | If you ever need to rebuild the midi module, you can use this command: 38 | 39 | ``` 40 | npm rebuild 41 | ``` 42 | 43 | For more information, visit the node-midi documentation here [https://github.com/justinlatimer/node-midi] 44 | 45 | ## Configuration 46 | 47 | After installation is complete copy `config.js.example` to `config.js` and edit the file according to your system's needs. 48 | 49 | Note: ProPresenter will only open one network port, and the main network port takes precedence. In other words, save yourself some hassle and put the same port number in both fields of the ProPresenter Network configuration. 50 | 51 | ## Setting Up Triggers 52 | 53 | Read the comments in the `app.js` file so you can understand the way the slide notes should be created in ProPresenter. Then, take a look at how the example triggers are configured and feel free to change them for your needs. 54 | 55 | ## Using Live 56 | 57 | ### Run the app 58 | 59 | To run the app, open up a terminal / command window in the folder where this code is stored. 60 | 61 | ``` 62 | node app.js 63 | ``` 64 | 65 | ### Open the UI 66 | 67 | Then, open a browser to `http://localhost:7000` (or whatever port you specified in the config.js file). 68 | 69 | ### Use the vmix lower3 webpage 70 | 71 | If you are integrating with vmix, obs, or some other system, you can open a specially designed "lower third" webpage that will be updated whenever ProPresenter text changes. 72 | 73 | `http://localhost:7000/lower3.html` 74 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - UI: Add/Remove/Configure modules from the frontend 4 | 5 | - UI: Allow end user to edit custom triggers by associating a ProPresenter event / note tag with an arbitrary HTTP/TCP/WebSocket call 6 | 7 | - UI: have each controller module expose an "arbitrary" command that can take an arbitrary string and send it to the app it controls 8 | 9 | - UI: Allow end user to edit custom triggers for each module by linking javascript code to global ProPresenter events like timer updates. 10 | 11 | - BACKEND: ensure that custom triggers get added to the config file. 12 | 13 | - UI: add documentation about the config file and the advanced triggers. 14 | 15 | - UI: build an electron wrapper around the app 16 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | // connects to ProPresenter 6 stage display (a websocket connection) 2 | // listens for events 3 | // runs triggers based on those events 4 | // see the documentation below 5 | 'use strict'; 6 | 7 | const os = require('os'); 8 | const path = require('path'); 9 | const fs = require('fs'); 10 | 11 | // ----- SETUP HAPPENS HERE ---------------- 12 | const HOME = os.homedir(); 13 | const CONF_FILE = path.join(HOME, '.config', 'pro-presenter-control.json'); 14 | 15 | // app-level configuration file 16 | const config = require('./config.js'); 17 | loadLocalConfigFile(); 18 | 19 | const { markdown } = require('./helpers.js'); 20 | const { ModuleTrigger, ModuleTriggerArg, GlobalModule } = require('./modules/module.js'); 21 | 22 | let Log = console.log; 23 | 24 | // Object Extension to add a "clear" function on objects 25 | Object.prototype.clear = function () { 26 | if (Array.isArray(this)) this.length = 0; 27 | else Object.keys(this).forEach((k) => delete this[k]); 28 | }; 29 | 30 | // ----- MODULES AND TRIGGERS -------------- 31 | 32 | // available modules 33 | // each module supports the same basic api: 34 | // static supports multiple? 35 | // static name 36 | // static create(options) // creates a new instance of this class from options 37 | // getInfo() // reports instance id and trigger documentation 38 | // registerTrigger( ModuleTrigger() ) // registers a trigger exposed by this module 39 | // handleTrigger(tagname, args, onSuccess, onError) 40 | // if a module supports multiple instances, the module must declare it so, 41 | // 42 | // modules must maintain their own connection to whatever it is they control 43 | 44 | // Everything begins with ProPresenter, so it should always be instantiated 45 | const { ProController } = require('./modules/pro.js'); 46 | 47 | // Controller Modules for Known Products 48 | const { VmixController } = require('./modules/vmix-controller.js'); 49 | const { X32Controller } = require('./modules/x32-controller.js'); 50 | const { JMLiveEventController } = require('./modules/jm-live-event-controller.js'); 51 | const { CompanionController } = require('./modules/companion-controller.js'); 52 | const { OscController } = require('./modules/osc-controller.js'); 53 | const { MidiController } = require('./modules/midi-controller.js'); 54 | const { OnyxController } = require('./modules/onyx-controller.js'); 55 | const { OBSController } = require('./modules/obs-controller.js'); 56 | const { HTTPController } = require('./modules/http-controller.js'); 57 | 58 | // arbitrary controllers for unknown products that support standard protocols 59 | // const { TCPController } = require( "./modules/tcp-controller.js" ); 60 | // const { SocketIOController } = require( './modules/socketio-controller.js' ); 61 | // const { WebSocketController } = require( "./modules/websocket-controller.js" ); 62 | 63 | // put modules into various structures to make access easier 64 | 65 | const modulesByName = {}; 66 | modulesByName[ProController.name] = ProController; 67 | modulesByName[VmixController.name] = VmixController; 68 | modulesByName[X32Controller.name] = X32Controller; 69 | modulesByName[JMLiveEventController.name] = JMLiveEventController; 70 | modulesByName[CompanionController.name] = CompanionController; 71 | modulesByName[OscController.name] = OscController; 72 | modulesByName[MidiController.name] = MidiController; 73 | modulesByName[OnyxController.name] = OnyxController; 74 | modulesByName[OBSController.name] = OBSController; 75 | modulesByName[HTTPController.name] = HTTPController; 76 | // modulesByName[ SocketIOController.name ] = SocketIOController; 77 | // modulesByName[ WebSocketController.name ] = WebSocketController; 78 | // modulesByName[ TCPController.name ] = TCPController; 79 | 80 | const globalController = new GlobalModule(); 81 | 82 | // this will keep a registration of all the enabled and configured controllers 83 | // each controller should expose its own commands and documentation 84 | const configuredControllers = []; 85 | const configuredControllersByUuid = {}; 86 | 87 | // create data structures to make it easier to access 88 | // the triggers exposed by each controller 89 | const configuredTriggers = []; 90 | const configuredTriggersByUuid = {}; 91 | 92 | let allow_triggers = true; 93 | 94 | let lower3 = { 95 | text: '', 96 | html: '', 97 | caption: '', 98 | image: config.LOWER3_IMAGE, 99 | }; 100 | 101 | let pro; // this will become the top level master propresenter module when we initialize it 102 | 103 | setGlobalTriggers(); 104 | registerAllConfigured(); 105 | 106 | // -------------------------------- 107 | // - ALL MODULES ARE NOW CONFIGURED 108 | // -------------------------------- 109 | // TODO: CONVERT PRO NOTES TO USE NEW TRIGGERS 110 | // [sermon_start] => log[SERMON STARTING] 111 | // [sermon_end] => log[SERMON ENDED] 112 | 113 | // need to create pluggable triggers for other propresenter states 114 | // need to create plugin system for additional modules 115 | 116 | // ---- UI SERVER CODE --- 117 | const http = require('http'); 118 | const url = require('url'); 119 | const WebSocket = require('ws'); 120 | const help = ` 121 | possible endpoints are the following: 122 | /api/help ← return this text 123 | /api/status ← return current status 124 | /api/triggers ← returns a list of current triggers 125 | /api/toggle/[trigger_uuid] ← toggles the status of a trigger 126 | /api/toggle ← toggles the status of all trigger processing 127 | `; 128 | 129 | const server = http.createServer(httpHandler); 130 | 131 | // handles realtime communication with frontend 132 | const wss = new WebSocket.Server({ 133 | server: server, 134 | clientTracking: true, 135 | }); 136 | 137 | wss.on('error', (e) => { 138 | console.log(e); 139 | }); 140 | 141 | wss.on('connection', function connection(ws) { 142 | ws.isAlive = true; 143 | 144 | ws.bettersend = function (message = '', data = {}) { 145 | let tosend = JSON.stringify({ message, data }); 146 | // console.log( 'sending:' ); 147 | // console.log( tosend ); 148 | ws.send(tosend); 149 | }; 150 | 151 | // SETUP MESSAGE CHANNELS FROM THE FRONTEND 152 | ws.on('message', function incoming(raw_message) { 153 | // to simulate socket.io 154 | // each "data" will be a JSON encoded dictionary 155 | // like this: 156 | // {'message': [string message], 'data': [submitted data]} 157 | console.log('received message from frontend'); 158 | console.log(raw_message); 159 | 160 | let { message, data } = JSON.parse(raw_message); 161 | switch (message) { 162 | case 'echo': 163 | broadcast('echo', data); 164 | break; 165 | case 'status': 166 | ws.bettersend('status', getStatus()); 167 | break; 168 | case 'pro_status': 169 | ws.bettersend('pro_status', getProStatus()); 170 | break; 171 | case 'full_status': 172 | ws.bettersend('full_status', getFullStatus()); 173 | break; 174 | case 'lower3': 175 | let status = getStatus(); 176 | ws.bettersend('lower3', status.lower3); 177 | break; 178 | case 'update_config': 179 | console.log('updating config'); 180 | Log(data); 181 | for (let key of Object.keys(config)) { 182 | if (config[key] != data[key]) config[key] = data[key]; 183 | } 184 | Log(config); 185 | saveConfig(); 186 | registerAllConfigured(); 187 | broadcast('status', getStatus()); 188 | break; 189 | case 'update_controller_config': 190 | Log('updating controller config'); 191 | Log(data); 192 | if (data.uuid && data.uuid in configuredControllersByUuid) { 193 | let controller = configuredControllersByUuid[data.uuid]; 194 | Log('BEFORE'); 195 | Log(controller.getInfo()); 196 | controller.updateConfig(data.config); // TODO: flesh this out for each component 197 | Log('AFTER'); 198 | Log(controller.getInfo()); 199 | } else { 200 | // the frontend has created a new controller! 201 | Log('creating a new controller'); 202 | } 203 | saveConfig(); // should read the config from each component and not from the global config object 204 | // processConfig(); 205 | // broadcast( 'status', getStatus() ); 206 | break; 207 | case 'update_controller': 208 | console.log('updating controller status'); 209 | Log(data); 210 | if (data.uuid in configuredControllersByUuid) { 211 | let controller = configuredControllersByUuid[data.uuid]; 212 | controller.enabled = data.enabled; 213 | for (let t of data.triggers) { 214 | if (t.uuid in configuredTriggersByUuid) { 215 | configuredTriggersByUuid[t.uuid].enabled = t.enabled; 216 | } 217 | } 218 | } 219 | broadcast('status', getStatus()); 220 | break; 221 | case 'update_trigger': 222 | console.log('updating trigger status'); 223 | Log(data); 224 | if (data.uuid in configuredTriggersByUuid) { 225 | configuredTriggersByUuid[data.uuid].enabled = data.enabled; 226 | } 227 | broadcast('status', getStatus()); 228 | break; 229 | 230 | // PROPRESENTER COMMANDS 231 | case 'trigger_slide': 232 | pro.remote.triggerSlide(data); 233 | break; 234 | case 'next_slide': 235 | pro.remote.next(); 236 | break; 237 | case 'prev_slide': 238 | pro.remote.prev(); 239 | break; 240 | case 'update_midi': 241 | console.log('selecting new MIDI port'); 242 | midi.closePort(); 243 | midi.openPort(data); 244 | break; 245 | case 'manual_notes_send': 246 | fireTriggersFromNotes(data); 247 | break; 248 | case 'toggle_allow_triggers': 249 | allow_triggers = data; 250 | broadcast('status', getStatus()); 251 | break; 252 | } 253 | }); 254 | }); 255 | 256 | // send keepalive pings 257 | // const interval = setInterval(function ws_ping() { 258 | // wss.clients.forEach(function each(ws) { 259 | // if (ws.isAlive === false) return ws.terminate(); 260 | // ws.isAlive = false; 261 | // ws.ping(noop); 262 | // }); 263 | // }, 30000); 264 | 265 | // and start the ui server 266 | console.log(` 267 | | 268 | | ProPresenter Watcher 269 | | UI available at http://localhost:${config.UI_SERVER_PORT} 270 | | 271 | `); 272 | server.listen(config.UI_SERVER_PORT, '0.0.0.0'); 273 | 274 | // PRIMARY FUNCTIONS 275 | function broadcast(message, data) { 276 | wss.clients.forEach(function each(ws) { 277 | ws.send(JSON.stringify({ message, data })); 278 | }); 279 | } 280 | 281 | function setGlobalTriggers() { 282 | // special triggers on the "Global" module 283 | // triggers for for custom logging 284 | globalController.registerTrigger( 285 | new ModuleTrigger( 286 | 'log', 287 | 'sends a log event to the logger of the format: "LOGSTRING: timestamp"', 288 | [new ModuleTriggerArg('string', 'string', 'string to log', true)], 289 | (_, s = '') => { 290 | if (s != null && s != '') s += ': '; 291 | Log(s + timestamp()); 292 | }, 293 | ), 294 | ); 295 | 296 | // special triggers for lower3 computations 297 | globalController.registerTrigger( 298 | new ModuleTrigger( 299 | 'l3', 300 | 'sets the lower third text', 301 | [ 302 | new ModuleTriggerArg( 303 | 'markdown_text', 304 | 'string', 305 | 'a string to be processed as markdown', 306 | false, 307 | ), 308 | ], 309 | (_, markdown_text) => { 310 | Log(markdown_text); 311 | lower3.text = markdown_text; 312 | lower3.html = markdown(markdown_text); 313 | }, 314 | ), 315 | ); 316 | globalController.registerTrigger( 317 | new ModuleTrigger( 318 | 'l3caption', 319 | 'sets the lower third caption text', 320 | [ 321 | new ModuleTriggerArg( 322 | 'caption', 323 | 'string', 324 | 'stores data to a lower third caption field', 325 | true, 326 | ), 327 | ], 328 | (_, caption) => { 329 | lower3.caption = caption; 330 | }, 331 | ), 332 | ); 333 | } 334 | 335 | function loadLocalConfigFile() { 336 | // user-level configuration file 337 | try { 338 | let jdata = fs.readFileSync(CONF_FILE); 339 | let localconf = JSON.parse(jdata); 340 | for (let key of Object.keys(localconf)) { 341 | config[key] = localconf[key]; 342 | } 343 | } catch (e) { 344 | console.log( 345 | `WARNING: Could not read local settings from ${CONF_FILE}, using defaults from repository`, 346 | ); 347 | } 348 | } 349 | 350 | // needs to process each component's config... not the global 351 | // config object we started with 352 | function saveConfig() { 353 | let dirname = path.dirname(CONF_FILE); 354 | let current = {}; 355 | for (let cm of configuredControllers) { 356 | // Log( cm.getInfo() ); 357 | if (cm == globalController) continue; 358 | 359 | // convert to array if another one by this key already exists 360 | if (cm.moduleName in current) { 361 | current[cm.moduleName] = [current[cm.moduleName]]; 362 | current[cm.moduleName].push(cm.config); 363 | } else { 364 | current[cm.moduleName] = cm.config; 365 | } 366 | } 367 | config.controllers = current; 368 | 369 | fs.mkdir(dirname, { recursive: true }, (err) => { 370 | if (err && err.code != 'EEXIST') { 371 | Log(`ERROR: Could not save settings to ${CONF_FILE}`); 372 | Log(err); 373 | } else { 374 | fs.writeFile(CONF_FILE, JSON.stringify(config, null, 2), 'utf8', (err) => { 375 | if (err) Log(err); 376 | else Log(`SUCCESS: Settings saved to ${CONF_FILE}`); 377 | }); 378 | } 379 | }); 380 | } 381 | 382 | function registerAllConfigured() { 383 | // ----- SETUP THE WEBLOGGER ------ 384 | if (config.USEWEBLOG) { 385 | const WebLogger = require('./modules/web-logger.js'); 386 | let weblog = new WebLogger(config.LOGGER_URL, config.LOGGER_KEY); 387 | Log = function (s, allowWebLog = true) { 388 | if (allowWebLog) weblog.log(s); 389 | console.log(s); 390 | }; 391 | } 392 | 393 | Log('Registering all configured controllers'); 394 | 395 | // since this might be the second time we have processed the configuration 396 | // we need to delete previously existing instances of controller modules 397 | // that means at the end of this, we will need to re-establish all event listeners 398 | for (let [name, mod] of Object.entries(modulesByName)) { 399 | if (mod.instances) { 400 | Log(`Clearing module instances for: ${name}`); 401 | mod.instances.forEach((e) => e.dispose()); 402 | mod.instances.length = 0; 403 | } 404 | } 405 | 406 | // reset the Controller and Trigger registrations 407 | configuredControllers.clear(); 408 | configuredControllersByUuid.clear(); 409 | configuredTriggers.clear(); 410 | configuredTriggersByUuid.clear(); 411 | 412 | // restore the global controller first 413 | registerControllerWithTriggers(globalController); 414 | 415 | // now, process the configuration and create all expected controllers 416 | // controller keys in the config file must match the static Module name of the controller 417 | for (let controllerName of Object.keys(config.controllers)) { 418 | if (!controllerName in modulesByName) continue; 419 | let controllerModule = modulesByName[controllerName]; 420 | let coptions = config.controllers[controllerName]; 421 | let cm; 422 | Log(`Reading configuration for controller: ${controllerName}`); 423 | if (Array.isArray(coptions)) { 424 | for (let instanceOptions of coptions) { 425 | cm = new controllerModule(instanceOptions); 426 | cm.on('log', (s) => Log(s)); 427 | registerControllerWithTriggers(cm); 428 | } 429 | } else { 430 | cm = new controllerModule(coptions); 431 | cm.on('log', (s) => Log(s)); 432 | registerControllerWithTriggers(cm); 433 | } 434 | } 435 | // we now have a configured module for each of the controllers specified in the 436 | // configuration file. Each of them should have created their own instances by now 437 | // and each of them should manage their own lifecycle 438 | 439 | // // finally, reconnect ProPresenter Listeners 440 | pro = ProController.master; 441 | setupProListeners(); 442 | } 443 | 444 | // takes a configured controller module and adds it to the 445 | // configured controllers and triggers structures 446 | function registerControllerWithTriggers(cm) { 447 | configuredControllers.push(cm); 448 | configuredControllersByUuid[cm.uuid] = cm; 449 | for (let trigger of cm.triggers) { 450 | configuredTriggers.push(trigger); 451 | configuredTriggersByUuid[trigger.uuid] = trigger; 452 | } 453 | } 454 | 455 | // ----- PRO PRESENTER LISTENERS ----- 456 | function setupProListeners() { 457 | pro.removeAllListeners(); 458 | 459 | pro.on('sysupdate', (e) => { 460 | // Log(e); 461 | if (allow_triggers) fireTriggers('~sysupdate~', [], pro); 462 | broadcast('sysupdate', e); 463 | }); 464 | 465 | // will fire for every individual timer update 466 | // pro.on( 'timerupdate', ( timer ) => { 467 | // // Log( timer ); 468 | // // fire a different trigger for each timer if that trigger exists 469 | // if ( allow_triggers ) fireTriggers( `timer-${timer.uid}`, [], pro ); 470 | // broadcast( 'timerupdate', timer ); 471 | // } ); 472 | 473 | // will fire max of once per second 474 | pro.on('clocksupdate', (clocks) => { 475 | // Log( timers ); 476 | for (let clock of clocks) { 477 | if (clock.updated && allow_triggers) { 478 | fireTriggers(`CLOCKUPDATE: ${clock.clockName}`, [], pro); 479 | } 480 | } 481 | // fire a global trigger for all timers 482 | if (allow_triggers) fireTriggers('~timersupdate~', [], pro); 483 | broadcast('clocksupdate', clocks); 484 | }); 485 | 486 | pro.on('slideupdate', (data) => { 487 | Log(data); 488 | console.log('--------- PRO SLIDE UPDATE -------------'); 489 | console.log(data); 490 | 491 | // always update the lower3 492 | // later triggers might override this 493 | lower3.text = pro.slides.current.text; 494 | lower3.html = markdown(pro.slides.current.text); 495 | lower3.caption = ''; 496 | 497 | // for each found tag, fire the matching triggers 498 | if (allow_triggers) { 499 | fireTriggersFromNotes(pro.slides.current.notes); 500 | fireTriggers('~slideupdate~', [], pro); 501 | } else { 502 | console.log('ProPresenter Update, but triggers are disabled.'); 503 | } 504 | console.log('-----------------------------------'); 505 | 506 | broadcast('slideupdate', data); 507 | broadcast('status', getStatus()); // contains lower3 data 508 | broadcast('pro_status', getProStatus()); // contains proPresenter data 509 | }); 510 | 511 | // pass all events directly through to the frontend 512 | pro.on('sddata', (data) => broadcast('sddata', data)); 513 | pro.on('sdupdate', (data) => broadcast('sdupdate', data)); 514 | pro.on('msgupdate', (data) => broadcast('msgupdate', data)); 515 | pro.on('remotedata', (data) => broadcast('remotedata', data)); 516 | pro.on('remoteupdate', (data) => broadcast('remoteupdate', data)); 517 | pro.on('log', console.log); // skip the weblog 518 | } 519 | 520 | function getStatus() { 521 | if (lower3.text == '' && pro.slides.current.text != '') { 522 | lower3.text = pro.slides.current.text; 523 | lower3.html = pro.slides.current.text; 524 | lower3.caption = ''; 525 | } 526 | return { 527 | config, 528 | allow_triggers, 529 | lower3, 530 | pro_connected: pro.connected, 531 | }; 532 | } 533 | 534 | function getProStatus() { 535 | return pro.fullStatus(); 536 | } 537 | 538 | function getFullStatus() { 539 | return { 540 | ...getStatus(), 541 | pro_status: pro.fullStatus(), 542 | controllers: configuredControllers.map((e) => e.getInfo()), 543 | triggers: configuredTriggers.map((e) => e.doc()), 544 | }; 545 | } 546 | 547 | function triggerStatus() { 548 | let retval = { allow_triggers, triggers: [] }; 549 | for (let i = 0; i < configuredTriggers.length; i++) { 550 | let t = configuredTriggers[i]; 551 | let o = { 552 | doc: t.doc(), 553 | id: i, 554 | }; 555 | retval.triggers.push(o); 556 | } 557 | return retval; 558 | } 559 | function fireTriggers(tagname, args = [], proInstance) { 560 | if (tagname != '~sysupdate~') Log(`\nTRIGGERS FOR: ${tagname}`); 561 | let used = false; 562 | configuredTriggers.forEach((t) => { 563 | if (t.enabled && t.tagname == tagname) { 564 | let { parentName, label, description } = t.doc(); 565 | Log(`TRIGGER: ${parentName} - ${label}\n => ${description}`); 566 | used = t.fireIfEnabled(args, proInstance) || used; 567 | } 568 | }); 569 | return used; 570 | } 571 | 572 | function makeTag(tag = '', type = 'short', args = []) { 573 | return { tag, type, args }; 574 | } 575 | 576 | // takes a string and looks for all 577 | // trigger codes of the formats: 578 | // [tag]content[/tag] 579 | // tag[arg1,arg2,arg3] 580 | function parseNotes(s = '') { 581 | let retval = []; 582 | const longcode = /\[([^\s]+)\](.*?)\[\/\1\]/gis; 583 | 584 | for (let found of findall(longcode, s)) { 585 | s = s.replace(found[0], ''); 586 | retval.push(makeTag(found[1], 'long', [found[2]])); 587 | } 588 | 589 | // because we want to support arbitrary strings in the shortcodes 590 | // they require a stream parser. 591 | let chars = s; 592 | let acc = []; 593 | let tag = ''; 594 | let args = []; 595 | let in_args = false; 596 | let in_delimited_string = false; 597 | let delimiters = /(['"`])/; 598 | let delimiter = ''; 599 | for (let i = 0; i < chars.length; i++) { 600 | let char = chars[i]; 601 | let m; 602 | if (in_args) { 603 | if (in_delimited_string) { 604 | if (char == delimiter) { 605 | in_delimited_string = false; 606 | let accumulated = acc.join(''); 607 | accumulated = accumulated.replace('\\n', '\n'); 608 | accumulated = accumulated.replace('\\r', '\r'); 609 | accumulated = accumulated.replace('\\t', '\t'); 610 | args.push(accumulated); 611 | acc = []; 612 | continue; 613 | } 614 | acc.push(char); 615 | continue; 616 | } 617 | 618 | m = char.match(delimiters); 619 | if (m) { 620 | delimiter = m[1]; 621 | in_delimited_string = true; 622 | continue; 623 | } 624 | 625 | if (char == ',') { 626 | args.push(acc.join('').trim()); 627 | acc = []; 628 | continue; 629 | } 630 | 631 | if (char == ']') { 632 | let leftover = acc.join('').trim(); 633 | acc = []; 634 | if (leftover.length > 0) { 635 | args.push(leftover); 636 | } 637 | retval.push(makeTag(tag, 'short', args)); 638 | in_args = false; 639 | args = []; 640 | tag = ''; 641 | continue; 642 | } 643 | 644 | acc.push(char); 645 | continue; 646 | } 647 | 648 | if (char == '[') { 649 | if (acc.length > 0) { 650 | tag = acc.join(''); 651 | acc = []; 652 | in_args = true; 653 | continue; 654 | } 655 | } 656 | 657 | // whitespace resets the accumulator outside of a tag or args list 658 | m = char.match(/\s/); 659 | if (m) { 660 | acc = []; 661 | continue; 662 | } 663 | 664 | acc.push(char); 665 | } 666 | return retval; 667 | } 668 | 669 | function fireTriggersFromNotes(noteText) { 670 | let used = false; 671 | let foundTags = parseNotes(noteText); 672 | Log(foundTags); 673 | 674 | for (let { tag, args } of foundTags) { 675 | used = fireTriggers(tag, args, pro) || used; 676 | } 677 | 678 | if (!used) { 679 | console.log('No triggers configured for this data:'); 680 | } 681 | } 682 | 683 | function httpHandler(req, res) { 684 | // console.log(req); 685 | if (req.url.match(/\/api\//)) { 686 | let match; 687 | let output; 688 | 689 | // get help 690 | match = req.url.match(/\/api\/help\/?$/); 691 | if (match) { 692 | res.writeHead(200, { 'Content-type': 'text/plain;charset=UTF-8' }); 693 | res.end(help); 694 | return; 695 | } else { 696 | res.writeHead(200, { 'Content-type': 'application/json;charset=UTF-8' }); 697 | } 698 | 699 | // get status 700 | match = req.url.match(/\/api\/status\/?$/); 701 | if (match) { 702 | output = JSON.stringify(getStatus()); 703 | } 704 | match = req.url.match(/\/api\/pro_status\/?$/); 705 | if (match) { 706 | output = JSON.stringify(getProStatus()); 707 | } 708 | match = req.url.match(/\/api\/full_status\/?$/); 709 | if (match) { 710 | output = JSON.stringify(getFullStatus()); 711 | } 712 | 713 | // get all triggers 714 | match = req.url.match(/\/api\/triggers\/?$/); 715 | if (match) { 716 | output = JSON.stringify(triggerStatus()); 717 | } 718 | 719 | match = req.url.match(/\/api\/toggle\/?$/); 720 | if (match) { 721 | allow_triggers = !allow_triggers; 722 | output = JSON.stringify(triggerStatus()); 723 | } 724 | 725 | match = req.url.match(/\/api\/toggle\/([^\/]*)\/?$/); 726 | if (match) { 727 | let uuid = match[1]; 728 | if (uuid in configuredTriggersByUuid) 729 | configuredTriggersByUuid[uuid].enabled = !configuredTriggersByUuid[uuid].enabled; 730 | output = JSON.stringify(triggerStatus()); 731 | } 732 | res.end(output); 733 | } else { 734 | // does the request result in a real file? 735 | let pathName = url.parse(req.url).pathname; 736 | if (pathName == '/') pathName = '/index.html'; 737 | console.log(pathName); 738 | fs.readFile(__dirname + '/ui' + pathName, function (err, data) { 739 | if (err) { 740 | res.writeHead(404); 741 | res.write('Page not found.'); 742 | res.end(); 743 | } else { 744 | let header = {}; 745 | if (pathName.match('.html')) header = { 'Content-type': 'text/html;charset=UTF-8' }; 746 | if (pathName.match('.css')) header = { 'Content-type': 'text/css;charset=UTF-8' }; 747 | res.writeHead(200, header); 748 | res.write(data); 749 | res.end(); 750 | } 751 | }); 752 | } 753 | } 754 | 755 | function findall(regex, subject) { 756 | let matches = []; 757 | let match = true; 758 | while (match) { 759 | match = regex.exec(subject); 760 | if (match) { 761 | matches.push(match); 762 | } 763 | } 764 | return matches; 765 | } 766 | 767 | function timestamp() { 768 | let d = new Date(); 769 | let year = d.getFullYear(); 770 | let month = d.getMonth().toString().padStart(2, '0'); 771 | let day = d.getDate().toString().padStart(2, '0'); 772 | let hour = d.getHours().toString().padStart(2, '0'); 773 | let min = d.getMinutes().toString().padStart(2, '0'); 774 | let sec = d.getSeconds().toString().padStart(2, '0'); 775 | return `${year}-${month}-${day} ${hour}:${min}:${sec}`; 776 | } 777 | -------------------------------------------------------------------------------- /config.js.example: -------------------------------------------------------------------------------- 1 | // this configuration file is overridden by whatever is in 2 | // $HOME/.config/pro-presenter-control.json 3 | 4 | const config = { 5 | UI_SERVER_PORT: 7000, 6 | LOWER3_IMAGE: "/lower3.jpg", 7 | 8 | // PROPRESENTER SETTINGS, 9 | PRO6_HOST: "192.168.1.4:60157", 10 | PRO6_SD_PASSWORD: "password", 11 | PRO6_CONTROL_PASSWORD: "password", 12 | 13 | // MIDI SETTINGS 14 | MIDI_PORT: 4, 15 | 16 | // JM_APPS LIVE EVENT SETTINGS 17 | JM_APPS_LIVE_URL: "https://example.org:99999", // no final slash 18 | 19 | // VMIX SETTINGS 20 | VMIX_HOST: "192.168.1.5:8088", // no final slash 21 | VMIX_LYRICS_INPUT: 10, 22 | 23 | // COMPANION (BITFOCUS) SETTINGS 24 | COMPANION_HOSTS: { first: "192.168.1.4:51234", second: "192.168.1.5:51234" }, 25 | 26 | // WEB LOGGER SETTINGS 27 | LOGGER_URL: "https://example.org/log/index.php", 28 | LOGGER_KEY: "key", 29 | USEWEBLOG: true, 30 | 31 | 32 | // for any controller, you may configure 33 | // additional triggers by adding a triggers array 34 | // the trigger callback will be called with the 35 | // controller's instance as the first argument to the callback 36 | // triggers look like this: 37 | /* 38 | triggers: [ 39 | { 40 | tagname: 'tag', 41 | description: 'description', 42 | args: [ 43 | { 44 | name: 'argname', 45 | type: 'number|string|json|bool', 46 | description: 'arg description', 47 | optional: true|false 48 | } 49 | ], 50 | callback: (self, proInstance, ...args) => {self.doSomething();} 51 | } 52 | ] 53 | */ 54 | 55 | controllers: { 56 | 57 | // PROPRESENTER SETTINGS 58 | // this is an array because we can have 59 | // multiple propresenter instances 60 | // any one of them can serve as the master 61 | // and any of the rest of them can be 'followers' 62 | // by default, the first one listed will be the master 63 | pro: [ 64 | { 65 | name: 'paul', 66 | host: 'localhost', 67 | port: 60157, 68 | sd_pass: 'av', 69 | remote_pass: 'control', 70 | version: 6, 71 | triggers: [], 72 | }, 73 | ], 74 | 75 | // MIDI SETTINGS 76 | midi: { 77 | port: 4, 78 | }, 79 | 80 | // enable the built-in http triggers 81 | http: {}, 82 | 83 | // JM_APPS LIVE EVENT SETTINGS 84 | jm_app_live_event: { 85 | url: "https://example.com", // no final slash 86 | }, 87 | 88 | // VMIX SETTINGS 89 | vmix: { 90 | host: "vmixip", // ip / hostname only 91 | port: 8088, // this should be the http port, telnet port is always 8099 92 | default_title_input: 10, 93 | }, 94 | 95 | // OBS SETTINGS 96 | obs: { 97 | host: 'localhost', 98 | port: 4444, 99 | password: 'obs', 100 | default_title_source: 'Pro Slide Text', 101 | }, 102 | 103 | // BITFOCUS COMPANION (StreamDeck) SETTINGS 104 | companion: [ 105 | { 106 | name: 'paul', 107 | host: '127.0.0.1', 108 | port: 51234, 109 | }, 110 | { 111 | name: 'barnabas', 112 | host: '127.0.0.2', 113 | port: 51234, 114 | }, 115 | ], 116 | 117 | // OSC SETTINGS 118 | // osc: { 119 | // host: "192.168.50.13", // telnet connections like integer port numbers 120 | // port: 2323, // must be an integer 121 | // } 122 | 123 | // X32 SETTINGS 124 | // x32: { 125 | // host: "192.168.50.10", // telnet connections like integer port numbers 126 | //} 127 | 128 | // ONYX SETTINGS 129 | // onyx: { 130 | // host: "127.0.0.3", // telnet connections like integer port numbers 131 | // port: 2323, // must be an integer 132 | // } 133 | }, 134 | }; 135 | 136 | module.exports = config; 137 | -------------------------------------------------------------------------------- /helpers.js: -------------------------------------------------------------------------------- 1 | // helper functions 2 | function hms2secs( hms ) { 3 | let sign = hms[ 0 ] == '-' ? -1 : 1; 4 | var [ h, m, s ] = hms.split( ':' ).map( e => parseInt( e ) ); // split it at the colons 5 | // the first number might be negative, so we 6 | // need to handle it specially; 7 | h = Math.abs( h ); 8 | var seconds = sign * ( h * 60 * 60 + m * 60 + s ); 9 | if ( isNaN( seconds ) ) seconds = 0; 10 | return seconds; 11 | } 12 | function timestring2secs( timestring ) { 13 | var match = timestring.match( /\s*(\d+:\d+)\s*([AP]M)/ ); 14 | if ( !match ) return 0; 15 | let [ h, m ] = match[ 1 ].split( ':' ).map( e => parseInt( e ) ); 16 | // the '+' prefix coerces the string to a number 17 | var seconds = h * 60 * 60 + m * 60; 18 | if ( isNaN( seconds ) ) seconds = 0; 19 | if ( match[ 2 ] == 'PM' ) seconds += 12 * 60 * 60; 20 | return seconds; 21 | } 22 | 23 | function markdown( s = '' ) { 24 | s = s.replace( /_(.*?)_/g, `$1` ); 25 | return s; 26 | } 27 | 28 | module.exports = { hms2secs, timestring2secs, markdown }; 29 | -------------------------------------------------------------------------------- /modules/companion-controller.js: -------------------------------------------------------------------------------- 1 | const net = require( 'net' ); 2 | const { Module, ModuleTrigger, ModuleTriggerArg } = require( './module' ); 3 | 4 | class CompanionController extends Module { 5 | static supportsMultiple = true; 6 | static lastId = 0; 7 | static name = 'companion'; 8 | static niceName = 'Companion Controller'; 9 | static instances = []; 10 | 11 | static create( config ) { 12 | return new CompanionController( config ); 13 | } 14 | 15 | constructor ( config, reset = false ) { 16 | super( config ); 17 | 18 | 19 | if ( reset ) { 20 | for ( let i of CompanionController.instances ) { 21 | i.dispose(); 22 | } 23 | CompanionController.instances.length = 0; 24 | } 25 | 26 | // store in the static instances list 27 | this.id = CompanionController.instances.length; 28 | CompanionController.instances.push( this ); 29 | 30 | this.updateConfig( config ); 31 | 32 | this.lastcommand = ''; 33 | this.lastmessage = ''; 34 | 35 | // COMPANION: 36 | // companionbutton[page number, button/bank number] 37 | const companion_button_pattern = /companionbutton\[\s*(.+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\]/gi; 38 | this.registerTrigger( 39 | new ModuleTrigger( 40 | 'companionbutton', 41 | 'click a streamdeck button', 42 | [ 43 | new ModuleTriggerArg( 'page', 'number', 'companion page 1-99', false ), 44 | new ModuleTriggerArg( 45 | 'button', 46 | 'number', 47 | 'button / bank number', 48 | false 49 | ), 50 | ], 51 | ( _, page, button ) => this.buttonPress( page, button ) 52 | ) 53 | ); 54 | 55 | // companionpage[page number, surface id] 56 | const companion_page_pattern = /companionpage\[\s*(.+)\s*,\s*(\d+)\s*,\s*(.+)\s*\]/gi; 57 | this.registerTrigger( 58 | new ModuleTrigger( 59 | 'companionpage', 60 | 'select a streamdeck page', 61 | [ 62 | new ModuleTriggerArg( 'page', 'number', 'companion page 1-99', false ), 63 | new ModuleTriggerArg( 'surface', 'string', 'surface id', false ), 64 | ], 65 | ( _, page, surface ) => this.pageSelect( page, surface ) 66 | ) 67 | ); 68 | 69 | this.onupdate = ( data ) => this.emit( 'update', data ); 70 | } 71 | 72 | updateConfig( config ) { 73 | super.updateConfig( config ); 74 | let { name, host, port } = config; 75 | this.name = name; 76 | this.host = host; 77 | this.port = +port; 78 | } 79 | 80 | msg( message ) { 81 | this.lastmessage = message; 82 | this.onupdate( message ); 83 | } 84 | 85 | send( cmd ) { 86 | this.lastcommand = cmd; 87 | console.log( `COMPANION: ${this.host}:${this.port} ${cmd}` ); 88 | this.msg( 'connecting to companion' ); 89 | 90 | let client = new net.Socket(); 91 | client.command = cmd; // save command to the client for later 92 | client.on( 'data', ( data ) => { 93 | let res = data.toString(); 94 | console.log( `COMPANION RESPONSE: (${client.command}) -> ${res}` ); 95 | if ( res.match( /\+OK/ ) ) this.msg( 'command successful' ); 96 | else this.msg( 'command failed' ); 97 | } ); 98 | client.connect( this.port, this.host, () => { 99 | console.log( `COMPANION SENDING: ${cmd}` ); 100 | this.msg( 'sending companion command' ); 101 | client.write( cmd + '\x0a' ); 102 | client.end(); 103 | } ); 104 | } 105 | 106 | pageSelect( page = null, surface = null ) { 107 | if ( page == null || surface == null ) { 108 | console.log( 'page select requires a page number and a surface id' ); 109 | this.msg( 'error sending command' ); 110 | return; 111 | } else { 112 | let cmd = `PAGE-SET ${page} ${surface}`; 113 | this.send( cmd ); 114 | } 115 | } 116 | 117 | buttonPress( page = null, button = null ) { 118 | if ( page == null || button == null ) { 119 | console.log( 'button press requires a page and a button/bank number' ); 120 | this.msg( 'error sending command' ); 121 | return; 122 | } else { 123 | let cmd = `BANK-PRESS ${page} ${button}`; 124 | this.send( cmd ); 125 | } 126 | } 127 | } 128 | 129 | module.exports.CompanionController = CompanionController; 130 | -------------------------------------------------------------------------------- /modules/http-controller.js: -------------------------------------------------------------------------------- 1 | const got = require( 'got' ); 2 | const { Module, ModuleTrigger, ModuleTriggerArg } = require( './module' ); 3 | 4 | // NOTE: OBS WebSocket Documentation is here: 5 | // https://www.npmjs.com/package/obs-websocket-js 6 | // https://github.com/Palakis/obs-websocket 7 | 8 | class HTTPController extends Module { 9 | static name = 'http'; 10 | static niceName = 'HTTP Controller'; 11 | static create( config ) { 12 | return new HTTPController( config ); 13 | } 14 | 15 | constructor ( config ) { 16 | super( config ); 17 | 18 | // setup triggers 19 | this.registerTrigger( 20 | new ModuleTrigger( 21 | 'http', 22 | `Will issue an arbitrary http request based on this slide note tag.`, 23 | [ 24 | new ModuleTriggerArg( 25 | 'url', 26 | 'string', 27 | 'url can be https or http, query params should be urlencoded', 28 | false, 29 | ), 30 | new ModuleTriggerArg( 31 | 'data', 'json', 'If data is empty, the method will be GET, otherwise the method will be POST. Data will be POSTed using content-type: application/json', true, 32 | ), 33 | new ModuleTriggerArg( 34 | 'bearer', 'string', 'If this request needs authorization, put the bearer token here.', true, 35 | ), 36 | ], 37 | async ( _, url, data = null, bearer = null ) => { 38 | let options = {}; 39 | let body; 40 | if ( bearer != null ) options.headers = { Authorization: `Bearer ${bearer}` } 41 | try { 42 | if ( data == null || data == '' || data == {} ) { 43 | let r = await got( url, options ); 44 | body = r.body; 45 | } else { 46 | let r = await got.post( url, { 47 | ...options, 48 | json: data, 49 | responseType: 'json', 50 | } ); 51 | body = r.body; 52 | } 53 | this.emit( 'body', body ); 54 | } catch ( e ) { 55 | this.emit( 'error', e ); 56 | } 57 | } 58 | ) 59 | ); 60 | } 61 | 62 | } 63 | 64 | module.exports.HTTPController = HTTPController; 65 | -------------------------------------------------------------------------------- /modules/jm-live-event-controller.js: -------------------------------------------------------------------------------- 1 | const io = require( 'socket.io-client' ); 2 | const { Module, ModuleTrigger, ModuleTriggerArg } = require( './module' ); 3 | 4 | // controls the JEFF_APPS live event server 5 | // using socket.io protocol 6 | class JMLiveEventController extends Module { 7 | static name = 'jm_app_live_event'; 8 | static niceName = 'Jeff Mikels Apps Live Event Controller'; 9 | static create( config ) { 10 | return new JMLiveEventController( config ); 11 | } 12 | 13 | constructor ( config ) { 14 | super( config ); 15 | 16 | this.connected = false; 17 | this.controlling = false; 18 | this.future_progress = null; 19 | 20 | this.updateConfig( config ); 21 | 22 | // LIVE EVENTS: 23 | // event[event_id] ← requests control of an event 24 | // live[progress_integer] ← sends progress to the event as an integer 25 | // const live_event_pattern = /event\[(\d+)\]/i; 26 | // const live_progress_pattern = /live\[(\d+)\]/i; 27 | this.registerTrigger( 28 | new ModuleTrigger( 29 | 'event', 30 | 'starts control for event', 31 | [ new ModuleTriggerArg( 'eid', 'number', 'event id', false ) ], 32 | ( _, eid ) => this.control( eid ) 33 | ) 34 | ); 35 | 36 | this.registerTrigger( 37 | new ModuleTrigger( 38 | 'live', 39 | 'sends progress update for this event', 40 | [ new ModuleTriggerArg( 'progress', 'number', 'progress', false ) ], 41 | ( _, progress ) => { 42 | this.update( progress ); 43 | 44 | // handle delayed event reset 45 | if ( progress == 999 ) 46 | setTimeout( () => { 47 | this.update( 0 ); 48 | this.log( 'automatically resetting event' ); 49 | }, 60 * 1000 ); 50 | } 51 | ) 52 | ); 53 | } 54 | 55 | updateConfig( config ) { 56 | super.updateConfig( config ); 57 | let { url, eid = 0 } = config; 58 | this.eid = eid; 59 | this.connect( url ); 60 | } 61 | 62 | connect( url ) { 63 | if ( this.socket ) this.socket.close(); 64 | 65 | this.log( 'JEFF_APPS LIVE: connecting to ' + url ); 66 | this.socket = io( url ); 67 | 68 | this.socket.on( 'connect', () => { 69 | this.log( 'JEFF_APPS LIVE: connected' ); 70 | this.connected = true; 71 | if ( this.eid ) this.control( this.eid ); 72 | } ); 73 | 74 | this.socket.on( 'disconnect', () => { 75 | this.log( 'JEFF_APPS LIVE: disconnected' ); 76 | this.connected = false; 77 | this.controlling = false; 78 | this.eid = null; 79 | } ); 80 | 81 | this.socket.on( 'control ready', ( data ) => { 82 | // console.log(data); 83 | this.log( 'JEFF_APPS LIVE: control confirmed for #' + this.eid ); 84 | this.controlling = this.eid; 85 | if ( this.future_progress != null ) { 86 | this.update( this.future_progress ); 87 | this.future_progress = null; 88 | } 89 | } ); 90 | 91 | this.socket.on( 'update progress', ( progress ) => { 92 | this.log( `PROGRESS CONFIRMED: ${progress}` ); 93 | } ); 94 | 95 | if ( this.eid ) this.control( eid ); 96 | } 97 | 98 | log( s ) { 99 | this.emit( 'update', s ); 100 | console.log( s ); 101 | } 102 | 103 | update( progress ) { 104 | if ( this.controlling ) { 105 | this.log( 'sending live progress: ' + progress ); 106 | this.socket.emit( 'control', progress ); 107 | } else { 108 | this.future_progress = progress; 109 | } 110 | } 111 | 112 | control( eid ) { 113 | if ( this.controlling == eid ) return; 114 | 115 | this.eid = eid; 116 | if ( this.connected ) { 117 | this.log( 'sending control request: ' + eid ); 118 | this.socket.emit( 'control request', eid ); 119 | } 120 | } 121 | } 122 | 123 | module.exports.JMLiveEventController = JMLiveEventController; 124 | -------------------------------------------------------------------------------- /modules/midi-controller.js: -------------------------------------------------------------------------------- 1 | const midi = require( 'midi' ); 2 | const { Module, ModuleTrigger, ModuleTriggerArg } = require( './module' ); 3 | 4 | class Timecode { 5 | constructor ( start, fps ) { 6 | let [ h, m, s, f ] = start.split( ':' ); 7 | this.h = +h || 0; 8 | this.m = +m || 0; 9 | this.s = +s || 0; 10 | this.f = +f || 1; 11 | this.fps = +fps; 12 | this.framenumber = 1; 13 | this.frametime = 1000 / this.fps; // in milliseconds 14 | } 15 | 16 | next() { 17 | this.framenumber += 1; 18 | this.f += 1; 19 | if ( this.f > this.fps ) { 20 | this.f = 1; 21 | this.s += 1; 22 | if ( this.s > 59 ) { 23 | this.s = 0; 24 | this.m += 1; 25 | if ( this.m > 59 ) { 26 | this.m = 0; 27 | this.h += 1; 28 | } 29 | } 30 | } 31 | } 32 | 33 | toString() { 34 | let h = this.h.toString().padStart( 2, '0' ); 35 | let m = this.m.toString().padStart( 2, '0' ); 36 | let s = this.s.toString().padStart( 2, '0' ); 37 | let f = this.f.toString().padStart( 2, '0' ); 38 | return `${h}:${m}:${s}:${f}`; 39 | } 40 | } 41 | 42 | class MidiController extends Module { 43 | static name = 'midi'; 44 | static niceName = 'MIDI Controller'; 45 | static create( config ) { 46 | return new MidiController( config ); 47 | } 48 | constructor ( config ) { 49 | super( config ); 50 | 51 | // this.input = new midi.Input(); 52 | this.output = new midi.Output(); 53 | this.ports = []; 54 | 55 | // get ports 56 | let portCount = this.output.getPortCount(); 57 | for ( let id = 0; id < portCount; id++ ) { 58 | let name = this.output.getPortName( id ); 59 | this.ports.push( { id, name } ); 60 | console.log( `MIDI PORT FOUND: ${id}: ${name}` ); 61 | } 62 | 63 | this.connected = false; 64 | this.port = null; 65 | 66 | // keep a record of all notes that are currently "on" 67 | this.playing = []; 68 | this.timecode = null; 69 | this.mtcTimer = null; 70 | this.nextFullFrameTime = null; 71 | this.nextFrameTime = null; 72 | this.mtcStarted = 0; 73 | 74 | // MIDI: 75 | // note[note,[velocity],[channel]] 76 | // channel defaults to 0 77 | // velocity defaults to 127 78 | const midi_note_pattern = /note\[(\d+)\s*(?:,\s*(\d+?))?\s*(?:,\s*(\d+))?\s*\]/gi; 79 | this.registerTrigger( 80 | new ModuleTrigger( 81 | 'note', 82 | 'play a midi note to the connected port', 83 | [ 84 | new ModuleTriggerArg( 'note', 'number', 'number from 0-127', false ), 85 | new ModuleTriggerArg( 86 | 'velocity', 87 | 'number', 88 | 'velocity from 0-127 defaults to 127', 89 | true 90 | ), 91 | new ModuleTriggerArg( 92 | 'channel', 93 | 'number', 94 | 'channel defaults to 0', 95 | true 96 | ), 97 | ], 98 | ( _, note, velocity = 127, channel = 0 ) => 99 | this.hit( note, velocity, channel ) 100 | ) 101 | ); 102 | 103 | // pc[program number, [channel]] 104 | // program change, channel defaults to 0 105 | const midi_pc_pattern = /pc\[(\d+)\s*(?:,\s*(\d+?))?\s*\]/gi; 106 | this.registerTrigger( 107 | new ModuleTrigger( 108 | 'pc', 109 | 'send a midi program change to the connected port', 110 | [ 111 | new ModuleTriggerArg( 'program', 'number', 'number', false ), 112 | new ModuleTriggerArg( 113 | 'channel', 114 | 'number', 115 | 'channel defaults to 0', 116 | true 117 | ), 118 | ], 119 | ( _, program, channel = 0 ) => this.program( program, channel ) 120 | ) 121 | ); 122 | 123 | // cc[controller (0-127), value, [channel]] 124 | // channel defaults to 0 125 | // both are required 126 | // note that controllers 120-127 are reserved for special channel mode messages 127 | // 120,0 = all sound off 128 | // 121,0 = reset controllers 129 | // 122,0 = local control off 130 | // 122,127 = local control on 131 | // 123,0 = all notes off 132 | // 124,0 = omni mode off 133 | // 125,0 = omni mode on 134 | // 126,c = mono mode where c is number of channels 135 | // 127,0 = poly mode 136 | const midi_cc_pattern = /cc\[(\d+)\s*(?:,\s*(\d+?))?\s*(?:,\s*(\d+))?\s*\]/gi; 137 | this.registerTrigger( 138 | new ModuleTrigger( 139 | 'cc', 140 | 'send a midi control change to the connected port', 141 | [ 142 | new ModuleTriggerArg( 143 | 'controller', 144 | 'number', 145 | 'number from 0-127 (silence all by setting cc 120 and cc 123 to 0)', 146 | false 147 | ), 148 | new ModuleTriggerArg( 149 | 'value', 150 | 'number', 151 | 'value from 0-127 defaults to 127', 152 | true 153 | ), 154 | new ModuleTriggerArg( 155 | 'channel', 156 | 'number', 157 | 'channel defaults to 0', 158 | true 159 | ), 160 | ], 161 | ( _, controller, value = 127, channel = 0 ) => 162 | this.control( controller, value, channel ) 163 | ) 164 | ); 165 | 166 | // mtc[initial timecode string, fps] 167 | // timecode string should be of the format HH:MM:SS:FF where FF is the number of the frame, zero indexed 168 | // fps is frames per second... defaults to 24 169 | // mtc[] will turn off the timecode generator 170 | const midi_mtc_pattern = /mtc\[(?:(.+?))?\s*(?:,\s*(\d+))?\s*\]/gi; 171 | this.registerTrigger( 172 | new ModuleTrigger( 173 | 'mtc', 174 | 'start/stop playing midi timecode to the connected port', 175 | [ 176 | new ModuleTriggerArg( 177 | 'initial', 178 | 'string', 179 | 'HH:MM:SS:FF where FF is frame number', 180 | true 181 | ), 182 | new ModuleTriggerArg( 'fps', 'number', 'defaults to 24', true ), 183 | ], 184 | ( _, initial, fps = 24 ) => 185 | initial == null ? this.mtcStop() : this.mtcStart( initial, fps ) 186 | ) 187 | ); 188 | 189 | this.updateConfig( config ); 190 | } 191 | 192 | updateConfig( config ) { 193 | super.updateConfig( config ); 194 | if ( this.port ) this.closePort(); 195 | if ( +( config.port ) ) this.openPort( +( config.port ) ); 196 | } 197 | 198 | status() { 199 | return { 200 | connected: this.connected, 201 | ports: this.ports, 202 | port: this.port, 203 | }; 204 | } 205 | 206 | closePort() { 207 | this.output.closePort(); 208 | this.connected = false; 209 | this.port = null; 210 | } 211 | 212 | openPort( id = 0 ) { 213 | if ( id >= this.output.getPortCount() ) { 214 | return false; 215 | } 216 | console.log( `MIDI: OPENING PORT ${id} : ${this.ports[ id ].name}` ); 217 | this.output.openPort( id ); 218 | this.port = this.ports[ id ]; 219 | this.connected = true; 220 | } 221 | 222 | // message should be an array matching the specification 223 | // here: http://www.midi.org/techspecs/midimessages.php 224 | // it's a plain wrapper for the node-midi function 225 | send( message ) { 226 | // console.log(message); 227 | this.output.sendMessage( message ); 228 | } 229 | 230 | panic() { 231 | for ( let channel = 0; channel < 16; channel++ ) { 232 | this.control( 120, 0, channel ); // all sound off 233 | this.control( 123, 0, channel ); // all notes off 234 | } 235 | } 236 | 237 | allOff() { 238 | for ( let [ note, data ] of Object.entries( this.notes ) ) { 239 | if ( data.playing ) this.note( note ); 240 | } 241 | } 242 | 243 | // simulates a note on/off sequence 244 | hit( note, velocity = 127, channel = 0, duration = 100 ) { 245 | this.note( note, velocity, channel ); 246 | setTimeout( () => { 247 | this.note( note, 0, 0 ); 248 | }, duration ); 249 | } 250 | 251 | // to send note off, just set velocity to 0 252 | note( note, velocity = 0, channel = 0 ) { 253 | console.log( `MIDI NOTE: ${channel}, ${note}, ${velocity}` ); 254 | if ( velocity > 0 ) { 255 | this.send( [ 0b10010000 + channel, note, velocity ] ); 256 | this.playing.push( { note, channel, velocity } ); 257 | } else { 258 | this.send( [ 0b10000000 + channel, note, 0 ] ); 259 | let playing = []; 260 | for ( let data of this.playing ) { 261 | if ( data.note == note && data.channel == channel ) continue; 262 | playing.push( data ); 263 | } 264 | this.playing = playing; 265 | } 266 | } 267 | 268 | control( controller, value, channel = 0 ) { 269 | console.log( `MIDI CONTROL: ${channel}, ${controller}, ${value}` ); 270 | this.send( [ 0b10110000 + channel, controller, value ] ); 271 | } 272 | 273 | program( program, channel = 0 ) { 274 | console.log( `MIDI PROGRAM: ${channel}, ${program}` ); 275 | this.send( [ 0b11000000 + channel, program ] ); 276 | } 277 | 278 | mtcQuarterFrames( part = 0, timecode = null ) { 279 | if ( part == 8 ) return; 280 | if ( timecode === null ) timecode = this.timecode; 281 | let qframe = mtc_quarter_frame( timecode, part ); 282 | this.send( qframe ); 283 | this.mtcQuarterFrames( part + 1, timecode ); 284 | } 285 | 286 | mtcFullFrame() { 287 | this.send( mtc_full_frame( this.timecode ) ); 288 | } 289 | 290 | mtcLoop() { 291 | let now = Date.now(); 292 | if ( now > this.nextFrameTime ) { 293 | this.timecode.next(); 294 | this.nextFrameTime = 295 | this.mtcStarted + this.timecode.framenumber * this.timecode.frametime; 296 | if ( now > this.nextFullFrameTime ) { 297 | // console.log(`MTC: ${this.timecode.toString()}`); 298 | this.mtcFullFrame(); 299 | this.nextFullFrameTime = 300 | this.nextFrameTime + 60 * this.timecode.frametime; 301 | } else { 302 | this.mtcQuarterFrames(); 303 | } 304 | } 305 | let sleep = this.nextFrameTime - now; 306 | if ( sleep < 0 ) sleep = 0; 307 | this.mtcTimer = setTimeout( () => { 308 | this.mtcLoop(); 309 | }, sleep ); 310 | } 311 | 312 | mtcStart( timecode = '01:00:00:01', fps = 24 ) { 313 | console.log( 'MIDI MTC: Starting...' ); 314 | this.timecode = new Timecode( timecode, fps ); 315 | console.log( this.timecode ); 316 | this.mtcStarted = Date.now(); 317 | this.nextFullFrameTime = this.mtcStarted; 318 | this.nextFrameTime = 319 | this.nextFullFrameTime + 320 | this.timecode.framenumber * this.timecode.frametime; 321 | this.mtcLoop(); 322 | } 323 | 324 | mtcStop() { 325 | if ( this.mtcTimer ) clearTimeout( this.mtcTimer ); 326 | } 327 | } 328 | 329 | function mtc_quarter_frame( timecode, piece = 0 ) { 330 | // there are 8 different mtc_quarter frame pieces 331 | // see https://en.wikipedia.org/wiki/MIDI_timecode 332 | // and https://web.archive.org/web/20120212181214/http://home.roadrunner.com/~jgglatt/tech/mtc.htm 333 | // these are little - endian bytes 334 | // piece 0 : 0xF1 0000 ffff frame 335 | let bytes = mtc_bytes( timecode ); 336 | let byte_index = 3 - Math.floor( piece / 2 ); 337 | let byte = bytes[ byte_index ]; 338 | 339 | // even pieces get the low nibble 340 | // odd pieces get the high nibble 341 | let nibble; 342 | if ( piece % 2 == 0 ) { 343 | nibble = byte & 15; 344 | } else { 345 | nibble = byte >> 4; 346 | } 347 | return [ 0xf1, piece * 16 + nibble ]; 348 | } 349 | 350 | function mtc_full_frame( timecode ) { 351 | let bytes = mtc_bytes( timecode ); 352 | return [ 0xf0, 0x7f, 0x7f, 0x01, 0x01, ...bytes, 0xf7 ]; 353 | } 354 | 355 | function mtc_bytes( timecode ) { 356 | // MIDI bytes are little-endian 357 | // Byte 0 358 | // 0rrhhhhh: Rate (0–3) and hour (0–23). 359 | // rr = 000: 24 frames/s 360 | // rr = 001: 25 frames/s 361 | // rr = 010: 29.97 frames/s (SMPTE drop-frame timecode) 362 | // rr = 011: 30 frames/s 363 | // Byte 1 364 | // 00mmmmmm: Minute (0–59) 365 | // Byte 2 366 | // 00ssssss: Second (0–59) 367 | // Byte 3 368 | // 000fffff: Frame (0–29, or less at lower frame rates) 369 | let { h, m, s, f, fps } = timecode; 370 | let rateflag; 371 | switch ( fps ) { 372 | case 24: 373 | rateflag = 0; 374 | break; 375 | case 25: 376 | rateflag = 1; 377 | break; 378 | case 29.97: 379 | rateflag = 2; 380 | break; 381 | case 30: 382 | rateflag = 3; 383 | break; 384 | } 385 | rateflag *= 32; // multiply by 32, because the rate flag starts at bit 6 386 | return [ rateflag + h, m, s, f ]; 387 | } 388 | 389 | module.exports.MidiController = MidiController; 390 | -------------------------------------------------------------------------------- /modules/module.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require( 'events' ); 2 | const { v4: uuidv4 } = require( 'uuid' ); 3 | 4 | /// each module needs to support the same basic api: 5 | /// static supports multiple? 6 | /// static name 7 | /// static create(options) // creates a new instance of this class from options 8 | /// getInfo() // reports instance id and command documentation 9 | /// each module should report its name and a list of triggers with arguments and documentation 10 | /// short-form commands will look like this: 11 | /// cmd[arg,arg,arg] 12 | /// long-form commands look like this (whitespace around 'data' will be stripped): 13 | /// [cmd]data[/cmd] 14 | /// triggers can also be registered on generic propresenter events 15 | /// by specifying the trigger tagname as ~eventname~ 16 | /// events are: 17 | /// update, sdupdate, remoteupdate, sddata, remotedata, msgupdate, slideupdate, sysupdate, timersupdate 18 | class Module extends EventEmitter { 19 | // MULTI-INSTANCE MODULES MUST BE DEFINED 20 | // AT THE MODULE LEVEL... SEE PRO.JS FOR EXAMPLE 21 | 22 | static name = 'module'; 23 | static niceName = 'Module'; 24 | static create() { 25 | console.log( 'unimplemented' ); 26 | } 27 | 28 | // lets us read the static name from an instance 29 | get moduleName() { 30 | return this.constructor.name; 31 | } 32 | get niceName() { 33 | return this.constructor.niceName; 34 | } 35 | get supportsMultiple() { 36 | return this.constructor.supportsMultiple; 37 | } 38 | 39 | triggers = []; // a list of ModuleTriggers 40 | triggersByTag = {}; 41 | enabled = true; 42 | disposed = false; 43 | 44 | 45 | constructor ( config = {}, reset = false ) { 46 | super(); 47 | this.config = config; 48 | this.uuid = uuidv4(); 49 | 50 | // register custom triggers specified in the config 51 | if ( config.triggers ) { 52 | for ( let t of config.triggers ) { 53 | this.registerTrigger( 54 | new ModuleTrigger( 55 | t.tagname, 56 | t.description, 57 | t.args.map( 58 | ( e ) => 59 | new ModuleTriggerArg( e.name, e.type, e.description, e.optional ) 60 | ), 61 | ( pro, ...args ) => t.callback( this, pro, ...args ) 62 | ) 63 | ); 64 | } 65 | } 66 | } 67 | 68 | // each module needs to be able to handle when an 69 | // object's configuration changes including 70 | // restarting services and child listeners 71 | updateConfig( config ) { 72 | this.config = config; 73 | this.emit( 'new_config' ); 74 | } 75 | 76 | // will dispose myself, don't use me after this !! 77 | disposeAllInstances() { 78 | if ( this.constructor.instances ) this.constructor.instances.forEach( e => e.dispose() ); 79 | } 80 | 81 | dispose() { 82 | this.removeAllListeners(); 83 | this.disposed = true; 84 | } 85 | 86 | log( s ) { 87 | this.emit( 'log', s ); 88 | } 89 | 90 | notify( s ) { 91 | this.emit( 'update', s ); 92 | } 93 | 94 | // ModuleTrigger( tagname, description, args, callback ) 95 | registerTrigger( moduleTrigger ) { 96 | moduleTrigger.parent = this; 97 | if ( this.supportsMultiple ) { 98 | moduleTrigger.args = [ 99 | new ModuleTriggerArg( 100 | 'module_name', 101 | 'string', 102 | `you must specify "${this.instanceName}" as the module_name`, 103 | false 104 | ), 105 | ...moduleTrigger.args, 106 | ]; 107 | } 108 | this.triggers.push( moduleTrigger ); 109 | this.triggersByTag[ moduleTrigger.tagname ] = moduleTrigger; 110 | } 111 | 112 | getInfo() { 113 | return { 114 | id: this.id, 115 | uuid: this.uuid, 116 | enabled: this.enabled, 117 | moduleName: this.moduleName, 118 | niceName: this.niceName, 119 | instanceName: this.instanceName, 120 | requiresInstance: this.supportsMultiple, 121 | config: this.config, 122 | triggers: this.triggers.map( ( e ) => e.doc() ), 123 | }; 124 | } 125 | 126 | // ppInstance is the ProPresenter instance 127 | // that triggered this trigger 128 | handleTriggerTag( tagname, args, proInstance ) { 129 | if ( tagname in this.triggersByTag && this.triggersByTag[ tagname ].enabled ) { 130 | let trigger = this.triggersByTag[ tagname ]; 131 | trigger.fire( args, proInstance ); 132 | } 133 | } 134 | 135 | // if a module wants to track the propresenter 136 | // data more directly, it should implement a function 137 | // to handle propresenter updates, but only if a trigger 138 | // cannot be configured to do what you need. 139 | handleProUpdate( updateType, pro ) { } 140 | } 141 | 142 | class ModuleTrigger { 143 | constructor ( tagname, description, args = [], callback ) { 144 | this.uuid = uuidv4(); 145 | this.enabled = true; 146 | this.tagname = tagname; 147 | this.description = description; 148 | this.args = args; 149 | this.callback = callback; 150 | this.allow_long_tag = 151 | this.args.length == 1 && this.args[ 0 ].type.match( /string|json/ ); 152 | } 153 | 154 | examples() { 155 | if ( this.tagname.match( /^~.+~$/ ) ) return []; 156 | let examples = []; 157 | let exampleArgNames = this.args.map( ( a ) => a.typed_name ); 158 | let exampleArgValues = this.args.map( ( a ) => a.example ); 159 | examples.push( `${this.tagname}[${exampleArgNames.join( ',' )}]` ); 160 | if ( this.args.length > 0 ) 161 | examples.push( `${this.tagname}[${exampleArgValues.join( ',' )}]` ); 162 | if ( this.allow_long_tag ) { 163 | examples.push( 164 | `[${this.tagname}]\nYou can put anything here ( < > 😎 , ' ").\n[/${this.tagname}]` 165 | ); 166 | } 167 | return examples; 168 | } 169 | 170 | doc() { 171 | let label = `slide code: ${this.tagname}`; 172 | let m = this.tagname.match( /^~(.+)~$/ ); 173 | if ( m ) { 174 | label = `every ${m[ 1 ]}`; 175 | } 176 | return { 177 | uuid: this.uuid, 178 | label: label, 179 | parentModule: this.parent?.moduleName ?? null, 180 | parentName: this.parent?.niceName ?? null, 181 | tagname: this.tagname, 182 | description: this.description, 183 | extrahelp: this.allow_long_tag 184 | ? 'This trigger can make use of the "long tag" format (see final example below). Tags in this format allow you to use any characters you want, including whitespace, commas, quotation marks, and even emojis. The outermost whitespace will be stripped away, but interior whitespace will be preserved and passed directly to this controller.' 185 | : '', 186 | enabled: this.enabled, 187 | args: this.args.map( ( e ) => e.doc() ), 188 | allowLongTag: this.allow_long_tag, 189 | examples: this.examples(), 190 | }; 191 | } 192 | 193 | fireIfEnabled( incomingArgs, ppInstance ) { 194 | if ( !this.enabled ) return false; 195 | if ( this.parent && !this.parent.enabled ) return false; 196 | 197 | // parse each arg according to the arg type 198 | let parsed = []; 199 | 200 | for ( let i = 0; i < this.args.length; i++ ) { 201 | let type = this.args[ i ].type; 202 | let val = null; 203 | if ( i < incomingArgs.length ) { 204 | let arg = incomingArgs[ i ]; 205 | if ( arg != null ) { 206 | switch ( type ) { 207 | case 'json': 208 | val = JSON.parse( arg ?? '{}' ); 209 | break; 210 | case 'bool': 211 | val = arg == 1 || arg == true || arg == 'true' || arg == 'on'; 212 | break; 213 | case 'number': 214 | val = parseFloat( arg ?? 0 ); 215 | break; 216 | case 'string': 217 | val = arg ?? ''; 218 | break; 219 | case 'dynamic': 220 | default: 221 | val = arg; 222 | } 223 | } 224 | } 225 | parsed.push( val ); 226 | } 227 | 228 | if ( 229 | this.parent && 230 | this.parent.supportsMultiple && 231 | this.parent.instanceName != parsed[ 0 ] 232 | ) 233 | return false; 234 | 235 | if ( this.parent && this.parent.supportsMultiple ) parsed.splice( 0 ); 236 | 237 | this.callback( ppInstance, ...parsed ); 238 | return true; 239 | } 240 | } 241 | 242 | class ModuleTriggerArg { 243 | constructor ( name = '', type = 'number', description = '', optional = false ) { 244 | this.name = name; 245 | this.type = type; 246 | this.description = description; 247 | this.optional = optional == true; 248 | this.typed_name = `${name}_${type}`; 249 | this.help = ''; 250 | switch ( this.type ) { 251 | case 'number': 252 | this.example = ( Math.random() * 100 ).toFixed( 0 ); 253 | this.help = 'numbers can be integers or decimals, positive or negative'; 254 | break; 255 | case 'json': 256 | this.example = `'{"key1":"value1", "key2":"value2"}'`; 257 | this.help = 258 | "If there are any commas, you must wrap the json string in single quotes( ' ) or backticks ( ` ). Valid json uses double quotes around all keys and all string values."; 259 | break; 260 | case 'bool': 261 | this.example = 'on'; 262 | this.help = ' bool values can be any of true,false,1,0,on,off'; 263 | break; 264 | case 'string': 265 | this.help = 266 | 'Strings with commas must be surrounded by quotation marks. You may use single quotes (\'), double quotes ("), or backticks (`).'; 267 | this.example = 'string without comma'; 268 | break; 269 | default: 270 | this.example = name; 271 | } 272 | } 273 | 274 | doc() { 275 | return { 276 | name: this.name, 277 | type: this.type, 278 | description: this.description, 279 | optional: this.optional, 280 | help: this.help, 281 | example: this.example, 282 | }; 283 | } 284 | } 285 | 286 | class GlobalModule extends Module { 287 | static name = 'global'; 288 | static niceName = 'Global'; 289 | } 290 | 291 | module.exports = { Module, ModuleTrigger, ModuleTriggerArg, GlobalModule }; 292 | 293 | /* PEG DOESN'T WORK PROPERLY 294 | { 295 | function makeCode(type, tag, args) {return {type, tag, args};} 296 | } 297 | 298 | 299 | Code 300 | = CodeStart Expression* 301 | 302 | CodeStart 303 | = PreCode CodeIndicator {return null;} 304 | 305 | PreCode 306 | = (!'---' .)* {return '';} 307 | 308 | CodeIndicator 309 | = Whitespace $"---" Whitespace { return '---'; } 310 | 311 | Expression 312 | = LongCode / ShortCode 313 | 314 | LongCode 315 | = Whitespace '[' stag:Tag ']' content:$(!'[/' .)* '[/' etag:Tag ']' Whitespace { 316 | if (stag != etag) throw new Error('tags do not match'); 317 | return makeCode('long',stag, [content]); 318 | } 319 | 320 | ShortCode 321 | = Whitespace tag:Tag '[' args:Args ']' Whitespace {return makeCode('short', tag, args); } 322 | 323 | Tag 324 | = $[a-z]* 325 | 326 | Args 327 | = head:Arg tail:(',' Arg)* {return [head, ...tail.map(e=>e[1])]} 328 | 329 | Arg 330 | = Number 331 | / String 332 | 333 | String 334 | = Separator '"' text:$[^"]* '"' {return text;} 335 | / Separator '`' text:$[^`]* '`' {return text;} 336 | / Separator "'" text:$[^']* "'" {return text;} 337 | / Separator text:$[^'"`\[\],]* {return text;} 338 | 339 | Number 340 | = Float 341 | / Integer 342 | 343 | Float 344 | = Separator digits:$(Digit+ '.' Digit+) { return parseFloat(digits); } 345 | 346 | Integer 347 | = Separator digits:Digit+ { return parseInt(digits); } 348 | 349 | Digit 350 | = [0-9] 351 | 352 | Separator 353 | = Whitespace 354 | / [\[\],] 355 | 356 | Whitespace 357 | = [ \t\n\r]* {return '';} 358 | */ 359 | -------------------------------------------------------------------------------- /modules/obs-controller.js: -------------------------------------------------------------------------------- 1 | const { default: OBSWebSocket } = require('obs-websocket-js'); 2 | const { Module, ModuleTrigger, ModuleTriggerArg } = require('./module'); 3 | 4 | // NOTE: OBS WebSocket Documentation is here: 5 | // https://www.npmjs.com/package/obs-websocket-js 6 | // https://github.com/Palakis/obs-websocket 7 | 8 | class OBSController extends Module { 9 | static name = 'obs'; 10 | static niceName = 'OBS Controller'; 11 | static create(config) { 12 | return new OBSController(config); 13 | } 14 | 15 | constructor(config) { 16 | super(config); 17 | 18 | this.obs = new OBSWebSocket(); 19 | this.studioMode = false; 20 | 21 | // remember sources and scenes so we don't 22 | // need to make requests all the time 23 | // (keyed by source name) 24 | this.sources = {}; 25 | this.scenes = {}; 26 | this.currentSceneName = ''; 27 | this.previewSceneName = ''; 28 | 29 | // triggers and functions needed 30 | /* 31 | 32 | TODO: Allow the use of numbers to refer to scenes and scene items 33 | TODO: use catch on all this.obs.call commands !!! 34 | 35 | TRIGGERS: 36 | [x] ~slideupdate~ -> this.setSourceText(...); 37 | [x] obs['jsonstring'] -> this.api 38 | [x] obsstream[onoff] -> this.setStream(onoff) 39 | [x] obsrecord[onoff] -> this.setRecord(onoff) 40 | [x] obsoutput[output?,onoff] -> this setOutput(output) 41 | [x] obstext[sourcename,text] -> this setSourceText() 42 | [x] obspreview[scene] -> this.setPreview() 43 | [x] obscut[scene?] -> this.cutToScene() 44 | [x] obsfade[scene?,duration?] -> this.fadeToScene() 45 | [x] obstransition[scene?,transition?,duration?] -> this.transitionToScene(); 46 | [x] obsmute[source,onoff] -> this.setSourceMute() 47 | 48 | // using SetSceneItemRender 49 | [x] obsactivate[sourcename, onoff, scenename?] -> this.setSourceActive 50 | 51 | note: 52 | modifying a Source will show up everywhere that source shows up, but 53 | in studio mode modifying a SceneItem does not change the Program 54 | so call SetCurrentScene after modifying a SceneItem if you want 55 | the changes to show up immediately 56 | 57 | update the sceneItem 58 | if studio mode 59 | get current preview and program 60 | if sceneItem was changed in program 61 | do SetCurrentScene (will copy program to preview) 62 | set previous preview back to preview 63 | 64 | */ 65 | 66 | // setup triggers 67 | this.registerTrigger( 68 | new ModuleTrigger( 69 | '~slideupdate~', 70 | `Will update a text source identified by default_title_source in the configuration on every slide update unless the slide notes contain "noobs"`, 71 | [], 72 | (pro) => { 73 | if (pro.slides.current.notes.match(/noobs/)) return; 74 | this.setSourceText(this.default_title_source, pro.slides.current.text); 75 | }, 76 | ), 77 | ); 78 | 79 | // For advanced OBS control, put OBS WebSocket commands in JSON text between obs tags 80 | // will be passed directly to obs.call like this: 81 | // obs.call(key, value) 82 | // [obs] 83 | // { 84 | // "SetCurrentSource": { 85 | // "scene-name": "Live Broadcast" 86 | // } 87 | // } 88 | // [/obs] 89 | this.registerTrigger( 90 | new ModuleTrigger( 91 | 'obs', 92 | 'sends commands directly to the obs api ', 93 | [ 94 | new ModuleTriggerArg( 95 | 'json_string', 96 | 'json', 97 | '{"SetCurrentScene": {"scene-name": "Live Broadcast"}}', 98 | false, 99 | ), 100 | ], 101 | (_, data = null) => (data == null ? null : this.multiSend(data)), 102 | ), 103 | ); 104 | 105 | this.registerTrigger( 106 | new ModuleTrigger( 107 | 'obsstream', 108 | 'toggles the obs stream on or off, leave blank to just toggle', 109 | [new ModuleTriggerArg('onoff', 'bool', '', true)], 110 | (_, onoff = null) => this.setStreaming(onoff), 111 | ), 112 | ); 113 | 114 | this.registerTrigger( 115 | new ModuleTrigger( 116 | 'obsrecord', 117 | 'toggles the obs recording on or off, leave blank to just toggle', 118 | [new ModuleTriggerArg('onoff', 'bool', '', true)], 119 | (_, onoff = null) => this.setRecording(onoff), 120 | ), 121 | ); 122 | 123 | this.registerTrigger( 124 | new ModuleTrigger( 125 | 'obsoutput', 126 | 'sets the obs output on or off, default is "on"', 127 | [ 128 | new ModuleTriggerArg('outputname', 'string', '', false), 129 | new ModuleTriggerArg('onoff', 'bool', '', true), 130 | ], 131 | (_, outputname, onoff = true) => this.setOutput(outputname, onoff), 132 | ), 133 | ); 134 | 135 | this.registerTrigger( 136 | new ModuleTrigger( 137 | 'obstext', 138 | 'sets the text of a source', 139 | [ 140 | new ModuleTriggerArg('sourcename', 'string', '', false), 141 | new ModuleTriggerArg('text', 'string', '', true), 142 | ], 143 | (_, source, text = '') => this.setSourceText(source, text), 144 | ), 145 | ); 146 | 147 | this.registerTrigger( 148 | new ModuleTrigger( 149 | 'obspreview', 150 | 'sets the obs scene to preview. No effect unless studio mode.', 151 | [new ModuleTriggerArg('scene', 'string', '', false)], 152 | (_, scene) => this.setPreviewScene(scene), 153 | ), 154 | ); 155 | 156 | this.registerTrigger( 157 | new ModuleTrigger( 158 | 'obscut', 159 | 'cuts to a specific scene, defaults to whatever is preview', 160 | [new ModuleTriggerArg('scenename', 'string', '', false)], 161 | (_, scene) => this.cutToScene(scene), 162 | ), 163 | ); 164 | 165 | this.registerTrigger( 166 | new ModuleTrigger( 167 | 'obsfade', 168 | 'fades to a specific scene, defaults to whatever is preview', 169 | [ 170 | new ModuleTriggerArg('scenename', 'string', '', true), 171 | new ModuleTriggerArg('duration', 'number', 'in milliseconds', true), 172 | ], 173 | (_, scene, duration) => this.fadeToScene(scene, duration), 174 | ), 175 | ); 176 | 177 | this.registerTrigger( 178 | new ModuleTrigger( 179 | 'obstransition', 180 | 'transition to specific scene with specific transition and duration, defaults to whatever is in preview', 181 | [ 182 | new ModuleTriggerArg('scenename', 'string', '', true), 183 | new ModuleTriggerArg('transition', 'string', '"Fade" or "Cut" or something else', true), 184 | new ModuleTriggerArg('duration', 'number', 'in milliseconds', true), 185 | ], 186 | (_, scene = null, transition = null, duration = null) => 187 | this.transitionToScene(scene, transition, duration), 188 | ), 189 | ); 190 | 191 | this.registerTrigger( 192 | new ModuleTrigger( 193 | 'obsmute', 194 | 'mutes a specific source, defaults to toggle', 195 | [ 196 | new ModuleTriggerArg('source', 'string', '', false), 197 | new ModuleTriggerArg('onoff', 'bool', 'turn mute on or off', true), 198 | ], 199 | (_, source, onoff = null) => this.setSourceMute(source, onoff), 200 | ), 201 | ); 202 | 203 | this.registerTrigger( 204 | new ModuleTrigger( 205 | 'obsvisible', 206 | 'toggles visibility of a specific scene item, defaults to toggle', 207 | [ 208 | new ModuleTriggerArg('source', 'string', '', false), 209 | new ModuleTriggerArg('onoff', 'bool', 'turn source on or off', true), 210 | new ModuleTriggerArg( 211 | 'scene', 212 | 'bool', 213 | 'scene in which to toggle, defaults to current scene', 214 | true, 215 | ), 216 | ], 217 | (_, source, onoff = null, scene = null) => this.setSceneItemRender(source, onoff, scene), 218 | ), 219 | ); 220 | 221 | this.future = this.updateConfig(config); 222 | } 223 | 224 | async updateConfig(config) { 225 | super.updateConfig(config); 226 | let { host, port, password } = config; 227 | this.host = host; 228 | this.port = port; 229 | this.password = password; 230 | this.default_title_source = config.default_title_source ?? 'Lyrics'; 231 | return await this.connect(); 232 | } 233 | 234 | getInfo() { 235 | let r = { ...super.getInfo(), ...this.getStatus() }; 236 | return r; 237 | } 238 | 239 | getStatus() { 240 | let r = {}; 241 | r.studioMode = this.studioMode; 242 | r.currentSceneName = this.currentSceneName; 243 | r.previewSceneName = this.previewSceneName; 244 | r.defaultTransition = this.defaultTransition; 245 | r.defaultTransitionDuration = this.defaultTransitionDuration; 246 | r.sources = this.sources; 247 | r.scenes = this.scenes; 248 | 249 | return r; 250 | } 251 | 252 | async connect() { 253 | if (this.obs.connected) this.obs.disconnect(); 254 | 255 | this.connected = false; 256 | let address = `ws://${this.host}:${this.port}`; 257 | let password = this.password; 258 | console.log(`INFO: connecting to obs using addr: ${address}, pass: ${password}`); 259 | await this.obs 260 | .connect(address, password) 261 | .then(() => { 262 | console.log('obs connection'); 263 | 264 | this.connected = true; 265 | this.emit('log', 'Connected to OBS'); 266 | 267 | this.on('SwitchScenes', (d) => (this.currentSceneName = d.sceneName)); 268 | this.on('ScenesChanged', (arr) => this.updateScenes(arr)); 269 | this.on('SourceRenamed', (d) => this.renameSource(d)); 270 | 271 | // not implemented, but might be useful 272 | // this.on( 'SourceCreated', ( d ) => this.handleSourceCreated(d) ); 273 | // this.on( 'SourceDestroyed', ( d ) => this.handleSourceDestroyed(d) ); 274 | 275 | return this.getOBSStatus(); 276 | }) 277 | .then(() => { 278 | console.log('obs connected'); 279 | this.emit('connected'); 280 | this.connected = true; 281 | }) 282 | .catch((err) => { 283 | // Promise convention dicates you have a catch on every chain. 284 | this.log(err); 285 | }); 286 | 287 | console.log('connection finished'); 288 | 289 | this.notify(); 290 | } 291 | 292 | notify(data) { 293 | this.emit('update', data); 294 | } 295 | 296 | safeSend(key, data = null) { 297 | console.log(`OBSSEND: ${key} ${JSON.stringify(data, null, 2)}`); 298 | if (data != null) 299 | return this.obs.call(key, data).catch((err) => { 300 | return err; 301 | }); 302 | else 303 | return this.obs.call(key).catch((err) => { 304 | return err; 305 | }); 306 | } 307 | 308 | // obj can contain multiple commands 309 | // JavaScript preserves the ordering of the keys 310 | async multiSend(obj) { 311 | console.log(`OBSMULTISEND: ${JSON.stringify(obj, null, 2)}`); 312 | if (!this.connected) return; 313 | 314 | let retval = []; 315 | for (let key of Object.keys(obj)) { 316 | // it's a promise so we can wait for the results 317 | retval.push( 318 | await this.obs.call(key, obj[key]).catch((err) => { 319 | return err; 320 | }), 321 | ); 322 | } 323 | return retval; 324 | } 325 | 326 | renameSource(data) { 327 | this.sources[data.newName] = this.sources[data.previousName]; 328 | delete data.previousName; 329 | this.notify(); 330 | } 331 | 332 | // don't trust the sources from the scene objects until we rewrite the code 333 | // to link the scene sources to the actual sources object. 334 | updateScenes(arr_scenes) { 335 | this.scenes = {}; 336 | arr_scenes.forEach((e) => (this.scenes[e.name] = e)); 337 | this.notify(); 338 | } 339 | 340 | updateSources(arr_sources) { 341 | this.sources = {}; 342 | arr_sources.forEach((e) => (this.sources[e.name] = e)); 343 | this.notify(); 344 | } 345 | 346 | // GLOBAL GETTERS 347 | async getOBSStatus() { 348 | console.log('requesting status details from OBS'); 349 | // some of the api requires studio mode 350 | let [studio, scenes, transition] = await this.multiSend({ 351 | GetStudioModeEnabled: {}, 352 | GetSceneList: {}, 353 | GetCurrentSceneTransition: {}, 354 | // GetCurrentPreviewScene: {}, 355 | // GetCurrentProgramScene: {}, 356 | // GetCurrentTransition: {}, 357 | }).catch((e) => console.log(e)); 358 | this.studioMode = studio.studioModeEnabled; 359 | this.previewSceneName = scenes.currentPreviewSceneName; 360 | this.currentSceneName = scenes.currentProgramSceneName; 361 | this.defaultTransition = transition.name; 362 | // this.defaultTransitionDuration = trans.duration ?? 0; 363 | console.log([studio, scenes, transition]); 364 | this.updateScenes(scenes.scenes); 365 | // this.updateSources(sources.sources); 366 | } 367 | 368 | // GLOBAL SETTERS 369 | setStudioMode(onoff = true) { 370 | return onoff ? this.safeSend('EnableStudioMode') : this.safeSend('DisableStudioMode'); 371 | } 372 | 373 | // SCENE SETTERS 374 | setPreviewScene(scene) { 375 | return this.safeSend('SetCurrentPreviewScene', { sceneName: scene }); 376 | } 377 | 378 | setCurrentScene(scene) { 379 | return this.safeSend('SetCurrentProgramScene', { sceneName: scene }); 380 | } 381 | 382 | transitionToScene(transition = null, scene = null, duration = null) { 383 | // remember, Javascript will preserve the insertion order of these keys 384 | let cmd = {}; 385 | if (transition != null) cmd.SetCurrentSceneTransition = { transitionName: transition }; 386 | 387 | if (duration != null) cmd.SetTransitionDuration = { duration }; 388 | 389 | if (scene == null) { 390 | cmd.TriggerStudioModeTransition = {}; 391 | } else { 392 | cmd.SetCurrentProgramScene = { sceneName: scene }; 393 | } 394 | return this.multiSend(cmd); 395 | } 396 | 397 | // when input is null, we toggle between program and preview 398 | fadeToScene(scene = null, duration = null) { 399 | return this.transitionToScene('Fade', scene, duration); 400 | } 401 | 402 | cutToScene(scene = null) { 403 | return this.transitionToScene('Cut', scene); 404 | } 405 | 406 | transition(transition_type = null) { 407 | return this.transitionToScene(transition_type); 408 | } 409 | 410 | fade(duration = null) { 411 | return this.fadeToScene(null, duration); 412 | } 413 | 414 | cut() { 415 | return this.cutToScene(null); 416 | } 417 | 418 | // SOURCE SETTERS 419 | setSourceMute(source, onoff = null) { 420 | if (onoff == null) return this.safeSend('ToggleMute', { source }); 421 | return this.safeSend('SetMute', { source, mute: onoff }); 422 | } 423 | 424 | setSourceText(source = null, text = '') { 425 | // DEPRECATED FUNCTION 426 | // SetTextFreetype2Properties 427 | // input type: text_ft2_source_v2 428 | 429 | // CURRENT FUNCTION 430 | // SetTextGDIPlusProperties 431 | // input type 432 | 433 | // since OBS uses two different text inputs 434 | // we send to both... it's wasteful, but not a problem 435 | 436 | // also, OBS won't actually update the text source if text is empty 437 | text = text == '' ? ' ' : text; 438 | source = source ?? this.default_title_source; 439 | return this.multiSend({ 440 | SetTextFreetype2Properties: { source, text }, 441 | SetTextGDIPlusProperties: { source, text }, 442 | }); 443 | } 444 | 445 | setStreaming(onoff = null) { 446 | if (onoff == null) return this.safeSend('StartStopStreaming'); 447 | if (onoff) return this.safeSend('StartStreaming'); 448 | return this.safeSend('StopStreaming'); 449 | } 450 | 451 | setRecording(onoff = null) { 452 | if (onoff == null) return this.safeSend('StartStopRecording'); 453 | if (onoff) return this.safeSend('StartRecording'); 454 | return this.safeSend('StopRecording'); 455 | } 456 | 457 | setOutput(output, onoff) { 458 | if (onoff == true) return this.safeSend('StartOutput', { outputName: output }); 459 | else return this.safeSend('StopOutput', { outputName: output }); 460 | } 461 | 462 | async setSceneItemRender(sourcename, onoff, scenename = null) { 463 | await this.getOBSStatus(); 464 | let prog = this.currentSceneName; 465 | let prev = this.previewSceneName; 466 | let r = []; 467 | let args = { 468 | source: sourcename, 469 | render: onoff, 470 | }; 471 | if (scenename != null) args['scene-name'] = scenename; 472 | r.push(await this.safeSend('SetSceneItemRender', args)); 473 | 474 | if (this.studioMode) { 475 | if (scenename == prog) { 476 | r.push(await this.setCurrentScene(prog)); 477 | setTimeout(() => this.setPreviewScene(prev), this.defaultTransitionDuration + 100); 478 | } 479 | } 480 | return r; 481 | } 482 | } 483 | 484 | // class OBSCommand { 485 | // constructor ( { command, options } ) { 486 | // this.command = command; 487 | // this.options = options; 488 | // } 489 | // } 490 | 491 | module.exports.OBSController = OBSController; 492 | -------------------------------------------------------------------------------- /modules/onyx-controller.js: -------------------------------------------------------------------------------- 1 | const { Module, ModuleTrigger, ModuleTriggerArg } = require( './module' ); 2 | 3 | //Set up telnet client for talking to MxManager 4 | var telnet = require( 'telnet-client' ); 5 | var tnc = new telnet(); 6 | var reconnectTimer = 0; 7 | var heartbeatTimer = 0; 8 | let poll_in = 500; 9 | 10 | class OnyxController extends Module { 11 | static name = 'onyx'; 12 | static niceName = 'Onyx Controller'; 13 | static create( config ) { 14 | return new OnyxController( config ); 15 | } 16 | 17 | constructor ( config ) { 18 | super( config ); 19 | 20 | // Set some variables for tracking connection status 21 | this.connectedMPC = false; 22 | this.connectedTelnet = false; 23 | this.setupTelnetListeners(); 24 | this.updateConfig( config ); 25 | 26 | // onyx go pattern 27 | // onyxgo[cuelist, cue] 28 | const onyx_go_pattern = /onyxgo\[(.+?)\s*(?:,\s*(\d+))?\s*\]/gi; 29 | this.registerTrigger( 30 | new ModuleTrigger( 31 | 'onyxgo', 32 | 'fire an onyx cuelist with an optional specific cue', 33 | [ 34 | new ModuleTriggerArg( 35 | 'cuelist', 36 | 'number', 37 | 'onyx cuelist number', 38 | false 39 | ), 40 | new ModuleTriggerArg( 41 | 'cue', 42 | 'number', 43 | 'onyx cue, defaults to null', 44 | true 45 | ), 46 | ], 47 | ( _, cuelist, cue = 0 ) => this.goCuelist( cuelist, cue ) 48 | ) 49 | ); 50 | 51 | const onyx_release_pattern = /onyxrelease\[(.+?)\s*(?:,\s*(\d+))?\s*\]/gi; 52 | this.registerTrigger( 53 | new ModuleTrigger( 54 | 'onyxrelease', 55 | 'release an onyx cuelist, optionally a specific cue', 56 | [ 57 | new ModuleTriggerArg( 58 | 'cuelist', 59 | 'number', 60 | 'onyx cuelist number', 61 | false 62 | ), 63 | new ModuleTriggerArg( 64 | 'cue', 65 | 'number', 66 | 'onyx cue, defaults to null', 67 | true 68 | ), 69 | ], 70 | ( _, cuelist, cue = 0 ) => this.releaseCuelist( cuelist, cue ) 71 | ) 72 | ); 73 | } 74 | 75 | updateConfig( config ) { 76 | super.updateConfig( config ); 77 | let { host, port } = config; 78 | this.onyxIP = host; 79 | this.onyxPort = port; 80 | this.connectTelnet(); 81 | } 82 | 83 | setupTelnetListeners() { 84 | tnc.on( 'ready', ( _ ) => { 85 | this.connectedTelnet = true; 86 | console.log( '> Onyx Telnet Connection Established' ); 87 | } ); 88 | 89 | tnc.on( 'close', () => { 90 | this.connectedTelnet = false; 91 | this.connectedMPC = false; 92 | console.log( '> Onyx Telnet Connection Closed' ); 93 | //Start a timer to reconnect, if one hasn't already been started 94 | if ( !reconnectTimer ) { 95 | reconnectTimer = setInterval( () => { 96 | this.reconnectTelnet(); 97 | }, 5000 ); 98 | } 99 | clearInterval( heartbeatTimer ); 100 | } ); 101 | 102 | tnc.on( 'error', ( e ) => { 103 | this.connectedTelnet = false; 104 | this.connectedMPC = false; 105 | console.log( '> Onyx Telnet Connection Error' ); 106 | console.log( e ); 107 | } ); 108 | } 109 | 110 | reconnectTelnet() { 111 | // Clear the reconnect timer if we are reconnecting 112 | if ( reconnectTimer ) { 113 | clearInterval( reconnectTimer ); 114 | reconnectTimer = 0; 115 | } 116 | this.connectTelnet(); 117 | } 118 | 119 | // Connect to MxManager Telnet 120 | connectTelnet() { 121 | console.log( '> Connecting to MxManager Telnet' ); 122 | tnc.connect( this.get_telnet_connect_settings() ); 123 | } 124 | 125 | goCuelist( cuelist, cue = null ) { 126 | if ( cue === null ) { 127 | this.run_telnet( 'GQL ' + cuelist ); 128 | } else { 129 | this.run_telnet( 'GTQ ' + cuelist + ' ' + cue ); 130 | } 131 | } 132 | 133 | releaseCuelist( cuelist ) { 134 | this.run_telnet( 'RQL ' + cuelist ); 135 | } 136 | 137 | get_telnet_connect_settings() { 138 | return { 139 | host: this.onyxIP, 140 | port: this.onyxPort, 141 | shellPrompt: '', 142 | timeout: 1500, 143 | negotiationMandatory: false, 144 | }; 145 | } 146 | 147 | get_value_between( number, min, max ) { 148 | if ( number < min ) return min; 149 | else if ( number > max ) return max; 150 | else return number; 151 | } 152 | 153 | run_telnet( command, callback ) { 154 | tnc.send( command ); 155 | /* 156 | let telnet_connection = new telnet(); 157 | telnet_connection.connect(get_telnet_connect_settings()) 158 | .then(function(){ 159 | telnet_connection.send(command, {waitfor:'.\r\n'}) 160 | .then(function(d){ 161 | telnet_connection.end(); 162 | let lines = d.split('\r\n'); 163 | //Remove first 2 welcome lines 164 | delete lines[0]; 165 | delete lines[1]; 166 | callback(lines,command); 167 | }, 168 | function(err){ 169 | console.log('Error connecting to telnet: ' . err); 170 | }) 171 | }); 172 | */ 173 | } 174 | } 175 | 176 | module.exports.OnyxController = OnyxController; 177 | -------------------------------------------------------------------------------- /modules/osc-controller.js: -------------------------------------------------------------------------------- 1 | const osc = require( 'osc' ); 2 | const { Module, ModuleTrigger, ModuleTriggerArg } = require( './module' ); 3 | 4 | class OscController extends Module { 5 | static name = 'osc'; 6 | static niceName = 'OSC Controller'; 7 | 8 | static create( config ) { 9 | return new OscController( config ); 10 | } 11 | constructor ( config ) { 12 | super( config ); 13 | 14 | // OSC: 15 | // osc[address, typestring, ...args] 16 | this.registerTrigger( 17 | new ModuleTrigger( 18 | 'osc', 19 | 'parse and send an osc message', 20 | [ 21 | new ModuleTriggerArg( 'address', 'string', 'osc address string', false ), 22 | new ModuleTriggerArg( 23 | 'typestring', 24 | 'string', 25 | 'node osc typestring ifstbrm is int, float, string, time, blob, rgba, midi', 26 | true 27 | ), 28 | new ModuleTriggerArg( 29 | '...arguments', 30 | 'dynamic', 31 | 'remaining arguments will be parsed according to the typestring where rgba is written as #aabbccdd and blob and midi args are written in space-delimited hex arrays like [0x01 0x02]', 32 | true 33 | ), 34 | ], 35 | ( _, address, typestring, ...args ) => { 36 | let msg = { 37 | address, 38 | args: [] 39 | }; 40 | for ( let i = 0; i < typestring.length; i++ ) { 41 | if ( i < args.length ) { 42 | let type = typestring[ i ]; 43 | let value = args[ i ]; 44 | switch ( type ) { 45 | case 's': 46 | // value already is a string 47 | break; 48 | case 'i': 49 | value = parseInt( value ) 50 | break; 51 | case 'f': 52 | value = parseFloat( value ); 53 | break; 54 | case 't': 55 | value = { native: parseInt( value ) } // timestamp 56 | break; 57 | case 'r': 58 | value = value.replace( /^#/, '0x' ); 59 | value = parseInt( value ); 60 | if ( isNaN( value ) ) { 61 | this.log( 'could not parse value into color' ); 62 | continue; 63 | } 64 | value = { 65 | r: ( value & 0xFF000000 ) >> ( 32 ), 66 | g: ( value & 0x00FF0000 ) >> ( 16 ), 67 | b: ( value & 0x0000FF00 ) >> ( 8 ), 68 | a: ( value & 0x000000FF ), 69 | } 70 | 71 | break; 72 | case 'b': 73 | case 'm': 74 | let a = []; 75 | let error = false; 76 | value = value.replace( /[\[\]]/g, '' ) 77 | for ( let v of value.split( ' ' ) ) { 78 | v = parseInt( v ); 79 | if ( isNaN( v ) ) { 80 | error = true; 81 | } else { 82 | a.push( v ) 83 | } 84 | } 85 | if ( error ) { 86 | this.log( 'could not parse array into list of bytes' ); 87 | continue; 88 | } else { 89 | value = new Uint8Array( a ); 90 | } 91 | break; 92 | default: 93 | continue; 94 | } 95 | msg.args.push( { type, value } ); 96 | } 97 | } 98 | this.send( msg ); 99 | } 100 | ) 101 | ); 102 | this.updateConfig( config ); 103 | } 104 | 105 | updateConfig( config ) { 106 | super.updateConfig( config ); 107 | this.host = config.host; 108 | this.port = config.port; 109 | this.proto = config.proto; // udp | tcp | ws 110 | this.connect(); 111 | } 112 | 113 | close() { 114 | this.connected = false; 115 | if ( this.oscPort ) { 116 | this.oscPort.close(); 117 | this.oscPort.removeAllListeners(); 118 | this.oscPort = null; 119 | } 120 | } 121 | 122 | connect() { 123 | this.close(); 124 | 125 | switch ( this.proto ) { 126 | case 'udp': 127 | this.oscPort = new osc.UDPPort( { 128 | localAddress: "0.0.0.0", 129 | remoteAddress: this.host, 130 | remotePort: this.port, 131 | metadata: true, 132 | } ); 133 | break; 134 | case 'ws': 135 | this.oscPort = new osc.WebSocketPort( { 136 | url: `ws://${this.host}:${this.port}`, 137 | metadata: true, 138 | } ); 139 | break; 140 | case 'tcp': 141 | default: 142 | this.oscPort = new osc.TCPSocketPort( { 143 | localAddress: "0.0.0.0", 144 | remoteAddress: this.host, 145 | remotePort: this.port, 146 | metadata: true, 147 | } ); 148 | } 149 | if ( this.oscPort == null ) return; 150 | this.oscPort.on( 'ready', () => this.connected = true ); 151 | this.oscPort.on( 'error', this.handleError ); 152 | this.oscPort.on( 'message', this.handleMessage ); 153 | // this.oscPort.on( 'bundle', this.handleBundle ); 154 | 155 | this.oscPort.open(); 156 | } 157 | 158 | handleError( e ) { 159 | this.log( e ); 160 | this.emit( 'error', e ); 161 | // this.connected = false; 162 | } 163 | 164 | // osc messages have two fields: address and args 165 | // address is a string like /an/osc/address 166 | // args are an array of objects with "type" and "value" 167 | // type can be i, f, s, t, b, r, m 168 | // for int32, float32, string, timetag, blob, rgba, midi 169 | // the timetag value is object with javascript timestamp in the "native" field 170 | // rgba value is an object with keys: r,g,b,a 171 | // blob value is Uint8Array 172 | // midi value is Uint8Array([port id, status, data1, data2]) 173 | handleMessage( oscMsg, timeTag, info ) { 174 | this.log( `OSC MSG RECEIVED: ${JSON.stringify( oscMsg )}` ); 175 | let address = oscMsg.address; 176 | let args = oscMsg.args; 177 | } 178 | 179 | status() { 180 | return { 181 | connected: this.connected, 182 | }; 183 | } 184 | 185 | // message should be an osc message according to node osc package 186 | // https://www.npmjs.com/package/osc 187 | send( message ) { 188 | this.oscPort.send( message ); 189 | } 190 | } 191 | 192 | module.exports.OscController = OscController; 193 | -------------------------------------------------------------------------------- /modules/pro.js: -------------------------------------------------------------------------------- 1 | // WS module doesn't work in browsers 2 | const { EventEmitter } = require('ws'); 3 | const WebSocket = require('ws'); 4 | 5 | const { Module, ModuleTrigger, ModuleTriggerArg } = require('./module.js'); 6 | const { hms2secs, timestring2secs, markdown } = require('../helpers.js'); 7 | 8 | class ProController extends Module { 9 | static name = 'pro'; 10 | static niceName = 'ProPresenter Controller'; 11 | static supportsMultiple = true; 12 | static instances = []; 13 | static get master() { 14 | for (let i of ProController.instances) { 15 | if (i.master) return i; 16 | } 17 | // looks like there is no master instance... create it now 18 | return new ProController({}); 19 | } 20 | 21 | static create(config) { 22 | return new ProController(config); 23 | } 24 | 25 | get allInstances() { 26 | return this.constructor.instances; 27 | } 28 | 29 | get slides() { 30 | return this.sd.slides; 31 | } 32 | 33 | get connected() { 34 | return this.sd.connected && this.remote.connected; 35 | } 36 | 37 | constructor(config, reset = false) { 38 | super(config); 39 | 40 | if (reset) { 41 | for (let i of ProController.instances) { 42 | i.dispose(); 43 | } 44 | ProController.instances.length = 0; 45 | } 46 | 47 | // store in the static instances list 48 | this.id = ProController.instances.length; 49 | ProController.instances.push(this); 50 | 51 | // setup object properties 52 | this.master = this.id == 0; // the first instance is by default the master instance 53 | this.follower = false; 54 | // this.options = options; 55 | 56 | this.updateConfig(config); 57 | this._registerDefaultTriggers(); 58 | } 59 | 60 | updateConfig(config) { 61 | console.log('NEW PRO CONFIG: '); 62 | console.log(JSON.stringify(config)); 63 | super.updateConfig(config); 64 | 65 | let { host, port, sd_pass, version = 6, remote_pass } = this.config; 66 | this.host = host; 67 | this.port = port; 68 | this.version = version; 69 | this.sd_pass = sd_pass; 70 | this.remote_pass = remote_pass; 71 | this.events = []; 72 | 73 | // setup connections 74 | if (this.sd) { 75 | this.sd.removeAllListeners(); 76 | this.sd.close(); 77 | } 78 | this.sd = new ProSDClient(host, port, sd_pass, version, this); 79 | this.events = [...this.events, 'sdupdate', 'sddata', 'msgupdate', 'sysupdate', 'slideupdate']; 80 | 81 | this.sd.on('update', () => this.emit('sdupdate', this.fullStatus())); 82 | this.sd.on('data', (data) => this.emit('sddata', data)); 83 | this.sd.on('msgupdate', (data) => this.emit('msgupdate', data)); 84 | this.sd.on('sysupdate', (data) => this.emit('sysupdate', data)); 85 | this.sd.on('slideupdate', (data) => this.emit('slideupdate', data)); 86 | 87 | if (this.remote) { 88 | this.remote.removeAllListeners(); 89 | this.remote.close(); 90 | } 91 | this.remote = new ProRemoteClient(host, port, remote_pass, version, this); 92 | this.events = [...this.events, 'clocksupdate', 'remoteupdate', 'remotedata']; 93 | 94 | this.remote.removeAllListeners(); 95 | this.remote.on('update', () => this.emit('remoteupdate', this.fullStatus())); 96 | this.remote.on('clocksupdate', () => { 97 | // this.mergeClocks(); // not reliable since the 98 | this.emit('clocksupdate', this.remote.status.clocks); 99 | }); 100 | this.remote.on('data', (data) => { 101 | this.emit('remotedata', data); 102 | if (this.master) { 103 | for (let i of ProController.instances) { 104 | if (i.id == this.id) continue; 105 | if (i.follower) i.remote.send(data); 106 | } 107 | } 108 | }); 109 | } 110 | 111 | getInfo() { 112 | return { 113 | ...super.getInfo(), 114 | ...this.status(), 115 | }; 116 | } 117 | 118 | status() { 119 | let r = { 120 | master: this.master, 121 | connected: this.connected, 122 | controlling: this.remote.controlling, 123 | ...this.sd.status(), 124 | }; 125 | // r.master = this.master; 126 | // r.follower = this.follower; 127 | // r.slides = this.slides; 128 | // r.sd = this.sd.status(); 129 | return r; 130 | } 131 | reconnect() { 132 | this.remote.connect(); 133 | this.sd.connect(); 134 | } 135 | fullStatus() { 136 | return { 137 | ...this.status(), 138 | ...this.remote.status, 139 | }; 140 | } 141 | 142 | // merges the sd "timers" with the remote "clocks" 143 | mergeClocks() { 144 | // sd timers have uid, text, and seconds as int 145 | // remote clocks have 146 | // { 147 | // "clockType": 1, 148 | // "clockState": false, 149 | // "clockName": "Countdown 2", 150 | // "clockIsPM": 1, 151 | // "clockDuration": "7:00:00", 152 | // "clockOverrun": false, 153 | // "clockEndTime": "--:--:--", 154 | // "clockTime": "--:--:--" 155 | // } 156 | for (let i = 0; i < this.sd.timers.length; i++) { 157 | let timer = this.sd.timers[i]; 158 | if (this.remote.status.clocks[i]) { 159 | let clock = this.remote.status.clocks[i]; 160 | this.remote.status.clocks[i] = this.sd.timers[i] = { ...timer, ...clock }; 161 | } 162 | } 163 | } 164 | 165 | _registerDefaultTriggers() { 166 | this.triggers = []; 167 | 168 | // // register default triggers 169 | // this.registerTrigger( 170 | // new ModuleTrigger( 171 | // 'master', 172 | // 'flags the source propresenter instance as the master instance', 173 | // [], 174 | // (caller) => { 175 | // caller.master = true; 176 | // caller.follower = false; 177 | // caller.emit('update'); 178 | // } 179 | // ) 180 | // ); 181 | 182 | // this.registerTrigger( 183 | // new ModuleTrigger( 184 | // 'follow', 185 | // 'flags the source propresenter instance as a follower instance', 186 | // [ 187 | // new ModuleTriggerArg( 188 | // 'onoff', 189 | // 'bool', 190 | // 'turn following on or off for this instance', 191 | // ) 192 | // ], 193 | // (caller, onoff) => { 194 | // caller.follower = onoff == true; 195 | // caller.emit('update'); 196 | // } 197 | // ) 198 | // ); 199 | } 200 | } 201 | 202 | class ProSlide { 203 | constructor(uid = '', text = '', notes = '') { 204 | this.uid = uid; 205 | this.text = text; 206 | this.notes = notes; 207 | } 208 | } 209 | 210 | // listens to ProPresenter as a stage display client 211 | class ProSDClient extends EventEmitter { 212 | constructor(host, port, password, version = 6, parent) { 213 | super(); 214 | this.host = host; 215 | this.port = port; 216 | this.password = password; 217 | this.version = version; 218 | this.parent = parent; 219 | 220 | // internal state 221 | this.connected = false; 222 | this.active = false; 223 | 224 | // tracking propresenter state 225 | this.stage_message = ''; 226 | this.system_time = { text: '', seconds: 0 }; 227 | this.timers = []; // need to preserve order to sync with remote protocol 228 | this.slides = { 229 | current: new ProSlide(), 230 | next: new ProSlide(), 231 | }; 232 | 233 | this.ondata = (data) => this.emit('data', data, this); 234 | this.onmsgupdate = (data) => this.emit('msgupdate', data, this); 235 | this.onsysupdate = (data) => this.emit('sysupdate', data, this); 236 | this.onslideupdate = (data) => this.emit('slideupdate', data, this); 237 | this.ontimerupdate = (data) => this.emit('timerupdate', data, this); 238 | 239 | this.connect(); 240 | } 241 | 242 | notify() { 243 | this.emit('update', this); 244 | } 245 | 246 | status() { 247 | return { 248 | system_time: this.system_time, 249 | timers: this.timers, 250 | stage_message: this.stage_message, 251 | slides: this.slides, 252 | connected: this.connected, 253 | active: this.active, 254 | }; 255 | } 256 | 257 | close() { 258 | if (this.ws) { 259 | this.ws?.removeAllListeners(); 260 | this.ws?.terminate(); 261 | delete this.ws; 262 | } 263 | 264 | this.connected = false; 265 | this.active = false; 266 | this.notify(); 267 | } 268 | 269 | reconnect(delay = 0) { 270 | this.parent.log(`Attempting reconnect in ${delay} seconds.`); 271 | clearTimeout(this.reconnectTimeout); 272 | this.reconnectTimeout = setTimeout(() => { 273 | this.connect(); 274 | }, delay * 1000); 275 | } 276 | 277 | connect() { 278 | this.connected = false; 279 | this.active = false; 280 | 281 | clearTimeout(this.reconnectTimeout); 282 | 283 | let url = `ws://${this.host}:${this.port}/stagedisplay`; 284 | console.log(`ProSDClient: connecting to ${url}`); 285 | if (this.ws) this.close(); 286 | try { 287 | this.ws = new WebSocket(url); 288 | } catch (e) { 289 | this.close(); 290 | console.log('ERROR: Could not connect to ' + url); 291 | console.log(e); 292 | return; 293 | } 294 | 295 | this.ws.on('message', (data) => { 296 | this.check(JSON.parse(data)); 297 | this.notify(); 298 | }); 299 | 300 | this.ws.on('open', () => { 301 | this.connected = true; 302 | this.authenticate(); 303 | this.notify(); 304 | }); 305 | 306 | this.ws.on('close', () => { 307 | // this.ws.terminate(); 308 | this.reconnect(10); 309 | this.connected = false; 310 | this.active = false; 311 | this.notify(); 312 | }); 313 | 314 | this.ws.on('error', (err) => { 315 | this.parent.log('ProPresenter WebSocket Error:'); 316 | // this.parent.log(err); 317 | this.ws.terminate(); 318 | this.reconnect(30); 319 | this.notify(); 320 | }); 321 | } 322 | 323 | send(Obj) { 324 | console.log('sending...'); 325 | console.log(JSON.stringify(Obj)); 326 | this.ws.send(JSON.stringify(Obj)); 327 | } 328 | 329 | authenticate() { 330 | let auth = { 331 | pwd: this.password, 332 | ptl: 610, 333 | acn: 'ath', 334 | }; 335 | this.send(auth); 336 | } 337 | 338 | check(data) { 339 | // this.parent.log(data); 340 | let newdata = {}; 341 | switch (data.acn) { 342 | case 'ath': 343 | //{"acn":"ath","ath":true/false,"err":""} 344 | if (data.ath) { 345 | this.parent.log('ProPresenter Listener is Connected'); 346 | this.active = true; 347 | newdata = { type: 'authentication', data: true }; 348 | } else { 349 | this.connected = false; 350 | this.active = false; 351 | newdata = { type: 'authentication', data: false }; 352 | } 353 | break; 354 | case 'tmr': 355 | let exists = false; 356 | let t = { 357 | uid: data.uid, 358 | text: data.txt, 359 | seconds: hms2secs(data.txt), 360 | }; 361 | for (let timer of this.timers) { 362 | if (timer.uid == t.uid) { 363 | timer.text = t.text; 364 | timer.seconds = t.seconds; 365 | exists = true; 366 | break; 367 | } 368 | } 369 | if (!exists) { 370 | this.timers.push(t); 371 | } 372 | newdata = { type: 'timer', data: t }; 373 | if (this.ontimerupdate) this.ontimerupdate(t); 374 | break; 375 | case 'sys': 376 | // { "acn": "sys", "txt": " 11:17 AM" } 377 | this.system_time = { 378 | text: data.txt, 379 | seconds: timestring2secs(data.txt), 380 | }; 381 | newdata = { type: 'systime', data: this.system_time }; 382 | if (this.onsysupdate) this.onsysupdate(this.system_time); 383 | break; 384 | case 'msg': 385 | // { acn: 'msg', txt: 'Test' } 386 | this.stage_message = data.txt; 387 | newdata = { type: 'message', data: this.stage_message }; 388 | if (this.onmsgupdate) this.onmsgupdate(this.stage_message); 389 | break; 390 | case 'fv': 391 | // we just got stage display slide information 392 | this.slides.current = new ProSlide(); 393 | this.slides.next = new ProSlide(); 394 | 395 | // the 'ary' object contains a list (unordered) of 4 items 396 | // where each item will be identified by the 'acn' field as 397 | // cs: current slide 398 | // csn: current slide notes 399 | // ns: next slide 400 | // nsn: next slide notes 401 | for (let blob of data.ary) { 402 | switch (blob.acn) { 403 | case 'cs': 404 | this.slides.current.uid = blob.uid; 405 | this.slides.current.text = blob.txt; 406 | break; 407 | case 'csn': 408 | this.slides.current.notes = blob.txt; 409 | break; 410 | case 'ns': 411 | this.slides.next.uid = blob.uid; 412 | this.slides.next.text = blob.txt; 413 | break; 414 | case 'nsn': 415 | this.slides.next.notes = blob.txt; 416 | break; 417 | } 418 | } 419 | newdata = { type: 'slides', data: this.slides }; 420 | if (this.onslideupdate) this.onslideupdate(this.slides); 421 | } 422 | if (this.ondata) this.ondata(newdata, this); 423 | } 424 | } 425 | 426 | // incomplete at the moment 427 | class ProRemoteClient extends EventEmitter { 428 | constructor(host, port, password, version = 6, parent) { 429 | super(); 430 | this.connected = false; 431 | this.controlling = false; 432 | this.host = host; 433 | this.port = port; 434 | this.password = password; 435 | this.version = version; 436 | this.parent = parent; 437 | 438 | this.callbacks = {}; 439 | 440 | // handle pro6 status 441 | this.status = { 442 | clocks: [], 443 | currentPresentation: null, 444 | currentSlideIndex: 0, 445 | library: [], 446 | playlists: [], 447 | }; 448 | 449 | this.connect(); 450 | } 451 | 452 | close() { 453 | this.ws?.close(); 454 | this.connected = false; 455 | this.controlling = false; 456 | this.notify(); 457 | this.removeAllListeners(); 458 | } 459 | 460 | reconnect(delay = 0) { 461 | this.parent.log(`Attempting reconnect in ${delay} seconds.`); 462 | clearTimeout(this.reconnectTimeout); 463 | this.reconnectTimeout = setTimeout(() => { 464 | this.connect(); 465 | }, delay * 1000); 466 | } 467 | 468 | connect() { 469 | this.connected = false; 470 | this.controlling = false; 471 | 472 | clearTimeout(this.reconnectTimeout); 473 | 474 | let url = `ws://${this.host}:${this.port}/remote`; 475 | console.log(`ProRemote: connecting to ${url}`); 476 | if (this.ws) this.close(); 477 | try { 478 | this.ws = new WebSocket(url); 479 | } catch (e) { 480 | this.close(); 481 | console.log('ERROR: Could not connect to ' + url); 482 | console.log(e); 483 | return; 484 | } 485 | 486 | this.ws.on('message', (data) => { 487 | // sometimes ProPresenter sends invalid data... be resilient 488 | try { 489 | data = JSON.parse(data); 490 | this.parent.log(data); 491 | this.handleData(data); 492 | } catch (e) { 493 | this.parent.log('RECEIVED INVALID JSON DATA'); 494 | return; 495 | } 496 | // this.notify(); 497 | }); 498 | this.ws.on('open', () => { 499 | this.authenticate(); 500 | this.notify(); 501 | }); 502 | this.ws.on('close', () => { 503 | this.connected = false; 504 | this.controlling = false; 505 | this.reconnect(10); 506 | this.notify(); 507 | }); 508 | this.ws.on('error', () => { 509 | this.connected = false; 510 | this.controlling = false; 511 | this.reconnect(30); 512 | this.notify(); 513 | }); 514 | } 515 | 516 | // notify is used for any status updates 517 | notify() { 518 | this.emit('update', this); 519 | } 520 | 521 | send(Obj, callback = null) { 522 | // register callback if there is one. 523 | if (typeof callback == 'function') { 524 | // fix api bug 525 | let responseAction = Obj.action; 526 | if (Obj.action == 'presentationRequest') responseAction = 'presentationCurrent'; 527 | this.callbacks[responseAction] = callback; 528 | } 529 | console.log('sending...'); 530 | console.log(JSON.stringify(Obj)); 531 | this.ws.send(JSON.stringify(Obj)); 532 | } 533 | 534 | authenticate() { 535 | let auth = { 536 | password: this.password, 537 | protocol: this.version == 7 ? '710' : '600', 538 | action: 'authenticate', 539 | }; 540 | this.send(auth); 541 | } 542 | 543 | flattenPlaylist(playlistObj) { 544 | let flattened = []; 545 | switch (playlistObj.playlistType) { 546 | case 'playlistTypePlaylist': 547 | flattened = playlistObj.playlist; 548 | break; 549 | case 'playlistTypeGroup': 550 | for (let playlist of playlistObj.playlist) { 551 | flattened.push(...this.flattenPlaylist(playlist)); 552 | } 553 | break; 554 | } 555 | return flattened; 556 | } 557 | 558 | loadStatus() { 559 | this.getClocks(); 560 | this.getLibrary(); 561 | // this.getPlaylists(); // crashes propresenter 7.8 562 | this.getPresentation(); 563 | this.getCurrentSlideIndex(); 564 | // if the stage display client is connected 565 | this.subscribeClocks(); 566 | } 567 | 568 | handleData(data) { 569 | // process data for this class instance 570 | switch (data.action) { 571 | case 'authenticate': 572 | if (data.authenticated == 1) this.connected = true; 573 | if (data.controller == 1) this.controlling = true; 574 | 575 | if (this.connected) this.loadStatus(); 576 | break; 577 | case 'libraryRequest': 578 | this.status.library = data.library; 579 | break; 580 | case 'playlistRequestAll': 581 | this.status.playlists = this.flattenPlaylist(data.playlistAll); 582 | break; 583 | case 'presentationCurrent': 584 | this.status.currentPresentation = data.presentation; 585 | break; 586 | case 'presentationSlideIndex': 587 | this.status.currentSlideIndex = +data.slideIndex; 588 | break; 589 | case 'presentationTriggerIndex': 590 | this.status.currentSlideIndex = +data.slideIndex; 591 | if (this.status.currentPresentation != data.presentationPath) { 592 | this.getPresentation(data.presentationPath); 593 | } 594 | break; 595 | case 'clockRequest': 596 | case 'clockDeleteAdd': 597 | this.status.clocks = data.clockInfo; 598 | this.addClockTypeText(); 599 | this.fixClockTimeData(); 600 | this.emit('clocksupdate'); 601 | break; 602 | case 'clockNameChanged': 603 | let index = data.clockIndex; 604 | if (this.status.clocks[index]) this.status.clocks[index].clockName = data.clockName; 605 | this.emit('clocksupdate'); 606 | break; 607 | case 'clockCurrentTimes': 608 | let didchange = false; 609 | if (this.status.clocks.length > 0) { 610 | for (let i = 0; i < data.clockTimes.length; i++) { 611 | if (this.status.clocks[i]) { 612 | if (this.status.clocks[i].clockTime != data.clockTimes[i]) { 613 | this.status.clocks[i].clockTime = data.clockTimes[i]; 614 | this.status.clocks[i].updated = true; 615 | didchange = true; 616 | } else { 617 | this.status.clocks[i].updated = false; 618 | } 619 | } 620 | } 621 | } 622 | if (didchange) { 623 | this.fixClockTimeData(); 624 | this.emit('clocksupdate'); 625 | } 626 | break; 627 | case 'clockStartStop': 628 | let i = data.clockIndex; 629 | if (this.status.clocks[i]) { 630 | let clock = this.status.clocks[i]; 631 | // I'm ignoring data.clockInfo because we don't know what the three items are 632 | clock.clockState = data.clockState == 1; // reported as int for some reason 633 | clock.clockTime = data.clockTime; 634 | } 635 | this.emit('clocksupdate'); 636 | break; 637 | default: 638 | break; 639 | } 640 | 641 | // handle update stream 642 | this.emit('data', data, this); 643 | 644 | // handle callbacks 645 | if (typeof this.callbacks[data.action] == 'function') { 646 | this.callbacks[data.action](data); 647 | delete this.callbacks[data.action]; 648 | } 649 | } 650 | 651 | addClockTypeText() { 652 | let types = ['Countdown', 'Countdown To Time', 'Elapsed Time']; 653 | for (let c of this.status.clocks) { 654 | c.clockTypeText = types[c.clockType]; 655 | } 656 | } 657 | fixClockTimeData() { 658 | for (let c of this.status.clocks) { 659 | c.text = c.clockTime; 660 | c.seconds = hms2secs(c.clockTime); 661 | c.over = c.seconds < 0; 662 | c.running = c.clockState; 663 | } 664 | } 665 | 666 | action(action, callback = null) { 667 | this.send({ action }, callback); 668 | } 669 | 670 | startClock(clockIndex, callback = null) { 671 | this.send({ action: 'clockStart', clockIndex }, callback); 672 | } 673 | 674 | stopClock(clockIndex, callback = null) { 675 | this.send({ action: 'clockStop', clockIndex }, callback); 676 | } 677 | 678 | resetClock(clockIndex, callback = null) { 679 | this.send({ action: 'clockReset', clockIndex }, callback); 680 | } 681 | 682 | subscribeClocks(callback = null) { 683 | this.action('clockStartSendingCurrentTime', callback); 684 | } 685 | 686 | unsubscribeClocks(callback = null) { 687 | this.action('clockStopSendingCurrentTime', callback); 688 | } 689 | 690 | getClocks(callback = null) { 691 | this.action('clockRequest', callback); 692 | } 693 | 694 | getLibrary(callback = null) { 695 | this.action('libraryRequest', callback); 696 | } 697 | 698 | getPlaylists(callback = null) { 699 | this.action('playlistRequestAll', callback); 700 | } 701 | 702 | getPresentation(path = null, quality = 200, callback = null) { 703 | if (path == null) { 704 | this.send( 705 | { 706 | action: 'presentationCurrent', 707 | presentationSlideQuality: quality, 708 | }, 709 | callback, 710 | ); 711 | } else { 712 | this.send( 713 | { 714 | action: 'presentationRequest', 715 | presentationPath: path, 716 | presentationSlideQuality: quality, 717 | }, 718 | callback, 719 | ); 720 | } 721 | } 722 | 723 | getCurrentSlideIndex(callback = null) { 724 | this.action('presentationSlideIndex', callback); 725 | } 726 | 727 | triggerSlide(index = 0, path = null, callback = null) { 728 | if (!this.controlling) return false; 729 | if (path == null && this.status.currentPresentation == null) return false; 730 | if (path == null) path = this.status.currentPresentation.presentationCurrentLocation; 731 | this.send( 732 | { 733 | action: 'presentationTriggerIndex', 734 | slideIndex: this.version == 7 ? index.toString() : index, 735 | presentationPath: path, 736 | }, 737 | callback, 738 | ); 739 | return true; 740 | } 741 | 742 | next(callback = null) { 743 | if (this.status.currentPresentation == null) return false; 744 | if (this.status.currentSlideIndex == null) return false; 745 | let nextIndex = this.status.currentSlideIndex + 1; 746 | return this.triggerSlide(nextIndex, null, callback); 747 | } 748 | 749 | prev(callback = null) { 750 | if (this.status.currentPresentation == null) return false; 751 | if (this.status.currentSlideIndex == null) return false; 752 | let nextIndex = this.status.currentSlideIndex - 1; 753 | if (nextIndex < 0) nextIndex = 0; 754 | return this.triggerSlide(nextIndex, null, callback); 755 | } 756 | } 757 | 758 | module.exports.ProController = ProController; 759 | -------------------------------------------------------------------------------- /modules/vmix-controller.js: -------------------------------------------------------------------------------- 1 | const got = require( 'got' ); 2 | const { Module, ModuleTrigger, ModuleTriggerArg } = require( './module' ); 3 | 4 | /* OLD VMIX TRIGGERS 5 | // VMIX: 6 | // [novmix] ← if found on a slide, no vmix triggers will be processed for that slide 7 | const vmix_ignore_pattern = /\[novmix\]/i; 8 | 9 | // vmix[transition_type, [input name/number], [transition duration milliseconds]] 10 | // transition_type can be whatever transition vmix supports 11 | // second two arguments are optional 12 | // input defaults to whatever is set to Preview 13 | // transition defaults to 1000 milliseconds 14 | const vmix_trans_pattern = /vmix\[(\w+)\s*(?:,\s*(.+?))?\s*(?:,\s*(\d+))?\s*\]/gi; 15 | 16 | // vmixcut[input name/number] ← shortcut to cut to an input (required) 17 | const vmix_cut_pattern = /vmixcut\[(.+?)\s*\]/gi; 18 | 19 | // vmixfade[input name/number, duration] ← shortcut to fade to an input (duration optional) 20 | const vmix_fade_pattern = /vmixfade\[(.+?)\s*(?:,\s*(\d+))?\s*\]/gi; 21 | 22 | // vmixtext[input name/number, [selected name/index], [textoverride]] 23 | // puts the current slide body text (or the textoverride) into the specified text box 24 | // of the specified input, selected name/index defaults to 0 25 | const vmix_text_pattern = /vmixtext\[(.+?)\s*(?:,\s*(.+?))?\s*(?:,\s*(.+?))?\s*\]/gi; 26 | 27 | // vmixoverlay[overlay number, [In|Out|On|Off], [input number]] 28 | // sets an input as an overlay 29 | // overlay is required 30 | // type defaults to null which toggles the overlay using the default transition 31 | // input defaults to the currently selected input (Preview) 32 | const vmix_overlay_pattern = /vmixoverlay\[(.+?)\s*(?:,\s*(.+?))?\s*(?:,\s*(.+?))?\s*\]/gi; 33 | 34 | // manually set the vmix lower3 html. 35 | const vmix_lower3_pattern = /\[l3\](.*?)\[\/l3\]/gis; 36 | const vmix_lower3caption_pattern = /\[l3caption\](.*?)\[\/l3caption\]/gis; 37 | 38 | // start streaming 39 | const vmix_streaming_pattern = /vmixstream\[([10]|on|off)\]/gi; 40 | 41 | // For advanced vMix control, put vMix API commands in JSON text between vmix tags 42 | // [vmix] 43 | // { 44 | // "Function": "Slide", 45 | // "Duration": 3000 46 | // } 47 | // [/vmix] 48 | const vmix_advanced = /\[vmix\](.*?)\[\/vmix\]/gis; 49 | 50 | // NOTE: vMix API Documentation is here: 51 | // https://www.vmix.com/help21/index.htm?DeveloperAPI.html 52 | // https://www.vmix.com/help19/index.htm?ShortcutFunctionReference.html 53 | // NOTE: multiple vmix triggers of each type can be handled per slide. 54 | 55 | */ 56 | 57 | class VmixController extends Module { 58 | static name = 'vmix'; 59 | static niceName = 'vMix Controller'; 60 | static create( config ) { 61 | return new VmixController( config ); 62 | } 63 | 64 | constructor ( config ) { 65 | super( config ); 66 | this.updateConfig( config ); 67 | 68 | this.lastmessage = ''; 69 | this.enabled = true; 70 | 71 | this.onupdate = this.notify; 72 | 73 | // setup triggers 74 | this.registerTrigger( 75 | new ModuleTrigger( 76 | '~slideupdate~', 77 | 'vMix Lyrics Handler (slide text => input text). This trigger runs on every slide update unless it sees "novmix" in the slide notes.', 78 | [], 79 | ( pro ) => { 80 | if ( pro.slides.current.notes.match( /novmix/ ) ) return; 81 | this.setInputText( this.default_title_input, pro.slides.current.text ); 82 | } 83 | ) 84 | ); 85 | 86 | // vmixtrans[transition_type, [input name/number], [transition duration milliseconds]] 87 | // transition_type can be whatever transition vmix supports 88 | // second two arguments are optional 89 | // input defaults to whatever is set to Preview 90 | // transition defaults to 1000 milliseconds 91 | // const vmix_trans_pattern = /vmixtrans\[(\w+)\s*(?:,\s*(.+?))?\s*(?:,\s*(\d+))?\s*\]/gi; 92 | this.registerTrigger( 93 | new ModuleTrigger( 94 | 'vmixtrans', 95 | 'fires a vmix transition event', 96 | [ 97 | new ModuleTriggerArg( 98 | 'transition', 99 | 'string', 100 | 'can be any transition type vmix supports (Fade, Cut, etc.) (see the vmix documentation for more)', 101 | false 102 | ), 103 | new ModuleTriggerArg( 104 | 'input', 105 | 'number', 106 | 'the number of the input to make live, defaults to whatever is Preview', 107 | true 108 | ), 109 | new ModuleTriggerArg( 110 | 'duration', 111 | 'number', 112 | 'defaults to 1000 ms', 113 | true 114 | ), 115 | ], 116 | ( _, trans, input, duration ) => 117 | this.transitionToInput( trans, input, duration ) 118 | ) 119 | ); 120 | 121 | // vmixcut[input name/number] ← shortcut to cut to an input (required) 122 | // const vmix_cut_pattern = /vmixcut\[(.+?)\s*\]/gi; 123 | this.registerTrigger( 124 | new ModuleTrigger( 125 | 'vmixcut', 126 | 'fires a vmix cut event', 127 | [ 128 | new ModuleTriggerArg( 129 | 'input', 130 | 'number', 131 | 'the number of the input to make live, defaults to whatever is Preview', 132 | true 133 | ), 134 | ], 135 | ( _, input ) => this.cutToInput( input ) 136 | ) 137 | ); 138 | 139 | // vmixfade[input name/number, duration] ← shortcut to fade to an input (duration optional) 140 | // const vmix_fade_pattern = /vmixfade\[(.+?)\s*(?:,\s*(\d+))?\s*\]/gi; 141 | this.registerTrigger( 142 | new ModuleTrigger( 143 | 'vmixfade', 144 | 'fires a vmix fade event', 145 | [ 146 | new ModuleTriggerArg( 147 | 'input', 148 | 'number', 149 | 'the number of the input to make live, defaults to whatever is Preview', 150 | true 151 | ), 152 | new ModuleTriggerArg( 153 | 'duration', 154 | 'number', 155 | 'defaults to 1000 ms', 156 | true 157 | ), 158 | ], 159 | ( _, input, duration ) => this.fadeToInput( input, duration ) 160 | ) 161 | ); 162 | 163 | // vmixtext[input name/number, [selected name/index], [textoverride]] 164 | // puts the current slide body text (or the textoverride) into the specified text box 165 | // of the specified input, selected name/index defaults to 0 166 | // const vmix_text_pattern = /vmixtext\[(.+?)\s*(?:,\s*(.+?))?\s*(?:,\s*(.+?))?\s*\]/gi; 167 | this.registerTrigger( 168 | new ModuleTrigger( 169 | 'vmixtext', 170 | 'updates the text on a vmix title', 171 | [ 172 | new ModuleTriggerArg( 173 | 'input', 174 | 'number', 175 | 'the number of the input containing the title to modify', 176 | false 177 | ), 178 | new ModuleTriggerArg( 179 | 'selection', 180 | 'string', 181 | 'name or index of text box to update, defaults to 0', 182 | true 183 | ), 184 | new ModuleTriggerArg( 185 | 'textoverride', 186 | 'string', 187 | 'defaults to the current slide body', 188 | true 189 | ), 190 | ], 191 | ( pro, input, selection = 0, override = null ) => 192 | this.setInputText( 193 | input, 194 | override ?? pro.slides.current.text, 195 | selection 196 | ) 197 | ) 198 | ); 199 | 200 | // vmixoverlay[overlay number, [In|Out|On|Off], [input number]] 201 | // sets an input as an overlay 202 | // overlay is required 203 | // type defaults to null which toggles the overlay using the default transition 204 | // input defaults to the currently selected input (Preview) 205 | // const vmix_overlay_pattern = /vmixoverlay\[(.+?)\s*(?:,\s*(.+?))?\s*(?:,\s*(.+?))?\s*\]/gi; 206 | this.registerTrigger( 207 | new ModuleTrigger( 208 | 'vmixoverlay', 209 | 'sets an input as an overlay, turning it on or off. If On or In, will wait duration ms and then disable again', 210 | [ 211 | new ModuleTriggerArg( 212 | 'overlay_number', 213 | 'number', 214 | 'which overlay slot do you want to use, defaults to 1', 215 | true 216 | ), 217 | new ModuleTriggerArg( 218 | 'toggletype', 219 | 'string', 220 | 'In/Out do a transition, On/Off do a cut, defaults to a toggle using the default transition', 221 | true 222 | ), 223 | new ModuleTriggerArg( 224 | 'input', 225 | 'number', 226 | 'the input to serve as the source of this overlay, defaults to the input currently on Preview', 227 | true 228 | ), 229 | new ModuleTriggerArg( 230 | 'seconds_on', 231 | 'number', 232 | 'the number of seconds to leave this overlay on. defaults to infinite', 233 | true 234 | ), 235 | ], 236 | ( _, overlaynum = 1, toggletype = 0, input = null, seconds = null ) => 237 | this.setOverlay( overlaynum, toggletype, input, seconds ) 238 | ) 239 | ); 240 | 241 | // start streaming 242 | // const vmix_streaming_pattern = /vmixstream\[([10]|on|off)\]/gi; 243 | this.registerTrigger( 244 | new ModuleTrigger( 245 | 'vmixstream', 246 | 'controls vmix stream', 247 | [ 248 | new ModuleTriggerArg( 249 | 'onoff', 250 | 'bool', 251 | 'on or off (defaults to on)', 252 | false 253 | ), 254 | new ModuleTriggerArg( 255 | 'stream_number', 256 | 'number', 257 | '1 or 0 defaults to 0', 258 | false 259 | ), 260 | ], 261 | ( _, start = true, stream_number = 1 ) => 262 | this.triggerStream( start, stream_number ) 263 | ) 264 | ); 265 | 266 | // vmix script trigger 267 | this.registerTrigger( 268 | new ModuleTrigger( 269 | 'vmixscript', 270 | 'fires a vmix script', 271 | [ 272 | new ModuleTriggerArg( 273 | 'script_name', 274 | 'string', 275 | 'name of the script to execute', 276 | false 277 | ), 278 | ], 279 | ( _, script_name = '' ) => 280 | this.scriptStart( script_name ) 281 | ) 282 | ); 283 | 284 | // vmix dynamic value set 285 | this.registerTrigger( 286 | new ModuleTrigger( 287 | 'vmixdv', 288 | 'sets a vmix dynamic value', 289 | [ 290 | new ModuleTriggerArg( 291 | 'index', 292 | 'number', 293 | 'index of the dynamic value to change', 294 | false 295 | ), 296 | new ModuleTriggerArg( 297 | 'value', 298 | 'string', 299 | 'value to set', 300 | false 301 | ) 302 | ], 303 | ( _, index = 0, value = '' ) => 304 | this.setDynamicValue( index, value ) 305 | ) 306 | ); 307 | 308 | // For advanced vMix control, include the api url params here 309 | // vmix[urlencoded_params] 310 | // const vmix_advanced = /\[vmix\](.*?)\[\/vmix\]/gis; 311 | this.registerTrigger( 312 | new ModuleTrigger( 313 | 'vmix', 314 | 'takes a urlencoded string, and passes it to the vmix api unchanged', 315 | [ 316 | new ModuleTriggerArg( 317 | 'query', 318 | 'string', 319 | 'Function=Cut&Input=1&Duration=4000', 320 | false 321 | ), 322 | ], 323 | ( _, query = null ) => ( query == null ? null : this.send( query ) ) 324 | ) 325 | ); 326 | 327 | 328 | // For advanced vMix control, put vMix API commands in JSON text between vmix tags 329 | // [vmixjson] 330 | // { 331 | // "Function": "Slide", 332 | // "Duration": 3000 333 | // } 334 | // [/vmixjson] 335 | // const vmix_advanced = /\[vmix\](.*?)\[\/vmix\]/gis; 336 | this.registerTrigger( 337 | new ModuleTrigger( 338 | 'vmixjson', 339 | 'sends commands directly to the vmix api after parsing them from a json string, can be a single object or an array', 340 | [ 341 | new ModuleTriggerArg( 342 | 'json_string', 343 | 'json', 344 | '[{"Function": "SetDynamicValue1", "Value": "hello world"},{"Function": "Cut", "Input": 1, "Duration": 4000}]', 345 | false 346 | ), 347 | ], 348 | ( _, data = null ) => ( data == null ? null : this.api( data ) ) 349 | ) 350 | ); 351 | } 352 | 353 | updateConfig( config ) { 354 | super.updateConfig( config ); 355 | let { host, port, default_title_input } = config; 356 | this.endpoint = `http://${host}:${port}/api/?`; 357 | this.default_title_input = default_title_input; 358 | } 359 | 360 | notify( data ) { 361 | this.emit( 'update', data ); 362 | } 363 | 364 | // returns a promise from "got" 365 | send( cmd ) { 366 | let url = `${this.endpoint}${cmd}`; 367 | console.log( `VMIX: ${url}` ); 368 | this.onupdate( 'sending command' ); 369 | return got( url ) 370 | .then( 371 | ( res ) => { 372 | console.log( `VMIX RESPONSE:\n${res.requestUrl}\n${res.body}` ); 373 | this.lastmessage = 'command successful'; 374 | this.onupdate( this.lastmessage ); 375 | }, 376 | ( err ) => { 377 | // console.log(err); 378 | this.lastmessage = 'command failed'; 379 | this.onupdate( this.lastmessage ); 380 | } 381 | ) 382 | .catch( ( err ) => { 383 | console.log( 'vmix request error' ); 384 | this.lastmessage = 'error sending command'; 385 | this.onupdate( this.lastmessage ); 386 | } ); 387 | } 388 | 389 | sendMultiple( queryList = [] ) { 390 | console.log( queryList ); 391 | let promises = [] 392 | for ( let query of queryList ) { 393 | promises.push( this.send( query ) ) 394 | } 395 | return Promise.all( promises ); 396 | } 397 | 398 | api( options ) { 399 | if ( Array.isArray( options ) ) { 400 | let promises = []; 401 | for ( let option of options ) promises.push( this.api( option ) ); 402 | return Promise.all( promises ); 403 | } 404 | 405 | let cmds = []; 406 | for ( let [ key, value ] of Object.entries( options ) ) { 407 | cmds.push( `${key}=${encodeURI( value )}` ); 408 | } 409 | let cmd = cmds.join( '&' ); 410 | return this.send( cmd ); 411 | } 412 | 413 | // when input is null, we toggle between program and preview 414 | transitionToInput( transition = 'Cut', input = null, duration = 1000 ) { 415 | let options = { Function: transition }; 416 | if ( input != null ) options.Input = input; 417 | if ( transition != 'Cut' ) options.Duration = duration; 418 | return this.api( options ); 419 | } 420 | 421 | // when input is null, we toggle between program and preview 422 | fadeToInput( input = null, duration = 1000 ) { 423 | return this.transitionToInput( 'Fade', input, duration ); 424 | } 425 | 426 | cutToInput( input = null ) { 427 | return this.transitionToInput( 'Cut', input ); 428 | } 429 | 430 | transition( transition_type = 'Cut' ) { 431 | return this.transitionToInput( transition_type ); 432 | } 433 | 434 | fade( duration = 1000 ) { 435 | return this.fadeToInput( null, duration ); 436 | } 437 | 438 | cut() { 439 | return this.cutToInput( null ); 440 | } 441 | 442 | // Dynamic Values are used in scripts 443 | setDynamicValue( index = 1, value = '' ) { 444 | let r = { 445 | Function: `SetDynamicValue${index}`, 446 | Value: value 447 | } 448 | return this.api( r ); 449 | } 450 | 451 | async scriptStart( scriptName, dynamicValues = {} ) { 452 | if ( dynamicValues.length > 0 ) { 453 | for ( let k of Object.keys( dynamicValues ) ) { 454 | await this.setDynamicValue( k, dynamicValues[ k ] ) 455 | } 456 | } 457 | return this.api( { 458 | Function: 'ScriptStart', 459 | Value: scriptName, 460 | } ); 461 | } 462 | 463 | // selected can be a name or an index 464 | setInputText( input = null, text = '', selected = 0 ) { 465 | let r = { 466 | Function: 'SetText', 467 | Value: text, 468 | }; 469 | if ( isNaN( +selected ) ) r.SelectedName = selected; 470 | else r.SelectedIndex = +selected; 471 | if ( input != null ) r.Input = input; 472 | return this.api( r ); 473 | } 474 | 475 | // type can be In, Out, On, Off or nothing for toggle 476 | // In/Out do a transition, On/Off do a cut 477 | setOverlay( overlay = 1, type = '', input = null, seconds = null ) { 478 | if ( this.previousTimer ) clearTimeout( this.previousTimer ); 479 | 480 | if ( isNaN( +overlay ) ) overlay = 1; 481 | let r = { 482 | Function: `OverlayInput${+overlay}${type}`, 483 | }; 484 | if ( input != null ) r.Input = input; 485 | 486 | if ( seconds != null && ( type == 'In' || type == 'On' ) ) { 487 | let flipped; 488 | switch ( type ) { 489 | case 'In': 490 | flipped = 'Out'; 491 | break; 492 | default: 493 | flipped = 'Off'; 494 | } 495 | this.previousTimer = setTimeout( 496 | () => this.setOverlay( overlay, flipped, input ), 497 | seconds * 1000 498 | ); 499 | } 500 | 501 | return this.api( r ); 502 | } 503 | 504 | triggerStream( shouldStart = true, stream = 0 ) { 505 | return this.api( { 506 | Function: shouldStart ? 'StartStreaming' : 'StopStreaming', 507 | Value: stream, 508 | } ); 509 | } 510 | } 511 | 512 | module.exports.VmixController = VmixController; 513 | -------------------------------------------------------------------------------- /modules/web-logger.js: -------------------------------------------------------------------------------- 1 | const got = require( 'got' ); 2 | 3 | class WebLogger { 4 | constructor ( url, key ) { 5 | this.key = key; 6 | this.url = url; 7 | this.debugLocal = false; 8 | } 9 | 10 | /// will run asynchronously 11 | log( s, forceLocal = false ) { 12 | if ( this.debugLocal || forceLocal ) 13 | console.log( `sending to external log: "${s}"` ); 14 | got 15 | .post( this.url, { 16 | body: JSON.stringify( { 17 | key: this.key, 18 | logstring: s, 19 | } ), 20 | } ) 21 | .then( ( res, err ) => { 22 | if ( err ) { 23 | console.log( 'error with remote log...' ); 24 | console.log( err ); 25 | } 26 | } ); 27 | } 28 | } 29 | 30 | module.exports = WebLogger; 31 | -------------------------------------------------------------------------------- /modules/x32-controller.js: -------------------------------------------------------------------------------- 1 | const osc = require( 'osc' ); 2 | const { Module, ModuleTrigger, ModuleTriggerArg } = require( './module' ); 3 | 4 | 5 | class X32Controller extends Module { 6 | static name = 'x32'; 7 | static niceName = 'X32 Controller'; 8 | 9 | static create( config ) { 10 | return new X32Controller( config ); 11 | } 12 | 13 | // x32 always uses udp port 10023 14 | port = 10023; 15 | 16 | constructor ( config ) { 17 | super( config ); 18 | 19 | // X32: 20 | // x32[address, typestring, ...args] 21 | this.registerTrigger( 22 | new ModuleTrigger( 23 | 'x32', 24 | 'parse and send an arbitrary x32 message', 25 | [ 26 | new ModuleTriggerArg( 'address', 'string', 'osc address string', false ), 27 | new ModuleTriggerArg( 28 | 'typestring', 29 | 'string', 30 | 'node osc typestring ifstbrm is int, float, string, time, blob, rgba, midi', 31 | true 32 | ), 33 | new ModuleTriggerArg( 34 | '...arguments', 35 | 'dynamic', 36 | 'remaining arguments will be parsed according to the typestring where rgba is written as #aabbccdd and blob and midi args are written in space-delimited hex arrays like [0x01 0x02]', 37 | true 38 | ), 39 | ], 40 | ( _, address, typestring, ...args ) => { 41 | let msg = { 42 | address, 43 | args: [] 44 | }; 45 | for ( let i = 0; i < typestring.length; i++ ) { 46 | if ( i < args.length ) { 47 | let type = typestring[ i ]; 48 | let value = args[ i ]; 49 | switch ( type ) { 50 | case 's': 51 | // value already is a string 52 | break; 53 | case 'i': 54 | value = parseInt( value ) 55 | break; 56 | case 'f': 57 | value = parseFloat( value ); 58 | break; 59 | case 't': 60 | value = { native: parseInt( value ) } // timestamp 61 | break; 62 | case 'r': 63 | value = value.replace( /^#/, '0x' ); 64 | value = parseInt( value ); 65 | if ( isNaN( value ) ) { 66 | console.log( 'could not parse value into color' ); 67 | continue; 68 | } 69 | value = { 70 | r: ( value & 0xFF000000 ) >> ( 32 ), 71 | g: ( value & 0x00FF0000 ) >> ( 16 ), 72 | b: ( value & 0x0000FF00 ) >> ( 8 ), 73 | a: ( value & 0x000000FF ), 74 | } 75 | 76 | break; 77 | case 'b': 78 | case 'm': 79 | let a = []; 80 | let error = false; 81 | value = value.replace( /[\[\]]/g, '' ) 82 | for ( let v of value.split( ' ' ) ) { 83 | v = parseInt( v ); 84 | if ( isNaN( v ) ) { 85 | error = true; 86 | } else { 87 | a.push( v ) 88 | } 89 | } 90 | if ( error ) { 91 | console.log( 'could not parse array into list of bytes' ); 92 | continue; 93 | } else { 94 | value = new Uint8Array( a ); 95 | } 96 | break; 97 | default: 98 | continue; 99 | } 100 | msg.args.push( { type, value } ); 101 | } 102 | } 103 | this.send( msg ); 104 | } 105 | ) 106 | ); 107 | 108 | // x32mute[type indicator, id, onoff] 109 | // mute group with /config/mute/[1...6] 110 | // mute channel with /ch/01..32/mix/on 111 | // mute bus with /bus/01...16/mix/on 112 | // mute dca with /dca/1...8/on 113 | this.registerTrigger( 114 | new ModuleTrigger( 115 | 'x32mute', 116 | 'mutes a channel, group, dca, or bus', 117 | [ 118 | new ModuleTriggerArg( 'cgdb', 'string', 'c, g, d, or b for channel, mute group, dca, or bus', false ), 119 | new ModuleTriggerArg( 120 | 'id', 121 | 'number', 122 | 'channel or group id (starting from 1; 32 channels, 6 groups, 8 dca, 16 busses)', 123 | false 124 | ), 125 | new ModuleTriggerArg( 126 | 'onoff', 127 | 'bool', 128 | 'defaults to on', 129 | true 130 | ), 131 | ], 132 | ( _, cgdb, id, onoff = true ) => this.mute( cgdb, id, onoff ) 133 | ) 134 | ); 135 | 136 | // x32fade[type indicator, id, onoff] 137 | // channel with /ch/01..32/mix/fader 138 | // bus with /bus/01...16/mix/fader 139 | // dca with /dca/1...8/fader 140 | this.registerTrigger( 141 | new ModuleTrigger( 142 | 'x32fade', 143 | 'sets the fader for a channel, dca, or bus', 144 | [ 145 | new ModuleTriggerArg( 'cdb', 'string', 'c, d, or b for channel, dca, or bus', false ), 146 | new ModuleTriggerArg( 147 | 'id', 148 | 'number', 149 | 'id (starting from 1; 32 channels, 8 dca, 16 busses)', 150 | false 151 | ), 152 | new ModuleTriggerArg( 153 | 'pct', 154 | 'number', 155 | '0-100 where 100 means +10db', 156 | true 157 | ), 158 | ], 159 | ( _, cdb, id, pct ) => this.fader( cdb, id, pct ) 160 | ) 161 | ); 162 | 163 | this.updateConfig( config ); 164 | } 165 | 166 | updateConfig( config ) { 167 | super.updateConfig( config ); 168 | this.host = config.host; 169 | if ( this.host && this.host != '' ) 170 | this.connect(); 171 | } 172 | 173 | close() { 174 | this.connected = false; 175 | if ( this.oscPort ) { 176 | this.oscPort.close(); 177 | this.oscPort.removeAllListeners(); 178 | this.oscPort = null; 179 | } 180 | } 181 | 182 | connect() { 183 | this.close(); 184 | this.oscPort = new osc.UDPPort( { 185 | localAddress: "0.0.0.0", 186 | remoteAddress: this.host, 187 | remotePort: this.port, 188 | metadata: true, 189 | } ); 190 | 191 | this.oscPort.on( 'ready', () => this.connected = true ); 192 | this.oscPort.on( 'error', this.handleError ); 193 | this.oscPort.on( 'message', this.handleMessage ); 194 | // this.oscPort.on( 'bundle', this.handleBundle ); 195 | 196 | this.oscPort.open(); 197 | } 198 | 199 | handleError( e ) { 200 | console.log( e ); 201 | this.emit( 'error', e ); 202 | // this.connected = false; 203 | } 204 | 205 | // osc messages have two fields: address and args 206 | // address is a string like /an/osc/address 207 | // args are an array of objects with "type" and "value" 208 | // type can be i, f, s, t, b, r, m 209 | // for int32, float32, string, timetag, blob, rgba, midi 210 | // the timetag value is object with javascript timestamp in the "native" field 211 | // rgba value is an object with keys: r,g,b,a 212 | // blob value is Uint8Array 213 | // midi value is Uint8Array([port id, status, data1, data2]) 214 | handleMessage( oscMsg, timeTag, info ) { 215 | this.log( `OSC MSG RECEIVED: ${JSON.stringify( oscMsg )}` ); 216 | let address = oscMsg.address; 217 | let args = oscMsg.args; 218 | } 219 | 220 | status() { 221 | return { 222 | connected: this.connected, 223 | }; 224 | } 225 | 226 | // message should be an osc message according to node osc package 227 | // https://www.npmjs.com/package/osc 228 | send( message ) { 229 | console.log( message ); 230 | try { 231 | this.oscPort.send( message ); 232 | } catch ( e ) { 233 | console.log( e ); 234 | } 235 | } 236 | 237 | mute( cgdb, num, onoff ) { 238 | let address; 239 | num = num.toString(); 240 | let value = onoff ? 0 : 1; 241 | switch ( cgdb ) { 242 | case 'c': 243 | address = `/ch/${num.padStart( 2, '0' )}/mix/on`; 244 | break; 245 | case 'g': 246 | address = `/config/mute/${num.padStart( 1, '0' )}`; 247 | value = onoff ? 1 : 0; 248 | break; 249 | case 'd': 250 | address = `/dca/${num.padStart( 1, '0' )}/on`; 251 | break; 252 | case 'b': 253 | address = `/bus/${num.padStart( 2, '0' )}/mix/on`; 254 | break; 255 | } 256 | if ( address ) { 257 | this.send( { 258 | address, 259 | args: [ { 260 | type: 'i', 261 | value, 262 | } ] 263 | } ) 264 | } 265 | } 266 | fader( cdb, num, pct ) { 267 | let address; 268 | num = num.toString(); 269 | let value = pct / 100; // convert the percentage to float 270 | switch ( cdb ) { 271 | case 'c': 272 | address = `/ch/${num.padStart( 2, '0' )}/mix/fader`; 273 | break; 274 | case 'd': 275 | address = `/dca/${num.padStart( 1, '0' )}/fader`; 276 | break; 277 | case 'b': 278 | address = `/bus/${num.padStart( 2, '0' )}/mix/fader`; 279 | break; 280 | } 281 | if ( address ) { 282 | this.send( { 283 | address, 284 | args: [ { 285 | value, 286 | type: 'f', 287 | } ] 288 | } ) 289 | } 290 | } 291 | } 292 | 293 | module.exports.X32Controller = X32Controller; 294 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "propresenter-master-control", 3 | "version": "1.0.0", 4 | "description": "Watches an active ProPresenter instance using the StageDisplay API and runs commands based on ProPresenter events.", 5 | "main": "app.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "lint": "eslint -c .eslintrc.yml . --ext .js,.ts" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "got": "^9.6.0", 14 | "isomorphic-ws": "^4.0.1", 15 | "midi": "^1.0.4", 16 | "obs-websocket-js": "^5.0.3", 17 | "osc": "^2.4.2", 18 | "socket.io": "^2.2.0", 19 | "telnet-client": "^1.4.9", 20 | "uuid": "^8.3.2", 21 | "ws": "^7.1.2" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.15.5", 25 | "@babel/eslint-parser": "^7.15.4", 26 | "@babel/plugin-syntax-class-properties": "^7.12.13", 27 | "@babel/preset-env": "^7.15.6", 28 | "eslint": "^7.32.0", 29 | "eslint-config-prettier": "^8.3.0", 30 | "eslint-plugin-import": "^2.24.2", 31 | "eslint-plugin-node": "^11.1.0", 32 | "eslint-plugin-promise": "^5.1.0", 33 | "prettier": "^2.4.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /start.bat: -------------------------------------------------------------------------------- 1 | node app.js 2 | @pause 3 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | open 'http://localhost:7000/' 4 | node watcher.js -------------------------------------------------------------------------------- /test-7.9+.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Test 7.9 API 8 | 9 | 10 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /test-obs.js: -------------------------------------------------------------------------------- 1 | const { default: OBSWebSocket } = require('obs-websocket-js'); 2 | 3 | const print = console.log; 4 | 5 | function findall(regex, subject) { 6 | let matches = []; 7 | let match = true; 8 | while (match) { 9 | match = regex.exec(subject); 10 | if (match) { 11 | matches.push(match); 12 | } 13 | } 14 | return matches; 15 | } 16 | 17 | print('------ OBS TESTING -----'); 18 | async function testOBS() { 19 | const obs = new OBSWebSocket(); 20 | try { 21 | await obs.connect('ws://127.0.0.1:4455', '7bZmcvMKmkOPnLZe'); 22 | } catch (e) { 23 | console.log(e); 24 | } 25 | // print(obs.getStatus()); 26 | // print(await obs.setSceneItemRender('Pro Slide Text', true, 'Live With Lyrics (Text)')); 27 | // print(await obs.setPreviewScene('Live With Lower Third')); 28 | } 29 | 30 | async function main() { 31 | // obs.on('connected', () => testOBS()); 32 | await testOBS(); 33 | } 34 | 35 | main(); 36 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Test 8 | 9 | 10 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | const { OBSController } = require('./modules/obs-controller'); 2 | 3 | const print = console.log; 4 | 5 | function findall(regex, subject) { 6 | let matches = []; 7 | let match = true; 8 | while (match) { 9 | match = regex.exec(subject); 10 | if (match) { 11 | matches.push(match); 12 | } 13 | } 14 | return matches; 15 | } 16 | 17 | print('------ OBS TESTING -----'); 18 | async function testOBS() { 19 | let obs = new OBSController({ host: '127.0.0.1', port: 4455, password: '7bZmcvMKmkOPnLZe' }); 20 | await obs.future; 21 | await obs.fade(); 22 | // await obs.getOBSStatus(); 23 | // print(obs.getStatus()); 24 | // print(await obs.setSceneItemRender('Pro Slide Text', true, 'Live With Lyrics (Text)')); 25 | // print(await obs.setPreviewScene('Live With Lower Third')); 26 | } 27 | 28 | async function main() { 29 | // obs.on('connected', () => testOBS()); 30 | await testOBS(); 31 | } 32 | 33 | main(); 34 | -------------------------------------------------------------------------------- /ui/index-old.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ProPresenter Watcher 8 | 190 | 191 | 192 | 193 | 194 |
195 | 196 | {{ pro_status.system_time.text }} 197 | 198 | PP: 199 | 200 | {{ pro_status.active ? "ACTIVE" : "INACTIVE" }} 201 | 202 | 203 | 204 | 205 |

206 | ProPresenter Master Control 207 |

208 | 209 | 210 | 211 | 212 | 218 | 219 |
220 | 221 | 222 | 247 | 248 | 249 |
250 |
251 | 252 | 253 |
254 |
255 |

Controllers Configuration

256 |
257 |

{{controller.niceName}}

258 |
    259 |
  • 260 | 261 | 262 |
  • 263 |
264 |
265 |
266 |
267 |

Triggers

268 |
    269 |
  • 270 | 274 |
  • 275 |
276 |
277 |
    278 |
  • 279 | 288 |
  • 289 |
  • 290 | 314 |
  • 315 |
316 |
317 | 318 |
319 | {{ logtext }} 320 |
321 |
322 |
323 | 324 | 325 | 326 | 327 | 328 | 452 | 453 | 454 | -------------------------------------------------------------------------------- /ui/lib/mdi/fonts/materialdesignicons-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffmikels/propresenter-watcher/fdfc2edb455537d33d6e131b39c24f9dd0776769/ui/lib/mdi/fonts/materialdesignicons-webfont.eot -------------------------------------------------------------------------------- /ui/lib/mdi/fonts/materialdesignicons-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffmikels/propresenter-watcher/fdfc2edb455537d33d6e131b39c24f9dd0776769/ui/lib/mdi/fonts/materialdesignicons-webfont.ttf -------------------------------------------------------------------------------- /ui/lib/mdi/fonts/materialdesignicons-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffmikels/propresenter-watcher/fdfc2edb455537d33d6e131b39c24f9dd0776769/ui/lib/mdi/fonts/materialdesignicons-webfont.woff -------------------------------------------------------------------------------- /ui/lib/mdi/fonts/materialdesignicons-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffmikels/propresenter-watcher/fdfc2edb455537d33d6e131b39c24f9dd0776769/ui/lib/mdi/fonts/materialdesignicons-webfont.woff2 -------------------------------------------------------------------------------- /ui/lower3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ProPresenter Watcher Lower Third 8 | 9 | 10 | 14 | 15 | 106 | 107 | 108 | 109 | 110 |
111 |
112 | 113 |
114 |
115 | 116 | 117 | 122 |
123 |
124 |
125 |
126 | 127 | 128 | 129 | 130 | 131 | 226 | 227 | 228 | -------------------------------------------------------------------------------- /ui/lower3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffmikels/propresenter-watcher/fdfc2edb455537d33d6e131b39c24f9dd0776769/ui/lower3.jpg -------------------------------------------------------------------------------- /ui/presenter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ProPresenter Notes Teleprompter 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |
19 |
20 | 21 | 22 |
23 |
24 |
25 |
{{timer.text}}
26 |
27 |
28 |
{{stage_message}}
29 |
30 | 31 | 32 | 33 | 34 | 35 | 135 | 136 | 137 | -------------------------------------------------------------------------------- /ui/sd.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ProPresenter Watcher Clock and Stage Display 8 | 9 | 71 | 72 | 73 | 74 | 75 |
76 |
77 |
78 |
{{timer.text}}
79 |
80 |
tap a counter to make it small
81 |
82 |
{{stage_message}}
83 |
84 | 85 | 86 | 87 | 88 | 89 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /ui/side3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ProPresenter Watcher Side Third 8 | 9 | 10 | 14 | 15 | 127 | 128 | 129 | 130 | 131 |
132 |
133 | 134 |
135 |
136 | 137 | 138 | 143 |
144 |
145 |
146 |
147 | 148 | 149 | 150 | 151 | 152 | 247 | 248 | 249 | -------------------------------------------------------------------------------- /ui/style.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Lexend:wght@300;500;700&display=swap'); 2 | html, 3 | body { 4 | margin: 0; 5 | width: 100%; 6 | height: 100%; 7 | height: auto; 8 | overflow: hidden; 9 | } 10 | 11 | body { 12 | background: black; 13 | color: ivory; 14 | font-family: 'Lexend', sans-serif; 15 | font-weight: 500; 16 | box-sizing: border-box; 17 | text-align: center; 18 | } 19 | textarea { 20 | width: 100%; 21 | color: ivory; 22 | } 23 | .timers { 24 | position: fixed; 25 | bottom: 0; 26 | left: 0; 27 | right: 0; 28 | } 29 | 30 | .timers .timer { 31 | display: inline-block; 32 | } 33 | 34 | .timer:nth-child(3n-1) { 35 | color: yellow; 36 | } 37 | 38 | .timer:nth-child(3n-2) { 39 | color: lightgreen; 40 | } 41 | 42 | .timer:nth-child(3n-3) { 43 | color: orchid; 44 | } 45 | 46 | .timer { 47 | font-size: 80px; 48 | margin: 0 40px; 49 | } 50 | 51 | .timer.negative { 52 | color: red; 53 | } 54 | 55 | .timer .small { 56 | font-size: 24px; 57 | } 58 | 59 | .message { 60 | font-size: 200px; 61 | letter-spacing: -20px; 62 | position: absolute; 63 | color: lightblue; 64 | top: 0; 65 | left: 0; 66 | right: 0; 67 | } 68 | 69 | span.blank { 70 | color: #bef7ff; 71 | text-transform: uppercase; 72 | text-decoration: underline; 73 | font-weight: 900; 74 | letter-spacing: -5px; 75 | } 76 | 77 | .mirror { 78 | transform: matrix(-1, 0, 0, 1, 0, 0); 79 | /* transform-origin: top left; */ 80 | } 81 | 82 | .prompter { 83 | font-size: 50pt; 84 | text-align: left; 85 | position: absolute; 86 | top: 0; 87 | left: 0; 88 | right: 0; 89 | bottom: 0; 90 | height: 100%; 91 | width: 100%; 92 | box-sizing: border-box; 93 | padding: 10px 30px; 94 | } 95 | .prompter p { 96 | margin: 0; 97 | margin-bottom: 0.3em; 98 | line-height: 1.1em; 99 | } 100 | /* .current, 101 | .next { 102 | white-space: pre-wrap; 103 | } */ 104 | 105 | .current { 106 | color: yellow; 107 | font-weight: 500; 108 | } 109 | .current.notes { 110 | color: rgb(179, 253, 29); 111 | font-size: 0.6em; 112 | font-weight: 300; 113 | } 114 | 115 | .next { 116 | color: grey; 117 | font-size: 0.5em; 118 | font-weight: 300; 119 | } 120 | -------------------------------------------------------------------------------- /watcher.js: -------------------------------------------------------------------------------- 1 | const main = 'app.js'; 2 | 3 | var process = require('process'); 4 | var cp = require('child_process'); 5 | var fs = require('fs'); 6 | 7 | var server = cp.fork(main); 8 | console.log('Server started'); 9 | 10 | fs.watch('.', { recursive: true }, function (event, filename) { 11 | console.log(`${filename} file changed on disk...`); 12 | if (filename.match(/\/ui\//)) { 13 | console.log('ui code... ignoring'); 14 | return; 15 | } 16 | if (!filename.match(/\.js$/)) { 17 | console.log('not a javascript file... ignoring'); 18 | return; 19 | } 20 | 21 | console.log(`reloading ${main}`); 22 | server.kill(); 23 | console.log('Server stopped'); 24 | server = cp.fork(main); 25 | console.log('Server started'); 26 | }); 27 | 28 | process.on('SIGINT', function () { 29 | server.kill(); 30 | fs.unwatchFile(main); 31 | process.exit(); 32 | }); 33 | --------------------------------------------------------------------------------