├── .browserslistrc
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── FUNDING.yml
├── LICENSE
├── README.md
├── electron-builder.yml
├── package.json
├── public
├── favicon.ico
└── index.html
├── readme_assets
├── app-overview_010.png
├── app-overview_041.png
├── html-view-circular.png
├── html-view-linear.png
└── html-view.png
├── src
├── App.vue
├── assets
│ ├── logo.png
│ └── logo.svg
├── background.ts
├── components
│ └── HelloWorld.vue
├── main.ts
├── plugins
│ └── vuetify.ts
├── shims-tsx.d.ts
├── shims-vue.d.ts
├── store
│ ├── actions.ts
│ ├── index.ts
│ ├── mutations.ts
│ └── state.ts
├── types
│ ├── title-mode.ts
│ └── vmix-input.ts
├── utility
│ ├── paths.ts
│ └── time.ts
├── view-templates
│ ├── overview.ts
│ └── progress.ts
├── views
│ ├── DelayCompensationSlider.vue
│ ├── HtmlViews.vue
│ ├── Layout
│ │ └── AppBar.vue
│ └── TitleModeSettings
│ │ ├── CurrentPositionField.vue
│ │ ├── CurrentPositionFields.vue
│ │ ├── Index.vue
│ │ ├── RemainingField.vue
│ │ ├── RemainingFields.vue
│ │ ├── WidthField.vue
│ │ └── WidthFields.vue
├── web-frontend
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ ├── Circular.vue
│ │ ├── Linear.vue
│ │ ├── Main.vue
│ │ ├── app.js
│ │ ├── mixin.js
│ │ └── styles
│ │ │ ├── app.sass
│ │ │ ├── circular.sass
│ │ │ ├── dark.sass
│ │ │ └── linear.sass
│ ├── webpack.mix.js
│ └── yarn.lock
└── webserver.ts
├── tsconfig.json
├── vue.config.js
└── yarn.lock
/.browserslistrc:
--------------------------------------------------------------------------------
1 | > 1%
2 | last 2 versions
3 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 |
4 | env: {
5 | node: true
6 | },
7 |
8 | extends: ['plugin:vue/essential', '@vue/prettier', '@vue/typescript'],
9 |
10 | rules: {
11 | 'no-console': process.env.NODE_ENV === 'production' ? 'off' : 'off',
12 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
13 | semicolon: 'off',
14 | semi: [2, 'never']
15 | },
16 |
17 | parserOptions: {
18 | parser: '@typescript-eslint/parser'
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 |
5 | # local env files
6 | .env.local
7 | .env.*.local
8 |
9 | # Log files
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 |
14 | # Editor directories and files
15 | .idea
16 | .vscode
17 | *.suo
18 | *.ntvs*
19 | *.njsproj
20 | *.sln
21 | *.sw?
22 |
23 | # Electron-builder output
24 | /dist_electron
25 |
26 | # Ignore files in dist public directory
27 | src/web-frontend/dist/public
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "printWidth": 100,
4 | "semi": false,
5 | "singleQuote": true
6 | }
--------------------------------------------------------------------------------
/FUNDING.yml:
--------------------------------------------------------------------------------
1 | custom: ["https://www.paypal.me/stigaard"]
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Jens Grønhøj Stigaard
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # vinproma
2 |
3 | [](../../releases)
4 | [](../../releases)
5 | [](https://paypal.me/stigaard)
6 |
7 | **v**Mix **In**put **Pro**gress **M**onitor **A**pp (shortened: *vinproma*) built with [ElectronJS](https://electronjs.org). ElectronJS is a cross-platform framework allowing the app to be built for each Windows, Mac or Linux.
8 |
9 | The app allows simple monitoring of realtime progress of the current playing video or audio track. The input in preview can also be to monitored in the HTML view.
10 |
11 | You are free to clone the repository to develop your own app based in this code.
12 |
13 | ## Feature summary
14 | - Read progress of current input in program
15 | - HTML view: View progress as a HTML page
16 | - Linear progress or circular
17 | - Light or dark mode
18 | - vMix title mode: Send progress to a vMix title input of your choice
19 | - Multiple types of data to send
20 | - Ability to setup multiple "destinations"
21 | - Ability to enable Delay compensation
22 |
23 | ## Downloads
24 |
25 | See the [Releases](../../releases) tab for a direct download of the app for Mac and Windows.
26 |
27 |
28 | ## App overview
29 | 
30 |
31 | ## HTML view (Linear or circular)
32 | 
33 |
34 | 
35 |
36 |
37 | ## Project architecture
38 | The project consists of several components. The app is built with Electron, meaning that the app can be compiled for Windows, Mac or Linux.
39 |
40 | The Electron frontend (also called "Renderer") serves the purpose of fetching info from a vMix instance, and letting the user see the state of the vMix instance connection and other settings.
41 |
42 | A web server lives on the backend side of the Electron app, serving requests from the users on port 8095. The web server includes a web socket server, serving flowing real time TCP/IP data to the connected clients/browsers.
43 |
44 | The web server communicates with the Electron frontend app, where practically realtime data from the vMix instances is passed as following:
45 |
46 | Electron renderer process → (via IPC) → Electron main process → Web server → (via Web Socket) → Web clients
47 |
48 | ## Known issues
49 | When running in development mode you can experience loss of connection to the vMix instance after some minutes.
50 |
51 | ## Roadmap
52 |
53 | - [ ] Add XAML template
54 |
55 |
56 | ## Project setup
57 | ### Install dependencies (based on package.json)
58 | ```
59 | yarn
60 | ```
61 |
62 | ### Compiles and hot-reloads for development
63 | ```
64 | yarn electron:serve
65 | ```
66 |
67 | ### Compiles and minifies for production
68 | ```
69 | yarn electron:build
70 | ```
71 |
72 | ### Lints and fixes files
73 | ```
74 | yarn lint
75 | ```
76 |
77 | ### Web frontend assets
78 | Note that in **src/web-frontend** there is also source code for the compiled assets for the web frontend.
79 | You should also install the dependencies for this by running
80 | ```
81 | cd src/web-frontend
82 | yarn
83 | ```
84 |
85 | Compiles and hot-reloads for development
86 | ```
87 | yarn watch
88 | ```
89 |
90 | There is also a alias for this in the main package.json:
91 | ```
92 | yarn web-assets-watch
93 | ```
94 | And also before building the Electron app, a command to build web frontend assets for production can be run:
95 | ```
96 | yarn web-assets-prod
97 | ```
98 |
99 | ### Customize configuration
100 | See [Configuration Reference](https://cli.vuejs.org/config/).
101 |
--------------------------------------------------------------------------------
/electron-builder.yml:
--------------------------------------------------------------------------------
1 | extraResources:
2 | - { from: "src/web-frontend/dist", to: "web-frontend/dist" }
3 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vinproma",
3 | "productName": "vinproma",
4 | "version": "0.4.1",
5 | "private": true,
6 | "author": {
7 | "name": "Jens Grønhøj Stigaard",
8 | "email": "jens@stigaard.info"
9 | },
10 | "license": "MIT",
11 | "repository": {
12 | "type": "git",
13 | "url": "git+https://github.com/jensstigaard/vinproma.git"
14 | },
15 | "scripts": {
16 | "serve": "vue-cli-service serve",
17 | "build": "vue-cli-service build",
18 | "lint": "vue-cli-service lint",
19 | "electron:build": "vue-cli-service electron:build",
20 | "electron:serve": "vue-cli-service electron:serve",
21 | "postinstall": "electron-builder install-app-deps",
22 | "postuninstall": "electron-builder install-app-deps",
23 | "web-assets-prod": "cd src/web-frontend && yarn prod",
24 | "web-assets-watch": "cd src/web-frontend && yarn watch"
25 | },
26 | "main": "background.js",
27 | "dependencies": {
28 | "@fortawesome/fontawesome-free": "^5.15",
29 | "electron": "^13.6.6",
30 | "electron-window-state": "^5",
31 | "express": "^4.17",
32 | "ip": "^1.1",
33 | "vmix-js-utils": "^3",
34 | "vue": "^2.6",
35 | "vue-class-component": "^7.2",
36 | "vue-debounce-decorator": "^1",
37 | "vue-directive-long-press": "^1",
38 | "vue-vmix-conn-plugin": "^1",
39 | "vuetify": "^2",
40 | "vuex": "^3.6",
41 | "vuex-electron": "^1",
42 | "ws": "^7"
43 | },
44 | "devDependencies": {
45 | "@types/cookie-parser": "^1.4",
46 | "@types/electron-devtools-installer": "^2.2.0",
47 | "@types/express": "^4.17",
48 | "@types/ip": "^1.1.0",
49 | "@types/node": "12",
50 | "@types/ws": "^7.4.0",
51 | "@typescript-eslint/eslint-plugin": "^4.11.1",
52 | "@typescript-eslint/parser": "4.4.0",
53 | "@vue/cli-plugin-eslint": "^4.5",
54 | "@vue/cli-plugin-typescript": "^4.5",
55 | "@vue/cli-plugin-vuex": "^4.5",
56 | "@vue/cli-service": "^4.5",
57 | "@vue/eslint-config-prettier": "^6.0",
58 | "@vue/eslint-config-typescript": "^7.0",
59 | "body-parser": "^1.19",
60 | "bufferutil": "^4.0",
61 | "cookie-parser": "^1.4",
62 | "deepmerge": "^4.2",
63 | "electron-devtools-installer": "^3.1.1",
64 | "eslint": "^6",
65 | "eslint-plugin-prettier": "^3.3.0",
66 | "eslint-plugin-vue": "^7.4.0",
67 | "fibers": "^5.0.0",
68 | "lint-staged": "^10.5",
69 | "prettier": "^2.2.1",
70 | "pug": "^3.0.3",
71 | "pug-plain-loader": "^1.1",
72 | "sass": "^1",
73 | "sass-loader": "^10",
74 | "typescript": "~4.0.5",
75 | "utf-8-validate": "^5",
76 | "vue-cli-plugin-electron-builder": "^2.0",
77 | "vue-cli-plugin-pug": "^2.0",
78 | "vue-cli-plugin-vuetify": "^2.0",
79 | "vue-property-decorator": "^9",
80 | "vue-template-compiler": "^2.6",
81 | "vuetify-loader": "^1.3.0"
82 | },
83 | "gitHooks": {
84 | "pre-commit": "lint-staged"
85 | },
86 | "lint-staged": {
87 | "*.{js,vue,ts}": [
88 | "vue-cli-service lint",
89 | "git add"
90 | ]
91 | }
92 | }
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jensstigaard/vinproma/cfe0c91e72118c39b2371bdb304c3efb7a1083ba/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | vinproma – vMix Input Progress Monitor
9 |
10 |
11 |
12 |
13 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/readme_assets/app-overview_010.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jensstigaard/vinproma/cfe0c91e72118c39b2371bdb304c3efb7a1083ba/readme_assets/app-overview_010.png
--------------------------------------------------------------------------------
/readme_assets/app-overview_041.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jensstigaard/vinproma/cfe0c91e72118c39b2371bdb304c3efb7a1083ba/readme_assets/app-overview_041.png
--------------------------------------------------------------------------------
/readme_assets/html-view-circular.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jensstigaard/vinproma/cfe0c91e72118c39b2371bdb304c3efb7a1083ba/readme_assets/html-view-circular.png
--------------------------------------------------------------------------------
/readme_assets/html-view-linear.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jensstigaard/vinproma/cfe0c91e72118c39b2371bdb304c3efb7a1083ba/readme_assets/html-view-linear.png
--------------------------------------------------------------------------------
/readme_assets/html-view.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jensstigaard/vinproma/cfe0c91e72118c39b2371bdb304c3efb7a1083ba/readme_assets/html-view.png
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 | v-app
3 | app-bar
4 |
5 | v-main
6 | v-container(v-if='!$vMixConnection.connected')
7 | .text-center
8 | v-icon(color='orange') fa-exclamation-circle
9 | div: b Not yet connected to vMix instance...
10 | div Please check whether the entered IP address ({{ $store.state.vMixConnection.host }}) is correct...
11 | v-container(fluid, v-else)
12 | div(v-if='!tallyInfo') No inputs found somehow...
13 | div(v-else)
14 | v-row
15 | v-col: delay-compensation-slider
16 |
17 | v-divider(vertical)
18 |
19 | v-col: html-views
20 |
21 | v-divider.my-4
22 |
23 | vmix-title-mode-settings(:titles='titles')
24 |
25 |
26 |
327 |
328 |
335 |
--------------------------------------------------------------------------------
/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jensstigaard/vinproma/cfe0c91e72118c39b2371bdb304c3efb7a1083ba/src/assets/logo.png
--------------------------------------------------------------------------------
/src/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/background.ts:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | import { app, protocol, BrowserWindow, ipcMain } from 'electron'
4 | import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'
5 | import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'
6 | import windowStateKeeper from 'electron-window-state'
7 |
8 | // Web socket and web socket server
9 | import WebSocket from 'ws'
10 | import webserver from './webserver'
11 |
12 | // VueX store
13 | // import store from './store'
14 | import './store'
15 |
16 | const isDevelopment = process.env.NODE_ENV !== 'production'
17 |
18 | // Keep a global reference of the window object, if you don't, the window will
19 | // be closed automatically when the JavaScript object is garbage collected.
20 | let win: BrowserWindow | null
21 |
22 | // Scheme must be registered before the app is ready
23 | protocol.registerSchemesAsPrivileged([
24 | { scheme: 'app', privileges: { secure: true, standard: true } },
25 | ])
26 |
27 | function createWindow() {
28 | const mainWindowState = windowStateKeeper({
29 | defaultWidth: 1000,
30 | defaultHeight: 600,
31 | })
32 |
33 | // Create the browser window.
34 | win = new BrowserWindow({
35 | width: mainWindowState.width,
36 | height: mainWindowState.height,
37 | x: mainWindowState.x,
38 | y: mainWindowState.y,
39 |
40 | webPreferences: {
41 | nodeIntegration: true,
42 | enableRemoteModule: true,
43 | },
44 | })
45 |
46 | // Manage window state (position and size)
47 | // and remember it for every time the application is opened
48 | mainWindowState.manage(win)
49 |
50 | if (process.env.WEBPACK_DEV_SERVER_URL) {
51 | // Load the url of the dev server if in development mode
52 | win.loadURL(process.env.WEBPACK_DEV_SERVER_URL as string)
53 | if (!process.env.IS_TEST) win.webContents.openDevTools()
54 | } else {
55 | createProtocol('app')
56 | // Load the index.html when not in development
57 | win.loadURL('app://./index.html')
58 | }
59 |
60 | win.on('closed', () => {
61 | win = null
62 | })
63 | }
64 |
65 | // Quit when all windows are closed.
66 | app.on('window-all-closed', () => {
67 | // On macOS it is common for applications and their menu bar
68 | // to stay active until the user quits explicitly with Cmd + Q
69 | if (process.platform !== 'darwin') {
70 | app.quit()
71 | }
72 | })
73 |
74 | app.on('activate', () => {
75 | // On macOS it's common to re-create a window in the app when the
76 | // dock icon is clicked and there are no other windows open.
77 | if (win === null) {
78 | createWindow()
79 | }
80 | })
81 |
82 | // This method will be called when Electron has finished
83 | // initialization and is ready to create browser windows.
84 | // Some APIs can only be used after this event occurs.
85 | app.on('ready', async () => {
86 | if (isDevelopment && !process.env.IS_TEST) {
87 | // Install Vue Devtools
88 | // Devtools extensions are broken in Electron 6.0.0 and greater
89 | // See https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/378 for more info
90 | // Electron will not launch with Devtools extensions installed on Windows 10 with dark mode
91 | // If you are not using Windows 10 dark mode, you may uncomment these lines
92 | // In addition, if the linked issue is closed, you can upgrade electron and uncomment these lines
93 | try {
94 | await installExtension(VUEJS_DEVTOOLS)
95 | } catch (e) {
96 | console.error('Vue Devtools failed to install:', e.toString())
97 | }
98 | }
99 | createWindow()
100 | })
101 |
102 | // Exit cleanly on request from parent process in development mode.
103 | if (isDevelopment) {
104 | if (process.platform === 'win32') {
105 | process.on('message', (data) => {
106 | if (data === 'graceful-exit') {
107 | app.quit()
108 | }
109 | })
110 | } else {
111 | process.on('SIGTERM', () => {
112 | app.quit()
113 | })
114 | }
115 | }
116 |
117 | // this.conn.on('disconnect', console.error)
118 |
119 | // const isMac = process.platform === 'darwin'
120 |
121 | // const template = [
122 | // // { role: 'appMenu' }
123 | // ...(isMac
124 | // ? [
125 | // {
126 | // label: app.name,
127 | // submenu: [
128 | // { role: 'about' },
129 | // { type: 'separator' },
130 | // { role: 'services' },
131 | // { type: 'separator' },
132 | // { role: 'hide' },
133 | // { role: 'hideothers' },
134 | // { role: 'unhide' },
135 | // { type: 'separator' },
136 | // { role: 'quit' }
137 | // ]
138 | // }
139 | // ]
140 | // : []),
141 | // {
142 | // label: 'View',
143 | // submenu: [
144 | // // {
145 | // // label: 'Swap Preview/Program rows',
146 | // // accelerator: process.platform === 'darwin' ? 'Alt+Cmd+P' : 'Ctrl+Shift+P',
147 | // // click: async () => {
148 | // // store.dispatch('swapPreviewProgramRows')
149 | // // }
150 | // // },
151 | // { type: 'separator' },
152 | // { role: 'reload' },
153 | // { role: 'forcereload' },
154 | // { role: 'toggledevtools' },
155 | // { type: 'separator' },
156 | // { role: 'resetzoom' },
157 | // { role: 'zoomin' },
158 | // { role: 'zoomout' },
159 | // { type: 'separator' },
160 | // { role: 'togglefullscreen' }
161 | // ]
162 | // }
163 | // ]
164 |
165 | // // @ts-ignore
166 | // Menu.setApplicationMenu(Menu.buildFromTemplate(template))
167 |
168 | let currentvMixData: string = ''
169 |
170 | function apiRoute(req: any, res: any) {
171 | if (currentvMixData === '') {
172 | return res.send({ message: 'No data...' })
173 | }
174 | // console.log('Theme', theme)
175 | return res.send(`[${currentvMixData}]`)
176 | }
177 |
178 | // Initialize webserver
179 | const { wss } = webserver(apiRoute)
180 |
181 | // Upon new ws connection - send current vMix data
182 | wss.on('connection', (ws: WebSocket) => {
183 | // Guard if current vMix data is empty...
184 | if (currentvMixData.length === 0) {
185 | return
186 | }
187 |
188 | ws.send(
189 | JSON.stringify({
190 | type: 'input',
191 | data: currentvMixData,
192 | })
193 | )
194 | })
195 |
196 | // Listen for vMix info data from renderer thread
197 | ipcMain.on('vMixInfo', (_event, data: any) => {
198 | currentvMixData = data
199 | // console.log('IPC Event', event)
200 | // console.log(data)
201 |
202 | // Broadcast to all open ws clients
203 | wss.clients.forEach(function each(client: WebSocket) {
204 | // Check if ws client is open
205 | if (client.readyState === WebSocket.OPEN) {
206 | client.send(
207 | JSON.stringify({
208 | type: 'input',
209 | data,
210 | })
211 | )
212 | }
213 | })
214 | })
215 |
--------------------------------------------------------------------------------
/src/components/HelloWorld.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Welcome to Vuetify
11 |
12 |
13 |
14 | For help and collaboration with other Vuetify developers,
15 |
please join our online
16 | Discord Community
17 |
18 |
19 |
20 |
21 |
22 | What's next?
23 |
24 |
25 |
26 |
33 | {{ next.text }}
34 |
35 |
36 |
37 |
38 |
39 |
40 | Important Links
41 |
42 |
43 |
44 |
51 | {{ link.text }}
52 |
53 |
54 |
55 |
56 |
57 |
58 | Ecosystem
59 |
60 |
61 |
62 |
69 | {{ eco.text }}
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
137 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import App from './App.vue'
3 | import store from './store'
4 | import vuetify from './plugins/vuetify'
5 |
6 | import { vMixConnectionPlugin, vMixConnectionPluginStore } from 'vue-vmix-conn-plugin'
7 |
8 | Vue.config.productionTip = false
9 |
10 | Vue.use(vMixConnectionPlugin, new vMixConnectionPluginStore())
11 |
12 | new Vue({
13 | store,
14 | vuetify,
15 | render: h => h(App)
16 | }).$mount('#app')
17 |
--------------------------------------------------------------------------------
/src/plugins/vuetify.ts:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Vuetify from 'vuetify'
3 |
4 | import 'vuetify/dist/vuetify.min.css'
5 | import '@fortawesome/fontawesome-free/css/all.css'
6 |
7 | Vue.use(Vuetify)
8 |
9 | export default new Vuetify({
10 | // Use Font Awesome icons
11 | icons: {
12 | iconfont: 'fa'
13 | },
14 |
15 | // Defaults to light theme
16 | theme: {
17 | dark: false
18 | }
19 | })
20 |
--------------------------------------------------------------------------------
/src/shims-tsx.d.ts:
--------------------------------------------------------------------------------
1 | import Vue, { VNode } from 'vue'
2 |
3 | declare global {
4 | namespace JSX {
5 | // tslint:disable no-empty-interface
6 | interface Element extends VNode {}
7 | // tslint:disable no-empty-interface
8 | interface ElementClass extends Vue {}
9 | interface IntrinsicElements {
10 | [elem: string]: any
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/shims-vue.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.vue' {
2 | import Vue from 'vue'
3 | export default Vue
4 | }
5 |
--------------------------------------------------------------------------------
/src/store/actions.ts:
--------------------------------------------------------------------------------
1 | //
2 | export default {
3 | setHost({ commit }: { commit: Function }, newHost: string) {
4 | commit('setHost', newHost)
5 | },
6 |
7 | addHostToPreviousConnectedVmixHosts({ state, commit }: { commit: Function; state: any }) {
8 | const host: string = state.vMixConnection.host
9 |
10 | if (state.previousVmixConnectionHosts.includes(host)) {
11 | return
12 | }
13 |
14 | commit('addHostToPreviousConnectedVmixHosts', state.vMixConnection.host)
15 | },
16 |
17 | setDelayCompensation({ commit }: { commit: Function }, newValue: number) {
18 | commit('setDelayCompensation', newValue)
19 | },
20 |
21 | toggleTitleMode({ commit }: { commit: Function }) {
22 | commit('toggleTitleMode')
23 | },
24 |
25 | saveTitleModeSettings({ commit }: { commit: Function }, data: { [key: string]: any }) {
26 | commit('saveTitleModeSettings', data)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Vuex from 'vuex'
3 |
4 | // @ts-ignore - this is necessary since VS Code cannot find declarations of the types of vuex-electron
5 | import { createPersistedState, createSharedMutations } from 'vuex-electron'
6 |
7 | Vue.use(Vuex)
8 |
9 | import state from './state'
10 | import actions from './actions'
11 | import mutations from './mutations'
12 |
13 | export default new Vuex.Store({
14 | state,
15 |
16 | actions,
17 |
18 | mutations,
19 |
20 | modules: {},
21 |
22 | plugins: [createPersistedState(), createSharedMutations()],
23 | strict: process.env.NODE_ENV !== 'production'
24 | })
25 |
--------------------------------------------------------------------------------
/src/store/mutations.ts:
--------------------------------------------------------------------------------
1 | import { TitleModeSettings } from '@/types/title-mode'
2 |
3 | export default {
4 | /**
5 | * Set vMix connection host
6 | * @param state
7 | * @param newHost
8 | */
9 | setHost(state: any, newHost: string) {
10 | state.vMixConnection.host = newHost
11 | },
12 |
13 | /**
14 | * Add host to previous vMix connection hosts
15 | * @param state
16 | * @param host
17 | */
18 | addHostToPreviousConnectedVmixHosts(state: any, host: string) {
19 | state.previousVmixConnectionHosts.push(host)
20 | },
21 |
22 | /**
23 | * Set delay compensation
24 | *
25 | * @param state
26 | * @param newValue
27 | */
28 | setDelayCompensation(state: any, newValue: number) {
29 | state.delayCompensation = newValue
30 | },
31 |
32 | /**
33 | * Toggle title mode on/off
34 | * @param state
35 | */
36 | toggleTitleMode(state: any) {
37 | state.titleMode.enabled = !state.titleMode.enabled
38 | },
39 |
40 | /**
41 | * Save title mode settings
42 | * @param state
43 | */
44 | saveTitleModeSettings(state: any, data: TitleModeSettings) {
45 | state.titleMode.enabled = data.enabled
46 | state.titleMode.sameInputForAllFields = data.sameInputForAllFields
47 |
48 | state.titleMode.fields.currentPosition = data.fields.currentPosition
49 | state.titleMode.fields.remaining = data.fields.remaining
50 | state.titleMode.fields.width = data.fields.width
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/store/state.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | vMixConnection: {
3 | host: '127.0.0.1',
4 | debug: false
5 | },
6 |
7 | previousVmixConnectionHosts: [],
8 |
9 | // Delay compensation
10 | // How many milliseconds shall the presentation act as it is before in time
11 | delayCompensation: 0,
12 |
13 | titleMode: {
14 | enabled: false,
15 | sameInputForAllFields: true,
16 | fields: {
17 | currentPosition: [],
18 | remaining: [],
19 | width: []
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/types/title-mode.ts:
--------------------------------------------------------------------------------
1 | // Title Mode settings type
2 | export type TitleModeSettings = {
3 | enabled: boolean
4 | sameInputForAllFields: boolean
5 | fields: {
6 | currentPosition: TitleModeCurrentPositionField[]
7 | remaining: TitleModeRemainingField[]
8 | width: TitleModeWidthField[]
9 | }
10 | }
11 |
12 | type TitleModeField = {
13 | inputKey: string
14 | selectedName: string
15 | }
16 |
17 | export type TitleModeCurrentPositionField = TitleModeField & { includeRemaining: boolean }
18 |
19 | export type TitleModeRemainingField = TitleModeField
20 |
21 | export type TitleModeWidthField = TitleModeField & { totalWidth: number }
22 |
--------------------------------------------------------------------------------
/src/types/vmix-input.ts:
--------------------------------------------------------------------------------
1 | export type VmixInput = {
2 | duration: number
3 | position: number
4 | }
5 |
--------------------------------------------------------------------------------
/src/utility/paths.ts:
--------------------------------------------------------------------------------
1 | import electron from 'electron'
2 | import path from 'path'
3 |
4 | const app = (electron.app || electron.remote.app)
5 |
6 | // https://www.electron.build/configuration/contents#extraresources
7 | const map: { [key: string]: string } = {
8 |
9 | darwin: path.resolve(
10 | // Mac Resources path (with library executables) is under Resources directory,
11 | // which is in same root as MacOS folder,
12 | // which is the directory with the main electron app executable
13 | app.getPath('exe'),
14 | '../../Resources'
15 | ),
16 |
17 | win32: path.resolve(
18 | // app.getPath('exe'),
19 | 'resources'
20 | ),
21 |
22 | linux: path.resolve(
23 | // app.getPath('exe'),
24 | 'resources'
25 | ),
26 | }
27 |
28 | /**
29 | *
30 | * @param {string} resource
31 | * @returns {string}
32 | */
33 | export function getResourcePath(resource: string): string {
34 | // If dev enviroment - use global executable
35 | if (process.env.NODE_ENV !== "production") {
36 | return path.resolve('src', resource)
37 | }
38 |
39 | return path.resolve(getResourcesDirectory(), resource)
40 | }
41 |
42 | export function getResourcesDirectory(): string {
43 | const platform: string = process.platform
44 |
45 | if (!(platform in map)) {
46 | throw new Error('Invalid platform')
47 | }
48 |
49 | return map[platform]
50 | }
51 |
52 | export default {
53 | getResourcePath
54 | }
--------------------------------------------------------------------------------
/src/utility/time.ts:
--------------------------------------------------------------------------------
1 | import { VmixInput } from '@/types/vmix-input'
2 |
3 | /**
4 | * Utility method: Duration nice
5 | *
6 | * Input is a number of a duration
7 | * Output will then be niceified duration
8 | * Examples:
9 | * 90 -> 1:30
10 | * 1000 -> 16:40
11 | *
12 | * @param {number} duration
13 | * @returns {string}
14 | */
15 | export function durationNice(duration: number): string {
16 | const minutes = Math.floor(duration / 60)
17 |
18 | const sec = duration % 60
19 | const seconds = `${sec < 10 ? '0' : ''}${sec}`
20 |
21 | return `${minutes}:${seconds}`
22 | }
23 |
24 | export function completeString(input: VmixInput, delayCompensation: number): string {
25 | return ''
26 | }
27 |
--------------------------------------------------------------------------------
/src/view-templates/overview.ts:
--------------------------------------------------------------------------------
1 | export default `
2 |
3 |
4 |
5 |
6 |
7 | vinproma – vMix Input Progress Monitor
8 |
9 |
10 |
11 |
12 |
13 |
View progress in HTML mode
14 |
18 |
22 |
23 |
24 |
25 |
26 | `
27 |
--------------------------------------------------------------------------------
/src/view-templates/progress.ts:
--------------------------------------------------------------------------------
1 | export default (type: string, theme: string = 'light') => {
2 | const parts = [
3 | `
4 |
5 |
6 |
7 |
8 |
9 | vinproma – vMix Input Progress Monitor
10 |
11 | `,
12 | // Inject CSS for dark theme if dark theme desired
13 | theme === 'dark' ? '' : '',
14 | `
15 |
16 |
17 |
18 |
21 |
22 |
23 |
24 |
25 |
26 | `
27 | ]
28 |
29 | return parts.join('')
30 | }
31 |
--------------------------------------------------------------------------------
/src/views/DelayCompensationSlider.vue:
--------------------------------------------------------------------------------
1 |
2 | div.pt-7.pr-2
3 | v-slider(
4 | v-model="delayCompensation"
5 | :min="range[0]"
6 | :max="range[1]"
7 | :step="step"
8 | :thumb-size="40"
9 | thumb-label="always"
10 | hide-details
11 | )
12 | template(v-slot:thumb-label="{ value }") {{ value }}ms
13 | template(v-slot:prepend)
14 | v-text-field(
15 | v-model="delayCompensation"
16 | hide-details
17 | single-line
18 | dense
19 | type="number"
20 | :step="step"
21 | :min="range[0]"
22 | :max="range[1]"
23 | style="width: 55px"
24 | ).mt-0.pt-0
25 | v-subheader: b Delay compensation
26 | v-tooltip(bottom)
27 | template(v-slot:activator="{ on }")
28 | v-btn(icon v-on="on" color="grey" style="cursor:help")
29 | v-icon(small) fa-question-circle
30 | span Do you experience the progress feeded back to be delayed? Use this setting to compensate for the delay in the communication.
31 |
32 |
33 |
34 |
52 |
--------------------------------------------------------------------------------
/src/views/HtmlViews.vue:
--------------------------------------------------------------------------------
1 |
2 | div
3 | h3 HTML view
4 | div View progress as a HTML page. Both linear and circular modes are available, and both light and dark themes.
5 | div: small Open web overview to view presentation options.
6 | v-btn(@click="openWeb(htmlOverviewAddress)").my-1
7 | small {{ htmlOverviewAddress }}
8 |
9 |
10 |
31 |
--------------------------------------------------------------------------------
/src/views/Layout/AppBar.vue:
--------------------------------------------------------------------------------
1 |
2 | v-app-bar(app color="primary" dark)
3 | div: b vMix Input Progress Monitor
4 | v-spacer
5 | v-combobox(
6 | v-model="host"
7 | label="vMix host"
8 | :items="previousHosts"
9 | @keyup.enter="changeHost"
10 | @blur="changeHost"
11 | :hide-selected="true"
12 | ).mt-8
13 | v-icon#connection-status.ml-2.mt-2(
14 | small
15 | :class="$vMixConnection.connected?'green--text':'red--text'"
16 | ) fa-circle
17 |
18 |
19 |
54 |
--------------------------------------------------------------------------------
/src/views/TitleModeSettings/CurrentPositionField.vue:
--------------------------------------------------------------------------------
1 |
2 | v-row
3 | // Remove icon
4 | v-btn(icon small @click="$emit('remove')").mt-3
5 | v-icon(small) fa-times
6 |
7 | // Select title for field
8 | v-col(v-show="!sameInputForAllFields").pt-0: v-select(
9 | dense
10 | v-model="configuredField.inputKey"
11 | label="Title"
12 | :items="titles"
13 | item-text="nice"
14 | item-value="key"
15 | )
16 |
17 | // Select text field in title
18 | v-col.pt-0: v-select(
19 | v-model="configuredField.selectedName"
20 | label="Text field"
21 | :items="fieldsAvailable()"
22 | item-text="nice"
23 | item-value="name"
24 | )
25 |
26 | v-checkbox(
27 | v-model="configuredField.includeRemaining"
28 | label="Include remaining"
29 | )
30 |
31 |
32 |
75 |
--------------------------------------------------------------------------------
/src/views/TitleModeSettings/CurrentPositionFields.vue:
--------------------------------------------------------------------------------
1 |
2 | div
3 | h4
4 | span Current Position ({{ configuredFields.length }})
5 | v-btn(
6 | v-show="!configuredFields.length || isDataValidToPersist"
7 | icon small
8 | @click="addField"
9 | )
10 | v-icon(small color="green") fa-plus
11 |
12 | div(v-show="!configuredFields.length"): small Click + above to add
13 |
14 | current-position-field(
15 | v-for="(configuredField, index) in configuredFields"
16 | :key="index"
17 | :titles="titles"
18 | :same-input-for-all-fields="sameInputForAllFields"
19 | :configured-field="configuredField"
20 | @remove="configuredFields.splice(index, 1)"
21 | )
22 |
23 |
24 |
50 |
--------------------------------------------------------------------------------
/src/views/TitleModeSettings/Index.vue:
--------------------------------------------------------------------------------
1 |
2 | div(style="margin-top: -30px")
3 | v-row
4 | v-col
5 | h3.pt-5
6 | span vMix Title mode
7 | v-tooltip(bottom)
8 | template(v-slot:activator="{ on }")
9 | v-btn(icon v-on="on" color="grey" style="cursor:help")
10 | v-icon(small) fa-question-circle
11 | span Feed progress back to a title input in vMix.
12 | | You can feed back to vMix as many fields you desired, both current position, remaining and width fields.
13 | | It is recommended to feed back to XAML titles, since it can present as a progress bar based on the width.
14 |
15 | v-col: v-checkbox(
16 | v-model="settings.enabled"
17 | label="Enabled"
18 | :disabled="!settings.enabled && !validFormData"
19 | )
20 |
21 | div
22 | div(v-if="!titles.length")
23 | // Show alert when there is no titles and the title mode is enabled
24 | v-alert(v-show="settings.enabled" type="warning" outlined)
25 | | No title inputs (GT or Xaml) found in vMix instance...
26 | div(v-else)
27 | v-row(style="margin-top:-20px")
28 | // Use same input for all fields
29 | v-col: v-checkbox(
30 | v-model="settings.sameInputForAllFields"
31 | label="Same input for all fields"
32 | )
33 |
34 | // If checked that same input should be used for all fields
35 | // Then show select
36 | v-col(v-show="settings.sameInputForAllFields")
37 | // Select title for all fields
38 | v-select(
39 | v-model="inputForAllFields"
40 | label="Title"
41 | :items="titles"
42 | item-text="nice"
43 | item-value="key"
44 | )
45 |
46 | v-divider
47 |
48 | v-alert(v-if="settings.sameInputForAllFields && !checkedInputForAllFields" type="warning")
49 | v-row
50 | div Select a title for all fields in the select dropdown right above
51 | v-spacer
52 | v-icon(small).mr-2 fa-arrow-up
53 |
54 | div.pt-3
55 | // Current position fields
56 | current-position-fields(
57 | :titles="titles"
58 | :same-input-for-all-fields="settings.sameInputForAllFields"
59 | :configured-fields="settings.fields.currentPosition"
60 | :is-data-valid-to-persist="validateCurrentPositionFields"
61 | )
62 |
63 | //- v-divider(vertical)
64 |
65 | // Remaining fields
66 | remaining-fields(
67 | :titles="titles"
68 | :same-input-for-all-fields="settings.sameInputForAllFields"
69 | :configured-fields="settings.fields.remaining"
70 | :is-data-valid-to-persist="validateRemainingFields"
71 | )
72 |
73 | //- v-divider(vertical)
74 |
75 | // Width fields
76 | width-fields(
77 | :titles="titles"
78 | :same-input-for-all-fields="settings.sameInputForAllFields"
79 | :configured-fields="settings.fields.width"
80 | :is-data-valid-to-persist="validateWidthFields"
81 | )
82 |
83 |
84 |
289 |
--------------------------------------------------------------------------------
/src/views/TitleModeSettings/RemainingField.vue:
--------------------------------------------------------------------------------
1 |
2 | v-row
3 | // Remove icon
4 | v-btn(icon small @click="$emit('remove')").mt-3
5 | v-icon(small) fa-times
6 |
7 | // Select title for field
8 | v-col(v-show="!sameInputForAllFields"): v-select(
9 | v-model="configuredField.inputKey"
10 | label="Title"
11 | dense
12 | :items="titles"
13 | item-text="nice"
14 | item-value="key"
15 | )
16 |
17 | // Select text field in title
18 | v-col: v-select(
19 | v-model="configuredField.selectedName"
20 | label="Text field"
21 | dense
22 | :items="fieldsAvailable()"
23 | item-text="nice"
24 | item-value="name"
25 | )
26 |
27 |
28 |
29 |
72 |
--------------------------------------------------------------------------------
/src/views/TitleModeSettings/RemainingFields.vue:
--------------------------------------------------------------------------------
1 |
2 | div
3 | h4
4 | span Remaining
5 | // Add button
6 | v-btn(
7 | v-show="!configuredFields.length || isDataValidToPersist"
8 | icon small
9 | @click="addField"
10 | )
11 | v-icon(small color="green") fa-plus
12 |
13 | div(v-show="!configuredFields.length"): small Click + above to add
14 |
15 | remaining-field(
16 | v-for="(configuredField, index) in configuredFields"
17 | :key="index"
18 | :titles="titles"
19 | :same-input-for-all-fields="sameInputForAllFields"
20 | :configured-field="configuredField"
21 | @remove="configuredFields.splice(index, 1)"
22 | )
23 |
24 |
25 |
51 |
--------------------------------------------------------------------------------
/src/views/TitleModeSettings/WidthField.vue:
--------------------------------------------------------------------------------
1 |
2 | v-row
3 | // Remove icon
4 | v-btn(icon small @click="$emit('remove')").mt-3
5 | v-icon(small) fa-times
6 |
7 | // Select title for field
8 | v-col(v-show="!sameInputForAllFields").pt-0: v-select(
9 | v-model="configuredField.inputKey"
10 | label="Title"
11 | dense
12 | :items="titles"
13 | item-text="nice"
14 | item-value="key"
15 | )
16 |
17 | // Select text field in title
18 | v-col: v-select(
19 | v-model="configuredField.selectedName"
20 | label="Text field"
21 | dense
22 | :items="fieldsAvailable()"
23 | item-text="nice"
24 | item-value="name"
25 | )
26 |
27 | v-text-field(
28 | v-model="configuredField.totalWidth"
29 | label="Total width"
30 | )
31 |
32 |
33 |
76 |
--------------------------------------------------------------------------------
/src/views/TitleModeSettings/WidthFields.vue:
--------------------------------------------------------------------------------
1 |
2 | div
3 | h4
4 | span Width ({{ configuredFields.length }})
5 | v-btn(
6 | v-show="!configuredFields.length || isDataValidToPersist"
7 | icon small
8 | @click="addField"
9 | )
10 | v-icon(small color="green") fa-plus
11 |
12 | div(v-show="!configuredFields.length"): small Click + above to add
13 |
14 | width-field(
15 | v-for="(configuredField, index) in configuredFields"
16 | :key="index"
17 | :titles="titles"
18 | :same-input-for-all-fields="sameInputForAllFields"
19 | :configured-field="configuredField"
20 | @remove="configuredFields.splice(index, 1)"
21 | )
22 |
23 |
24 |
50 |
--------------------------------------------------------------------------------
/src/web-frontend/README.md:
--------------------------------------------------------------------------------
1 | Compile web frontend assets by running
2 | ```npx mix```
3 |
4 | And for production
5 | ```npx mix --production```
--------------------------------------------------------------------------------
/src/web-frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vinproma-web-frontend",
3 | "version": "1.0.0",
4 | "private": true,
5 | "main": "index.js",
6 | "author": "Jens Stigaard",
7 | "license": "MIT",
8 | "dependencies": {
9 | "font-awesome": "^4.7.0",
10 | "moment": "^2.29.4",
11 | "pug": "^3.0.3",
12 | "tailwindcss": "^2.0.2",
13 | "vue": "^2.6.12",
14 | "vue-circle-counter": "^3.0.0",
15 | "vue-native-websocket": "^2.0.14",
16 | "vue-timeago": "^5.1.3"
17 | },
18 | "devDependencies": {
19 | "jsonfile": "^6.1",
20 | "laravel-mix": "^6",
21 | "mix-tailwindcss": "^1.3.0",
22 | "postcss": "^8.4.31",
23 | "pug-plain-loader": "^1.1.0",
24 | "resolve-url-loader": "^3.1.2",
25 | "vue-loader": "^15.9.6"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/web-frontend/src/Circular.vue:
--------------------------------------------------------------------------------
1 |
2 | .flex.flex-row.circular-progress-bars
3 | // Preview column
4 | div(class='w-1/2')
5 | .preview-inputs.text-center
6 | big Preview
7 | // Save as program statement
8 | div(v-if='!data.inPreview.length', key='preview-same-as-program', style='opacity: 0.5') Same as program
9 |
10 | transition-group.flex.flex-wrap.justify-items-center(name='fade', tag='div')
11 | .item.flex-grow(v-for='(preview, i) in data.inPreview', :key='`preview-${i}`')
12 | // Circular progress
13 | circle-counter(
14 | ref='circularPreview',
15 | size='280',
16 | :dash-count='durationSeconds(preview)',
17 | :active-count='positionSeconds(preview)',
18 | :text='counterText(preview)'
19 | )
20 | // Title
21 | .title {{ preview.title }}
22 | div(v-if='preview.state === "Paused"') Paused
23 | div(v-if='preview.loop === "True"') Looping
24 |
25 | // Program column
26 | div(class='w-1/2')
27 | .program-inputs.text-center
28 | big Program
29 | transition-group.flex.flex-wrap.justify-items-center(name='fade', tag='div')
30 | .item.flex-grow(v-for='(program, i) in data.inProgram', :key='`program-${i}`')
31 | // Circular progress
32 | circle-counter(
33 | v-if='hasDuration(program)',
34 | size='350',
35 | :dash-count='durationSeconds(program)',
36 | :active-count='positionSeconds(program)',
37 | :active-stroke='backgroundColor(program)',
38 | :text='counterText(program)'
39 | )
40 | // Title
41 | .title {{ program.title }}
42 | div(v-if='program.state === "Paused"') Paused
43 | div(v-if='program.loop === "True"') Looping
44 |
45 |
46 |
65 |
--------------------------------------------------------------------------------
/src/web-frontend/src/Linear.vue:
--------------------------------------------------------------------------------
1 |
2 | .linear-progress-bars
3 | .preview-inputs
4 | transition-group(name='fade', tag='div')
5 | .item(v-for='(preview, i) in data.inPreview' :key="`preview-${i}`")
6 | .position-bar(:style='positionStyle(preview)')
7 | .text-overlays
8 | div
9 | span {{ preview.title }}
10 | span(style='display: inline-block; padding: 0 10px') —
11 | span
12 | | {{ positionText(preview) }}
13 | | / {{ durationText(preview) }}
14 | | /
15 | span(:style='remainingWarning(preview) ? "font-weight:bold" : ""')
16 | | {{ remainingText(preview) }}
17 | .program-inputs
18 | transition-group(name='fade', tag='div')
19 | .item(v-for='(program, i) in data.inProgram' :key="`program-${i}`")
20 | .position-bar(:style='positionStyle(program)')
21 | .text-overlays
22 | .duration
23 | | {{ positionText(program) }}
24 | | / {{ durationText(program) }}
25 | | /
26 | span(:style='remainingWarning(program) ? "font-weight:bold" : ""') {{ remainingText(program) }}
27 | .title {{ program.title }}
28 |
29 |
30 |
41 |
--------------------------------------------------------------------------------
/src/web-frontend/src/Main.vue:
--------------------------------------------------------------------------------
1 |
2 | #app
3 | div(v-if='connectionLost', style='padding: 10px; text-align: center')
4 | | Connection lost... Try to refresh...
5 | .text-center.px-4.py-3(v-else-if='!data || !data.hasOwnProperty("inProgram")')
6 | | Connected to vMix instance - but no data received yet... Waiting in patience!
7 | div(v-else)
8 | component(v-bind:is='presentationType', :data='data')
9 |
10 |
11 |
96 |
--------------------------------------------------------------------------------
/src/web-frontend/src/app.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import VueNativeSock from 'vue-native-websocket'
3 |
4 | // Import App view
5 | import Main from './Main.vue'
6 |
7 | const host = location.host.split(':')[0]
8 | // Vue use native web socket client
9 | // To communicate with backend which fetches data from vMix instance
10 | Vue.use(VueNativeSock, `ws://${host}:8096/`, {
11 | reconnection: true, // (Boolean) whether to reconnect automatically (false)
12 | reconnectionAttempts: 5, // (Number) number of reconnection attempts before giving up (Infinity),
13 | reconnectionDelay: 3000, // (Number) how long to initially wait before attempting a new (1000)
14 | format: 'json',
15 | })
16 |
17 | const app = new Vue({
18 | render: (h) => h(Main),
19 | }).$mount('#app')
20 |
--------------------------------------------------------------------------------
/src/web-frontend/src/mixin.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 |
3 | // Helper to nicely present durations as readable text
4 | function durationNice(duration) {
5 | const minutes = Math.floor(duration / 60)
6 |
7 | const sec = duration % 60
8 | const seconds = `${sec < 10 ? '0' : ''}${sec}`
9 |
10 | return `${minutes}:${seconds}`
11 | }
12 |
13 | // Base Mixin
14 | export default Vue.extend({
15 | // Helpers
16 | methods: {
17 | roundedSeconds(ms) {
18 | return Math.floor(ms / 1000)
19 | },
20 |
21 | positionSeconds(input) {
22 | return this.roundedSeconds(input.position)
23 | },
24 |
25 | durationSeconds(input) {
26 | return this.roundedSeconds(input.duration)
27 | },
28 |
29 | hasDuration(input) {
30 | return input.hasOwnProperty('duration') && this.durationSeconds(input) > 0
31 | },
32 |
33 | positionText(input) {
34 | return durationNice(this.positionSeconds(input))
35 | },
36 |
37 | durationText(input) {
38 | return durationNice(this.durationSeconds(input))
39 | },
40 |
41 | remainingText(input) {
42 | const diffMs = input.duration - input.position
43 | return durationNice(Math.ceil(diffMs / 1000))
44 | },
45 |
46 | positionPercentage(input) {
47 | return Math.round((input.position / input.duration) * 100)
48 | },
49 |
50 | counterText(input) {
51 | if (!this.hasDuration(input)) {
52 | return `${input.type || '-'}`
53 | }
54 | // console.log(input)
55 |
56 | const pos = this.positionText(input)
57 | const dur = this.durationText(input)
58 | const remaining = this.remainingText(input)
59 |
60 | return `${pos} / ${dur} / ${remaining}`
61 | },
62 |
63 | remainingWarning(input) {
64 | if (input.loop === 'True') {
65 | return false
66 | }
67 |
68 | // Remaining time in milliseconds
69 | const remainingTime = input.duration - input.position
70 |
71 | // If longer than 120 seconds then threshold is 10 sec - otherwise 5 seconds
72 | const warningThreshold = this.durationSeconds(input) > 120 ? 10000 : 5000
73 |
74 | return remainingTime <= warningThreshold
75 | },
76 |
77 | isRunning(input) {
78 | return input.state === 'Running'
79 | },
80 |
81 | backgroundColor(input, isProgram = true) {
82 | // Is paused?
83 | if (!this.isRunning(input)) {
84 | // Show blue-ish color when paused
85 | return 'rgba(100, 150, 255, 0.6)'
86 | }
87 |
88 | return this.remainingWarning(input) // Below "warning" threshold?
89 | ? 'rgba(255, 100, 100, 0.8)' // Show red (warning color)
90 | : 'rgba(100, 200, 100, 0.8)' // Otherwise green
91 | },
92 |
93 | positionStyle(input) {
94 | const p = this.positionPercentage(input)
95 | const percentage = p < 2 ? 0 : p
96 |
97 | const background = this.backgroundColor(input)
98 |
99 | return {
100 | width: `${percentage}%`,
101 | background,
102 | }
103 | },
104 | },
105 | })
106 |
--------------------------------------------------------------------------------
/src/web-frontend/src/styles/app.sass:
--------------------------------------------------------------------------------
1 | // Fonts
2 | @import url("https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons|Raleway:300,400,600")
3 | @import url("https://use.fontawesome.com/releases/v5.1.0/css/all.css")
4 |
5 | // Tailwind
6 | @tailwind base
7 | @tailwind components
8 | @tailwind utilities
9 |
10 | // Variables
11 |
12 |
13 | html, body
14 | margin: 0
15 | padding: 0
16 |
17 | height: 100%
18 |
19 | font-family: Lato, Raleway, sans-serif
20 | font-weight: normal
21 | font-size: 14pt
22 |
23 | overflow: hidden
24 |
25 | a
26 | text-decoration: none
27 |
28 | #overview-links
29 | text-align: center
30 | margin-top: 20px
31 |
32 | > div
33 | a
34 | display: inline-block
35 | width: 40%
36 | height: 150px
37 |
38 | padding: 50px
39 |
40 | font-size: 30pt
41 | text-align: center
42 |
43 | border: 1px solid #CCC
44 | background: #EEE
45 |
46 | color: #333
47 |
48 | &:nth-child(2)
49 | background: #222
50 | color: #EEE
51 |
52 | #app
53 | @import "circular"
54 | @import "linear"
55 |
56 | .fade-enter-active, .fade-leave-active
57 | transition: opacity .3s
58 |
59 | .fade-enter, .fade-leave-to
60 | // /* .fade-leave-active below version 2.1.8 */ {
61 | opacity: 0
62 |
--------------------------------------------------------------------------------
/src/web-frontend/src/styles/circular.sass:
--------------------------------------------------------------------------------
1 |
2 | #circular-progress-bars
3 | margin: 10px 15px
4 | text-shadow: 2px 2px 4px rgba(0,0,0,0.5)
5 |
6 | .title
7 | font-size: 18pt
8 |
9 | svg
10 | margin: 0 auto
11 |
12 | // Shared for both program and preview linear bars
13 | .program-inputs, .preview-inputs
14 | .item
15 |
16 | &:not(:last-child)
17 | // border-bottom: 1px dotted #CCC
18 |
--------------------------------------------------------------------------------
/src/web-frontend/src/styles/dark.sass:
--------------------------------------------------------------------------------
1 | body
2 | color: white
--------------------------------------------------------------------------------
/src/web-frontend/src/styles/linear.sass:
--------------------------------------------------------------------------------
1 |
2 | #linear-progress-bars
3 | position: absolute
4 |
5 | bottom: 2%
6 | left: 2%
7 | right: 2%
8 |
9 | text-align: center
10 |
11 | .program-inputs .item
12 | height: 110px
13 |
14 | .text-overlays
15 | padding: 10px
16 |
17 | .preview-inputs .item
18 | height: 30px
19 | margin-bottom: 1px
20 |
21 | .text-overlays
22 | padding: 2px
23 |
24 | // Shared for both program and preview linear bars
25 | .program-inputs, .preview-inputs
26 | .item
27 | background: rgba(0, 0, 0, 0.2)
28 | position: relative
29 |
30 | .duration
31 | font-size: 30pt
32 |
33 | .text-overlays
34 | position: absolute
35 | height: 100%
36 | width: 100%
37 |
38 | z-index: 10
39 |
40 | text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.3)
41 |
42 | .title
43 | // padding x: 5px
44 |
45 | .position-bar
46 | position: absolute
47 | left: 0
48 | top: 0
49 | bottom: 0
50 | // Variable width
51 |
52 | z-index: 0
53 |
54 | transition: width ease 0.7s, background ease 0.3s
55 |
--------------------------------------------------------------------------------
/src/web-frontend/webpack.mix.js:
--------------------------------------------------------------------------------
1 | const mix = require('laravel-mix')
2 |
3 | require('mix-tailwindcss')
4 |
5 | /*
6 | |--------------------------------------------------------------------------
7 | | Mix Asset Management
8 | |--------------------------------------------------------------------------
9 | |
10 | | Mix provides a clean, fluent API for defining some Webpack build steps
11 | | for your Laravel application. By default, we are compiling the Sass
12 | | file for your application, as well as bundling up your JS files.
13 | |
14 | */
15 |
16 | mix.webpackConfig({
17 | module: {
18 | rules: [
19 | // https://vue-loader.vuejs.org/guide/pre-processors.html#pug
20 | {
21 | test: /\.pug$/,
22 | oneOf: [
23 | // this applies to `` in Vue components
24 | {
25 | resourceQuery: /^\?vue/,
26 | use: ['pug-plain-loader'],
27 | },
28 | // this applies to pug imports inside JavaScript
29 | {
30 | use: ['raw-loader', 'pug-plain-loader'],
31 | },
32 | ],
33 | },
34 | ],
35 | },
36 | })
37 |
38 | mix
39 | .setPublicPath('dist/public')
40 | .js('src/app.js', 'js')
41 | .sass('src/styles/app.sass', 'css')
42 | .sass('src/styles/dark.sass', 'css')
43 | .vue({ version: 2 })
44 | .tailwind()
45 |
46 | mix
47 | // Disable notifications
48 | .disableNotifications()
49 | // Add source maps
50 | .sourceMaps()
51 |
--------------------------------------------------------------------------------
/src/webserver.ts:
--------------------------------------------------------------------------------
1 | import paths from './utility/paths'
2 |
3 | // Express
4 | import express, { RequestHandler } from 'express'
5 | import cookieParser from 'cookie-parser'
6 | import bodyParser from 'body-parser'
7 |
8 | import WebSocket from 'ws'
9 |
10 | import overviewView from './view-templates/overview'
11 | import htmlView from './view-templates/progress'
12 |
13 | // Web server port
14 | const WEB_SERVER_PORT = 8095
15 | const WEB_SOCKET_SERVER_PORT = 8096
16 |
17 | /**
18 | * Init express web server and web socket server
19 | * including custom routes
20 | * @param routes
21 | */
22 | function init(
23 | apiRoute: RequestHandler | null
24 | ): { app: Express.Application; wss: WebSocket.Server } {
25 | const app = express()
26 | const server = require('http').createServer(app)
27 |
28 | // Web socket server
29 | // Configuration from basic configuration in documentation
30 | const wss = new WebSocket.Server({
31 | port: WEB_SOCKET_SERVER_PORT,
32 | perMessageDeflate: {
33 | zlibDeflateOptions: {
34 | // See zlib defaults.
35 | chunkSize: 1024,
36 | memLevel: 7,
37 | level: 3,
38 | },
39 | zlibInflateOptions: {
40 | chunkSize: 10 * 1024,
41 | },
42 | // Other options settable:
43 | clientNoContextTakeover: true, // Defaults to negotiated value.
44 | serverNoContextTakeover: true, // Defaults to negotiated value.
45 | serverMaxWindowBits: 10, // Defaults to negotiated value.
46 | // Below options specified as default values.
47 | concurrencyLimit: 10, // Limits zlib concurrency for perf.
48 | threshold: 1024, // Size (in bytes) below which messages
49 | // should not be compressed.
50 | },
51 | })
52 |
53 | // app.use(logger('dev'))
54 | app.use(bodyParser.json())
55 | app.use(bodyParser.urlencoded({ extended: false }))
56 | app.use(cookieParser())
57 |
58 | // Public files folder
59 | const staticFilesPath = paths.getResourcePath('web-frontend/dist/public')
60 | // console.log(staticFilesPath)
61 | app.use(express.static(staticFilesPath))
62 |
63 | // Web server - listen for HTTP request on port
64 | server.listen(WEB_SERVER_PORT, () => {
65 | // console.log('\n')
66 | // console.log('\t', 'Open http://localhost:' + WEB_SERVER_PORT + ' in a browser to connect')
67 | // console.log('\n')
68 | })
69 |
70 | app.get('/', (_req: any, res: any) => {
71 | return res.send(overviewView)
72 | })
73 |
74 | app.get('/linear', (req: any, res: any) => {
75 | const theme = req.query.theme && req.query.theme === 'dark' ? 'dark' : 'light'
76 | // console.log('Theme', theme)
77 | return res.send(htmlView('linear', theme))
78 | })
79 |
80 | app.get('/circular', (req: any, res: any) => {
81 | const theme = req.query.theme && req.query.theme === 'dark' ? 'dark' : 'light'
82 | return res.send(htmlView('circular', theme))
83 | })
84 |
85 | // Append api route if present
86 | if (apiRoute) {
87 | app.get('/api', apiRoute)
88 | }
89 |
90 | // catch 404 and forward to error handler
91 | app.use((req: any, res: any, next: any) => {
92 | const err = new Error(`Page Not Found - ${req.originalUrl}`)
93 |
94 | res.status(404)
95 | next(err)
96 | })
97 |
98 | // error handlers
99 |
100 | // development error handler
101 | // will print stacktrace
102 | if (app.get('env') === 'development') {
103 | app.use((err: any, _req: any, res: any) => {
104 | res.status(err.status || 500)
105 |
106 | res.render('error', {
107 | message: err.message,
108 | error: err,
109 | })
110 | })
111 | } else {
112 | // production error handler
113 | // no stacktraces leaked to user
114 | app.use((err: any, _req: any, res: any) => {
115 | res.status(err.status || 500)
116 |
117 | res.render('error', {
118 | message: err.message,
119 | error: {},
120 | })
121 | })
122 | }
123 |
124 | wss.on('connection', function connection(ws) {
125 | ws.on('message', function incoming(data) {
126 | // Ping request received
127 | if (data === 'ping') {
128 | // Return with sending a pong message
129 | ws.send('pong')
130 | }
131 | })
132 | })
133 |
134 | // Export express app and web sockets server to allow
135 | // to tap into listeners and to send/broadcast messages
136 | return {
137 | app,
138 | wss,
139 | }
140 | }
141 |
142 | export default init
143 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "esnext",
5 | "strict": true,
6 | "jsx": "preserve",
7 | "importHelpers": true,
8 | "moduleResolution": "node",
9 | "experimentalDecorators": true,
10 | "esModuleInterop": true,
11 | "allowSyntheticDefaultImports": true,
12 | "sourceMap": true,
13 | "baseUrl": ".",
14 | "types": [
15 | "webpack-env"
16 | ],
17 | "paths": {
18 | "@/*": [
19 | "src/*"
20 | ]
21 | },
22 | "lib": [
23 | "esnext",
24 | "dom",
25 | "dom.iterable",
26 | "scripthost"
27 | ]
28 | },
29 | "include": [
30 | "src/**/*.ts",
31 | "src/**/*.tsx",
32 | "src/**/*.vue",
33 | "tests/**/*.ts",
34 | "tests/**/*.tsx"
35 | ],
36 | "exclude": [
37 | "node_modules"
38 | ]
39 | }
--------------------------------------------------------------------------------
/vue.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | transpileDependencies: ['vuetify'],
3 | pluginOptions: {
4 | electronBuilder: {
5 | // https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/484#issuecomment-711085442
6 | nodeIntegration: true,
7 | },
8 | },
9 | }
10 |
--------------------------------------------------------------------------------