├── .babelrc ├── .electron-vue ├── build.js ├── dev-client.js ├── dev-runner.js ├── webpack.main.config.js ├── webpack.renderer.config.js └── webpack.web.config.js ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .travis.yml ├── LICENSE.md ├── README.md ├── appveyor.yml ├── build └── icons │ ├── 256x256.png │ ├── icon.icns │ └── icon.ico ├── images └── folderplayout.PNG ├── package.json ├── src ├── index.ejs ├── main │ ├── api.js │ ├── index.dev.js │ ├── index.js │ ├── media.js │ └── playout.js └── renderer │ ├── App.vue │ ├── assets │ ├── .gitkeep │ └── logo.png │ ├── components │ ├── DashBoard.vue │ ├── DashBoard │ │ ├── ProgressBar.vue │ │ ├── StatusText.vue │ │ └── TimingComponent.vue │ ├── EditSchedule.vue │ ├── Schedule.vue │ └── Settings.vue │ ├── main.js │ ├── router │ └── index.js │ └── store │ ├── index.js │ └── storeState.js ├── static ├── .gitkeep └── fatal.html └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "comments": false, 3 | "env": { 4 | "main": { 5 | "presets": [ 6 | [ 7 | "@babel/preset-env", 8 | { 9 | "targets": { 10 | "node": 12.14 11 | } 12 | } 13 | ] 14 | ] 15 | }, 16 | "renderer": { 17 | "presets": [ 18 | [ 19 | "@babel/preset-env", 20 | { 21 | "modules": false, 22 | "targets": { 23 | "electron": 9 24 | } 25 | } 26 | ] 27 | ] 28 | }, 29 | "web": { 30 | "presets": [ 31 | [ 32 | "@babel/preset-env", 33 | { 34 | "modules": false, 35 | "targets": { 36 | "electron": 9 37 | } 38 | } 39 | ] 40 | ] 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.electron-vue/build.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | process.env.NODE_ENV = 'production' 4 | 5 | const { say } = require('cfonts') 6 | const chalk = require('chalk') 7 | const del = require('del') 8 | const { spawn } = require('child_process') 9 | const webpack = require('webpack') 10 | const Multispinner = require('multispinner') 11 | 12 | 13 | const mainConfig = require('./webpack.main.config') 14 | const rendererConfig = require('./webpack.renderer.config') 15 | const webConfig = require('./webpack.web.config') 16 | 17 | const doneLog = chalk.bgGreen.white(' DONE ') + ' ' 18 | const errorLog = chalk.bgRed.white(' ERROR ') + ' ' 19 | const okayLog = chalk.bgBlue.white(' OKAY ') + ' ' 20 | const isCI = process.env.CI || false 21 | 22 | if (process.env.BUILD_TARGET === 'clean') clean() 23 | else if (process.env.BUILD_TARGET === 'web') web() 24 | else build() 25 | 26 | function clean () { 27 | del.sync(['build/*', '!build/icons', '!build/icons/icon.*']) 28 | console.log(`\n${doneLog}\n`) 29 | process.exit() 30 | } 31 | 32 | function build () { 33 | greeting() 34 | 35 | del.sync(['dist/electron/*', '!.gitkeep']) 36 | 37 | const tasks = ['main', 'renderer'] 38 | const m = new Multispinner(tasks, { 39 | preText: 'building', 40 | postText: 'process' 41 | }) 42 | 43 | let results = '' 44 | 45 | m.on('success', () => { 46 | process.stdout.write('\x1B[2J\x1B[0f') 47 | console.log(`\n\n${results}`) 48 | console.log(`${okayLog}take it away ${chalk.yellow('`electron-builder`')}\n`) 49 | process.exit() 50 | }) 51 | 52 | pack(mainConfig).then(result => { 53 | results += result + '\n\n' 54 | m.success('main') 55 | }).catch(err => { 56 | m.error('main') 57 | console.log(`\n ${errorLog}failed to build main process`) 58 | console.error(`\n${err}\n`) 59 | process.exit(1) 60 | }) 61 | 62 | pack(rendererConfig).then(result => { 63 | results += result + '\n\n' 64 | m.success('renderer') 65 | }).catch(err => { 66 | m.error('renderer') 67 | console.log(`\n ${errorLog}failed to build renderer process`) 68 | console.error(`\n${err}\n`) 69 | process.exit(1) 70 | }) 71 | } 72 | 73 | function pack (config) { 74 | return new Promise((resolve, reject) => { 75 | config.mode = 'production' 76 | webpack(config, (err, stats) => { 77 | if (err) reject(err.stack || err) 78 | else if (stats.hasErrors()) { 79 | let err = '' 80 | 81 | stats.toString({ 82 | chunks: false, 83 | colors: true 84 | }) 85 | .split(/\r?\n/) 86 | .forEach(line => { 87 | err += ` ${line}\n` 88 | }) 89 | 90 | reject(err) 91 | } else { 92 | resolve(stats.toString({ 93 | chunks: false, 94 | colors: true 95 | })) 96 | } 97 | }) 98 | }) 99 | } 100 | 101 | function web () { 102 | del.sync(['dist/web/*', '!.gitkeep']) 103 | webConfig.mode = 'production' 104 | webpack(webConfig, (err, stats) => { 105 | if (err || stats.hasErrors()) console.log(err) 106 | 107 | console.log(stats.toString({ 108 | chunks: false, 109 | colors: true 110 | })) 111 | 112 | process.exit() 113 | }) 114 | } 115 | 116 | function greeting () { 117 | const cols = process.stdout.columns 118 | let text = '' 119 | 120 | if (cols > 85) text = 'lets-build' 121 | else if (cols > 60) text = 'lets-|build' 122 | else text = false 123 | 124 | if (text && !isCI) { 125 | say(text, { 126 | colors: ['yellow'], 127 | font: 'simple3d', 128 | space: false 129 | }) 130 | } else console.log(chalk.yellow.bold('\n lets-build')) 131 | console.log() 132 | } -------------------------------------------------------------------------------- /.electron-vue/dev-client.js: -------------------------------------------------------------------------------- 1 | const hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true') 2 | 3 | hotClient.subscribe(event => { 4 | /** 5 | * Reload browser when HTMLWebpackPlugin emits a new index.html 6 | * 7 | * Currently disabled until jantimon/html-webpack-plugin#680 is resolved. 8 | * https://github.com/SimulatedGREG/electron-vue/issues/437 9 | * https://github.com/jantimon/html-webpack-plugin/issues/680 10 | */ 11 | // if (event.action === 'reload') { 12 | // window.location.reload() 13 | // } 14 | 15 | /** 16 | * Notify `mainWindow` when `main` process is compiling, 17 | * giving notice for an expected reload of the `electron` process 18 | */ 19 | if (event.action === 'compiling') { 20 | document.body.innerHTML += ` 21 | 34 | 35 |
36 | Compiling Main Process... 37 |
38 | ` 39 | } 40 | }) 41 | -------------------------------------------------------------------------------- /.electron-vue/dev-runner.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const chalk = require('chalk') 4 | const electron = require('electron') 5 | const path = require('path') 6 | const { say } = require('cfonts') 7 | const { spawn } = require('child_process') 8 | const webpack = require('webpack') 9 | const WebpackDevServer = require('webpack-dev-server') 10 | const webpackHotMiddleware = require('webpack-hot-middleware') 11 | const HtmlWebpackPlugin = require('html-webpack-plugin') 12 | 13 | const mainConfig = require('./webpack.main.config') 14 | const rendererConfig = require('./webpack.renderer.config') 15 | 16 | let electronProcess = null 17 | let manualRestart = false 18 | let hotMiddleware 19 | 20 | function logStats (proc, data) { 21 | let log = '' 22 | 23 | log += chalk.yellow.bold(`┏ ${proc} Process ${new Array((19 - proc.length) + 1).join('-')}`) 24 | log += '\n\n' 25 | 26 | if (typeof data === 'object') { 27 | data.toString({ 28 | colors: true, 29 | chunks: false 30 | }).split(/\r?\n/).forEach(line => { 31 | log += ' ' + line + '\n' 32 | }) 33 | } else { 34 | log += ` ${data}\n` 35 | } 36 | 37 | log += '\n' + chalk.yellow.bold(`┗ ${new Array(28 + 1).join('-')}`) + '\n' 38 | 39 | console.log(log) 40 | } 41 | 42 | function startRenderer () { 43 | return new Promise((resolve, reject) => { 44 | rendererConfig.entry.renderer = [path.join(__dirname, 'dev-client')].concat(rendererConfig.entry.renderer) 45 | rendererConfig.mode = 'development' 46 | const compiler = webpack(rendererConfig) 47 | hotMiddleware = webpackHotMiddleware(compiler, { 48 | log: false, 49 | heartbeat: 2500 50 | }) 51 | 52 | compiler.hooks.compilation.tap('compilation', compilation => { 53 | HtmlWebpackPlugin.getHooks(compilation).afterEmit.tapAsync('html-webpack-plugin-after-emit', (data, cb) => { 54 | hotMiddleware.publish({ action: 'reload' }) 55 | cb() 56 | }) 57 | }) 58 | 59 | compiler.hooks.done.tap('done', stats => { 60 | logStats('Renderer', stats) 61 | }) 62 | 63 | const server = new WebpackDevServer( 64 | compiler, 65 | { 66 | contentBase: path.join(__dirname, '../'), 67 | quiet: true, 68 | before (app, ctx) { 69 | app.use(hotMiddleware) 70 | ctx.middleware.waitUntilValid(() => { 71 | resolve() 72 | }) 73 | } 74 | } 75 | ) 76 | 77 | server.listen(9080) 78 | }) 79 | } 80 | 81 | function startMain () { 82 | return new Promise((resolve, reject) => { 83 | mainConfig.entry.main = [path.join(__dirname, '../src/main/index.dev.js')].concat(mainConfig.entry.main) 84 | mainConfig.mode = 'development' 85 | const compiler = webpack(mainConfig) 86 | 87 | compiler.hooks.watchRun.tapAsync('watch-run', (compilation, done) => { 88 | logStats('Main', chalk.white.bold('compiling...')) 89 | hotMiddleware.publish({ action: 'compiling' }) 90 | done() 91 | }) 92 | 93 | compiler.watch({}, (err, stats) => { 94 | if (err) { 95 | console.log(err) 96 | return 97 | } 98 | 99 | logStats('Main', stats) 100 | 101 | if (electronProcess && electronProcess.kill) { 102 | manualRestart = true 103 | process.kill(electronProcess.pid) 104 | electronProcess = null 105 | startElectron() 106 | 107 | setTimeout(() => { 108 | manualRestart = false 109 | }, 5000) 110 | } 111 | 112 | resolve() 113 | }) 114 | }) 115 | } 116 | 117 | function startElectron () { 118 | var args = [ 119 | '--inspect=5858', 120 | path.join(__dirname, '../dist/electron/main.js') 121 | ] 122 | 123 | // detect yarn or npm and process commandline args accordingly 124 | if (process.env.npm_execpath.endsWith('yarn.js')) { 125 | args = args.concat(process.argv.slice(3)) 126 | } else if (process.env.npm_execpath.endsWith('npm-cli.js')) { 127 | args = args.concat(process.argv.slice(2)) 128 | } 129 | 130 | electronProcess = spawn(electron, args) 131 | 132 | electronProcess.stdout.on('data', data => { 133 | electronLog(data, 'blue') 134 | }) 135 | electronProcess.stderr.on('data', data => { 136 | electronLog(data, 'red') 137 | }) 138 | 139 | electronProcess.on('close', () => { 140 | if (!manualRestart) process.exit() 141 | }) 142 | } 143 | 144 | function electronLog (data, color) { 145 | let log = '' 146 | data = data.toString().split(/\r?\n/) 147 | data.forEach(line => { 148 | log += ` ${line}\n` 149 | }) 150 | if (/[0-9A-z]+/.test(log)) { 151 | console.log( 152 | chalk[color].bold('┏ Electron -------------------') + 153 | '\n\n' + 154 | log + 155 | chalk[color].bold('┗ ----------------------------') + 156 | '\n' 157 | ) 158 | } 159 | } 160 | 161 | function greeting () { 162 | const cols = process.stdout.columns 163 | let text = '' 164 | 165 | if (cols > 104) text = 'electron-vue' 166 | else if (cols > 76) text = 'electron-|vue' 167 | else text = false 168 | 169 | if (text) { 170 | say(text, { 171 | colors: ['yellow'], 172 | font: 'simple3d', 173 | space: false 174 | }) 175 | } else console.log(chalk.yellow.bold('\n electron-vue')) 176 | console.log(chalk.blue(' getting ready...') + '\n') 177 | } 178 | 179 | function init () { 180 | greeting() 181 | 182 | Promise.all([startRenderer(), startMain()]) 183 | .then(() => { 184 | startElectron() 185 | }) 186 | .catch(err => { 187 | console.error(err) 188 | }) 189 | } 190 | 191 | init() 192 | -------------------------------------------------------------------------------- /.electron-vue/webpack.main.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | process.env.BABEL_ENV = 'main' 4 | 5 | const path = require('path') 6 | const { dependencies } = require('../package.json') 7 | const webpack = require('webpack') 8 | 9 | const MinifyPlugin = require("babel-minify-webpack-plugin") 10 | 11 | let mainConfig = { 12 | entry: { 13 | main: path.join(__dirname, '../src/main/index.js') 14 | }, 15 | externals: [ 16 | ...Object.keys(dependencies || {}) 17 | ], 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.(js)$/, 22 | enforce: 'pre', 23 | exclude: /node_modules/, 24 | use: { 25 | loader: 'eslint-loader', 26 | options: { 27 | // formatter: require('eslint-friendly-formatter') 28 | } 29 | } 30 | }, 31 | { 32 | test: /\.js$/, 33 | use: 'babel-loader', 34 | exclude: /node_modules/ 35 | }, 36 | { 37 | test: /\.node$/, 38 | use: 'node-loader' 39 | } 40 | ] 41 | }, 42 | node: { 43 | __dirname: process.env.NODE_ENV !== 'production', 44 | __filename: process.env.NODE_ENV !== 'production' 45 | }, 46 | output: { 47 | filename: '[name].js', 48 | libraryTarget: 'commonjs2', 49 | path: path.join(__dirname, '../dist/electron') 50 | }, 51 | plugins: [ 52 | new webpack.NoEmitOnErrorsPlugin() 53 | ], 54 | resolve: { 55 | extensions: ['.js', '.json', '.node'] 56 | }, 57 | target: 'electron-main' 58 | } 59 | 60 | /** 61 | * Adjust mainConfig for development settings 62 | */ 63 | if (process.env.NODE_ENV !== 'production') { 64 | mainConfig.plugins.push( 65 | new webpack.DefinePlugin({ 66 | '__static': `"${path.join(__dirname, '../static').replace(/\\/g, '\\\\')}"` 67 | }) 68 | ) 69 | } 70 | 71 | /** 72 | * Adjust mainConfig for production settings 73 | */ 74 | if (process.env.NODE_ENV === 'production') { 75 | mainConfig.plugins.push( 76 | new MinifyPlugin(), 77 | new webpack.DefinePlugin({ 78 | 'process.env.NODE_ENV': '"production"' 79 | }) 80 | ) 81 | } 82 | 83 | module.exports = mainConfig 84 | -------------------------------------------------------------------------------- /.electron-vue/webpack.renderer.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | process.env.BABEL_ENV = 'renderer' 4 | 5 | const path = require('path') 6 | const { dependencies } = require('../package.json') 7 | const webpack = require('webpack') 8 | 9 | // const MinifyPlugin = require("babel-minify-webpack-plugin") 10 | const CopyWebpackPlugin = require('copy-webpack-plugin') 11 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 12 | const HtmlWebpackPlugin = require('html-webpack-plugin') 13 | const { VueLoaderPlugin } = require('vue-loader') 14 | 15 | /** 16 | * List of node_modules to include in webpack bundle 17 | * 18 | * Required for specific packages like Vue UI libraries 19 | * that provide pure *.vue files that need compiling 20 | * https://simulatedgreg.gitbooks.io/electron-vue/content/en/webpack-configurations.html#white-listing-externals 21 | */ 22 | let whiteListedModules = ['vue', 'bootstrap-vue'] 23 | 24 | let rendererConfig = { 25 | devtool: '#cheap-module-eval-source-map', 26 | entry: { 27 | renderer: path.join(__dirname, '../src/renderer/main.js') 28 | }, 29 | externals: [ 30 | ...Object.keys(dependencies || {}).filter(d => !whiteListedModules.includes(d)) 31 | ], 32 | module: { 33 | rules: [ 34 | { 35 | test: /\.(js|vue)$/, 36 | enforce: 'pre', 37 | exclude: /node_modules/, 38 | use: { 39 | loader: 'eslint-loader', 40 | options: { 41 | // formatter: require('eslint-friendly-formatter') 42 | } 43 | } 44 | }, 45 | { 46 | test: /\.scss$/, 47 | use: ['vue-style-loader', 'css-loader', 'sass-loader'] 48 | }, 49 | { 50 | test: /\.sass$/, 51 | use: ['vue-style-loader', 'css-loader', 'sass-loader?indentedSyntax'] 52 | }, 53 | { 54 | test: /\.less$/, 55 | use: ['vue-style-loader', 'css-loader', 'less-loader'] 56 | }, 57 | { 58 | test: /\.css$/, 59 | use: ['vue-style-loader', 'css-loader'] 60 | }, 61 | { 62 | test: /\.html$/, 63 | use: 'vue-html-loader' 64 | }, 65 | { 66 | test: /\.js$/, 67 | use: 'babel-loader', 68 | exclude: /node_modules/ 69 | }, 70 | { 71 | test: /\.node$/, 72 | use: 'node-loader' 73 | }, 74 | { 75 | test: /\.vue$/, 76 | use: { 77 | loader: 'vue-loader', 78 | options: { 79 | extractCSS: process.env.NODE_ENV === 'production', 80 | loaders: { 81 | sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1', 82 | scss: 'vue-style-loader!css-loader!sass-loader', 83 | less: 'vue-style-loader!css-loader!less-loader' 84 | } 85 | } 86 | } 87 | }, 88 | { 89 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 90 | use: { 91 | loader: 'url-loader', 92 | query: { 93 | limit: 10000, 94 | name: 'imgs/[name]--[folder].[ext]' 95 | } 96 | } 97 | }, 98 | { 99 | test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, 100 | loader: 'url-loader', 101 | options: { 102 | limit: 10000, 103 | name: 'media/[name]--[folder].[ext]' 104 | } 105 | }, 106 | { 107 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 108 | use: { 109 | loader: 'url-loader', 110 | query: { 111 | limit: 10000, 112 | name: 'fonts/[name]--[folder].[ext]' 113 | } 114 | } 115 | } 116 | ] 117 | }, 118 | node: { 119 | __dirname: process.env.NODE_ENV !== 'production', 120 | __filename: process.env.NODE_ENV !== 'production' 121 | }, 122 | plugins: [ 123 | new VueLoaderPlugin(), 124 | new MiniCssExtractPlugin({filename: 'styles.css'}), 125 | new HtmlWebpackPlugin({ 126 | filename: 'index.html', 127 | template: path.resolve(__dirname, '../src/index.ejs'), 128 | minify: { 129 | collapseWhitespace: true, 130 | removeAttributeQuotes: true, 131 | removeComments: true 132 | }, 133 | nodeModules: process.env.NODE_ENV !== 'production' 134 | ? path.resolve(__dirname, '../node_modules') 135 | : false 136 | }), 137 | new webpack.HotModuleReplacementPlugin(), 138 | new webpack.NoEmitOnErrorsPlugin() 139 | ], 140 | output: { 141 | filename: '[name].js', 142 | libraryTarget: 'commonjs2', 143 | path: path.join(__dirname, '../dist/electron') 144 | }, 145 | resolve: { 146 | alias: { 147 | '@': path.join(__dirname, '../src/renderer'), 148 | 'vue$': 'vue/dist/vue.esm.js' 149 | }, 150 | extensions: ['.js', '.vue', '.json', '.css', '.node'] 151 | }, 152 | target: 'electron-renderer' 153 | } 154 | 155 | /** 156 | * Adjust rendererConfig for development settings 157 | */ 158 | if (process.env.NODE_ENV !== 'production') { 159 | rendererConfig.plugins.push( 160 | new webpack.DefinePlugin({ 161 | '__static': `"${path.join(__dirname, '../static').replace(/\\/g, '\\\\')}"` 162 | }) 163 | ) 164 | } 165 | 166 | /** 167 | * Adjust rendererConfig for production settings 168 | */ 169 | if (process.env.NODE_ENV === 'production') { 170 | rendererConfig.devtool = '' 171 | 172 | rendererConfig.plugins.push( 173 | // new MinifyPlugin(), 174 | new CopyWebpackPlugin({ 175 | patterns: [ 176 | { 177 | from: path.join(__dirname, '../static'), 178 | to: path.join(__dirname, '../dist/electron/static'), 179 | globOptions: { 180 | ignore: ['.*'] 181 | } 182 | } 183 | ] 184 | }), 185 | new webpack.DefinePlugin({ 186 | 'process.env.NODE_ENV': '"production"' 187 | }), 188 | new webpack.LoaderOptionsPlugin({ 189 | minimize: true 190 | }) 191 | ) 192 | } 193 | 194 | module.exports = rendererConfig 195 | -------------------------------------------------------------------------------- /.electron-vue/webpack.web.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | process.env.BABEL_ENV = 'web' 4 | 5 | const path = require('path') 6 | const webpack = require('webpack') 7 | 8 | const MinifyPlugin = require("babel-minify-webpack-plugin") 9 | const CopyWebpackPlugin = require('copy-webpack-plugin') 10 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 11 | const HtmlWebpackPlugin = require('html-webpack-plugin') 12 | const { VueLoaderPlugin } = require('vue-loader') 13 | 14 | let webConfig = { 15 | devtool: '#cheap-module-eval-source-map', 16 | entry: { 17 | web: path.join(__dirname, '../src/renderer/main.js') 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.(js|vue)$/, 23 | enforce: 'pre', 24 | exclude: /node_modules/, 25 | use: { 26 | loader: 'eslint-loader', 27 | options: { 28 | // formatter: require('eslint-friendly-formatter') 29 | } 30 | } 31 | }, 32 | { 33 | test: /\.scss$/, 34 | use: ['vue-style-loader', 'css-loader', 'sass-loader'] 35 | }, 36 | { 37 | test: /\.sass$/, 38 | use: ['vue-style-loader', 'css-loader', 'sass-loader?indentedSyntax'] 39 | }, 40 | { 41 | test: /\.less$/, 42 | use: ['vue-style-loader', 'css-loader', 'less-loader'] 43 | }, 44 | { 45 | test: /\.css$/, 46 | use: ['vue-style-loader', 'css-loader'] 47 | }, 48 | { 49 | test: /\.html$/, 50 | use: 'vue-html-loader' 51 | }, 52 | { 53 | test: /\.js$/, 54 | use: 'babel-loader', 55 | include: [ path.resolve(__dirname, '../src/renderer') ], 56 | exclude: /node_modules/ 57 | }, 58 | { 59 | test: /\.vue$/, 60 | use: { 61 | loader: 'vue-loader', 62 | options: { 63 | extractCSS: true, 64 | loaders: { 65 | sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1', 66 | scss: 'vue-style-loader!css-loader!sass-loader', 67 | less: 'vue-style-loader!css-loader!less-loader' 68 | } 69 | } 70 | } 71 | }, 72 | { 73 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 74 | use: { 75 | loader: 'url-loader', 76 | query: { 77 | limit: 10000, 78 | name: 'imgs/[name].[ext]' 79 | } 80 | } 81 | }, 82 | { 83 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 84 | use: { 85 | loader: 'url-loader', 86 | query: { 87 | limit: 10000, 88 | name: 'fonts/[name].[ext]' 89 | } 90 | } 91 | } 92 | ] 93 | }, 94 | plugins: [ 95 | new VueLoaderPlugin(), 96 | new MiniCssExtractPlugin({filename: 'styles.css'}), 97 | new HtmlWebpackPlugin({ 98 | filename: 'index.html', 99 | template: path.resolve(__dirname, '../src/index.ejs'), 100 | minify: { 101 | collapseWhitespace: true, 102 | removeAttributeQuotes: true, 103 | removeComments: true 104 | }, 105 | nodeModules: false 106 | }), 107 | new webpack.DefinePlugin({ 108 | 'process.env.IS_WEB': 'true' 109 | }), 110 | new webpack.HotModuleReplacementPlugin(), 111 | new webpack.NoEmitOnErrorsPlugin() 112 | ], 113 | output: { 114 | filename: '[name].js', 115 | path: path.join(__dirname, '../dist/web') 116 | }, 117 | resolve: { 118 | alias: { 119 | '@': path.join(__dirname, '../src/renderer'), 120 | 'vue$': 'vue/dist/vue.esm.js' 121 | }, 122 | extensions: ['.js', '.vue', '.json', '.css'] 123 | }, 124 | target: 'web' 125 | } 126 | 127 | /** 128 | * Adjust webConfig for production settings 129 | */ 130 | if (process.env.NODE_ENV === 'production') { 131 | webConfig.devtool = '' 132 | 133 | webConfig.plugins.push( 134 | new MinifyPlugin(), 135 | new CopyWebpackPlugin({ 136 | patterns: [ 137 | { 138 | from: path.join(__dirname, '../static'), 139 | to: path.join(__dirname, '../dist/web/static'), 140 | globOptions: { 141 | ignore: ['.*'] 142 | } 143 | } 144 | ] 145 | }), 146 | new webpack.DefinePlugin({ 147 | 'process.env.NODE_ENV': '"production"' 148 | }), 149 | new webpack.LoaderOptionsPlugin({ 150 | minimize: true 151 | }) 152 | ) 153 | } 154 | 155 | module.exports = webConfig 156 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | test/unit/coverage/** 2 | test/unit/*.js 3 | test/e2e/*.js 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:node/recommended", 5 | "plugin:vue/essential", 6 | "plugin:prettier/recommended" 7 | ], 8 | "plugins": [ 9 | "prettier" 10 | ], 11 | "parser": "vue-eslint-parser", 12 | "parserOptions": { 13 | "sourceType": "module" 14 | }, 15 | "settings": { 16 | "node": { 17 | "tryExtensions": [ 18 | ".js", 19 | ".json", 20 | ".node", 21 | ".vue" 22 | ] 23 | }, 24 | "import/resolver": { 25 | "alias": [ 26 | "@", 27 | "./src/renderer" 28 | ] 29 | } 30 | }, 31 | "rules": { 32 | "no-unused-vars": "off", 33 | "no-extra-semi": "off", 34 | "prettier/prettier": "error", 35 | "node/no-unsupported-features/es-syntax": [ 36 | "error", 37 | { 38 | "ignores": [ 39 | "modules" 40 | ] 41 | } 42 | ], 43 | "node/no-unpublished-import": [ 44 | "error", 45 | { 46 | "allowModules": [ 47 | "electron" 48 | ] 49 | } 50 | ], 51 | "node/no-unpublished-require": [ 52 | "error", 53 | { 54 | "allowModules": [ 55 | "electron" 56 | ] 57 | } 58 | ], 59 | "node/no-extraneous-import": "off" 60 | } 61 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist 3 | dist_electron 4 | build/* 5 | !build/icons 6 | coverage 7 | node_modules/ 8 | npm-debug.log 9 | npm-debug.log.* 10 | yarn-error.log 11 | thumbs.db 12 | !.gitkeep 13 | logs/ 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": false, 4 | "singleQuote": true 5 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Commented sections below can be used to run tests on the CI server 2 | # https://simulatedgreg.gitbooks.io/electron-vue/content/en/testing.html#on-the-subject-of-ci-testing 3 | osx_image: xcode8.3 4 | sudo: required 5 | dist: trusty 6 | language: c 7 | matrix: 8 | include: 9 | - os: osx 10 | - os: linux 11 | env: CC=clang CXX=clang++ npm_config_clang=1 12 | compiler: clang 13 | cache: 14 | directories: 15 | - node_modules 16 | - "$HOME/.electron" 17 | - "$HOME/.cache" 18 | addons: 19 | apt: 20 | packages: 21 | - libgnome-keyring-dev 22 | - icnsutils 23 | #- xvfb 24 | before_install: 25 | - mkdir -p /tmp/git-lfs && curl -L https://github.com/github/git-lfs/releases/download/v1.2.1/git-lfs-$([ 26 | "$TRAVIS_OS_NAME" == "linux" ] && echo "linux" || echo "darwin")-amd64-1.2.1.tar.gz 27 | | tar -xz -C /tmp/git-lfs --strip-components 1 && /tmp/git-lfs/git-lfs pull 28 | - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then sudo apt-get install --no-install-recommends -y icnsutils graphicsmagick xz-utils; fi 29 | install: 30 | #- export DISPLAY=':99.0' 31 | #- Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & 32 | - nvm install 7 33 | - curl -o- -L https://yarnpkg.com/install.sh | bash 34 | - source ~/.bashrc 35 | - npm install -g xvfb-maybe 36 | - yarn 37 | script: 38 | #- xvfb-maybe node_modules/.bin/karma start test/unit/karma.conf.js 39 | #- yarn run pack && xvfb-maybe node_modules/.bin/mocha test/e2e 40 | - yarn run build 41 | branches: 42 | only: 43 | - master 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # folderplayout 2 | 3 | > A scheduled playout client for CasparCG. 4 | 5 | ![Folderplayout](https://raw.githubusercontent.com/baltedewit/folderplayout/v3/images/folderplayout.PNG) 6 | 7 | Folderplayout is based on hierarchical schedule. You can combine groups, folders, clips and live inputs and use dates, weeks, days and hours to schedule these. When nothing from the schedule is playing, an external input is played. For example an info channel. 8 | 9 | Folderplayout can be ran on solely CasparCG using Decklink inputs or using CasparCG for playout and Blackmagic Atem's for switching inputs. 10 | 11 | Internally, folderplayout builds on SuperFly's Timeline project and the Timeline State Resolver from NRK's Sofie project. 12 | 13 | #### Build Setup 14 | 15 | ``` bash 16 | # install dependencies 17 | yarn install 18 | 19 | # serve with hot reload at localhost:9080 20 | yarn run dev 21 | 22 | # build electron application for production 23 | yarn run build 24 | 25 | 26 | # lint all JS/Vue component files in `src/` 27 | yarn run lint 28 | 29 | ``` 30 | 31 | --- 32 | 33 | This project was generated with [electron-vue](https://github.com/SimulatedGREG/electron-vue)@[8fae476](https://github.com/SimulatedGREG/electron-vue/tree/8fae4763e9d225d3691b627e83b9e09b56f6c935) using [vue-cli](https://github.com/vuejs/vue-cli). Documentation about the original structure can be found [here](https://simulatedgreg.gitbooks.io/electron-vue/content/index.html). 34 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # Commented sections below can be used to run tests on the CI server 2 | # https://simulatedgreg.gitbooks.io/electron-vue/content/en/testing.html#on-the-subject-of-ci-testing 3 | version: 0.1.{build} 4 | 5 | branches: 6 | only: 7 | - master 8 | 9 | image: Visual Studio 2017 10 | platform: 11 | - x64 12 | 13 | cache: 14 | - node_modules 15 | - '%APPDATA%\npm-cache' 16 | - '%USERPROFILE%\.electron' 17 | - '%USERPROFILE%\AppData\Local\Yarn\cache' 18 | 19 | init: 20 | - git config --global core.autocrlf input 21 | 22 | install: 23 | - ps: Install-Product node 8 x64 24 | - choco install yarn --ignore-dependencies 25 | - git reset --hard HEAD 26 | - yarn 27 | - node --version 28 | 29 | build_script: 30 | #- yarn test 31 | - yarn build 32 | 33 | test: off 34 | -------------------------------------------------------------------------------- /build/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mint-dewit/folderplayout/c2852735a680dd360b97c639cd4f411c5b55bc5d/build/icons/256x256.png -------------------------------------------------------------------------------- /build/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mint-dewit/folderplayout/c2852735a680dd360b97c639cd4f411c5b55bc5d/build/icons/icon.icns -------------------------------------------------------------------------------- /build/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mint-dewit/folderplayout/c2852735a680dd360b97c639cd4f411c5b55bc5d/build/icons/icon.ico -------------------------------------------------------------------------------- /images/folderplayout.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mint-dewit/folderplayout/c2852735a680dd360b97c639cd4f411c5b55bc5d/images/folderplayout.PNG -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "folderplayout", 3 | "version": "0.6.4", 4 | "author": "Balte de Wit ", 5 | "description": "A scheduled playout client for CasparCG.", 6 | "license": "MIT", 7 | "main": "./dist/electron/main.js", 8 | "engines": { 9 | "node": ">=12.18.2" 10 | }, 11 | "scripts": { 12 | "build": "node .electron-vue/build.js && electron-builder", 13 | "build:dir": "node .electron-vue/build.js && electron-builder --dir", 14 | "build:clean": "cross-env BUILD_TARGET=clean node .electron-vue/build.js", 15 | "build:web": "cross-env BUILD_TARGET=web node .electron-vue/build.js", 16 | "dev": "cross-env NODE_ENV=development node .electron-vue/dev-runner.js", 17 | "lint": "eslint --ext .js,.vue src", 18 | "lint:fix": "eslint --ext .js,.vue --fix src", 19 | "pack": "npm run pack:main && npm run pack:renderer", 20 | "pack:main": "cross-env NODE_ENV=production webpack --progress --colors --config .electron-vue/webpack.main.config.js", 21 | "pack:renderer": "cross-env NODE_ENV=production webpack --progress --colors --config .electron-vue/webpack.renderer.config.js" 22 | }, 23 | "build": { 24 | "productName": "folderplayout", 25 | "appId": "com.balte_nl.folderplayout", 26 | "directories": { 27 | "output": "build" 28 | }, 29 | "files": [ 30 | "dist/electron/**/*" 31 | ], 32 | "dmg": { 33 | "contents": [ 34 | { 35 | "x": 410, 36 | "y": 150, 37 | "type": "link", 38 | "path": "/Applications" 39 | }, 40 | { 41 | "x": 130, 42 | "y": 150, 43 | "type": "file" 44 | } 45 | ] 46 | }, 47 | "mac": { 48 | "icon": "build/icons/icon.icns" 49 | }, 50 | "win": { 51 | "icon": "build/icons/icon.ico" 52 | }, 53 | "linux": { 54 | "icon": "build/icons" 55 | } 56 | }, 57 | "dependencies": { 58 | "@fortawesome/fontawesome-svg-core": "^1.2.10", 59 | "@fortawesome/free-solid-svg-icons": "^5.6.1", 60 | "@fortawesome/vue-fontawesome": "^0.1.3", 61 | "axios": "^0.18.0", 62 | "bootstrap": "^4.4.1", 63 | "bootstrap-vue": "^2.15.0", 64 | "recurrence-parser": "^0.5.4", 65 | "superfly-timeline": "^8.0.0", 66 | "timeline-state-resolver": "^3.20.1", 67 | "uid": "^0.0.2", 68 | "vue": "^2.5.16", 69 | "vue-electron": "^1.0.6", 70 | "vue-router": "^3.0.1", 71 | "vuedraggable": "^2.17.0", 72 | "vuex": "^3.0.1", 73 | "vuex-electron": "^1.0.0" 74 | }, 75 | "optionalDependencies": { 76 | "bufferutil": "latest", 77 | "utf-8-validate": "latest" 78 | }, 79 | "devDependencies": { 80 | "@babel/core": "^7.0.0", 81 | "@babel/preset-env": "^7.10.2", 82 | "@babel/register": "^7.0.0", 83 | "babel-eslint": "^10.1.0", 84 | "babel-loader": "^8.1.0", 85 | "babel-minify-webpack-plugin": "^0.3.1", 86 | "cfonts": "^2.1.2", 87 | "chalk": "^4.0.0", 88 | "copy-webpack-plugin": "^6.0.1", 89 | "cross-env": "^7.0.2", 90 | "css-loader": "^3.5.3", 91 | "del": "^5.1.0", 92 | "devtron": "^1.4.0", 93 | "electron": "^9.0.1", 94 | "electron-builder": "^22.7.0", 95 | "electron-debug": "^3.1.0", 96 | "electron-devtools-installer": "^3.0.0", 97 | "eslint": "^7.1.0", 98 | "eslint-config-prettier": "^6.11.0", 99 | "eslint-loader": "^4.0.2", 100 | "eslint-plugin-node": "^11.1.0", 101 | "eslint-plugin-prettier": "^3.1.3", 102 | "eslint-plugin-vue": "^7.0.0-alpha.5", 103 | "file-loader": "^6.0.0", 104 | "html-webpack-plugin": "^4.3.0", 105 | "jquery": "^3.4.0", 106 | "mini-css-extract-plugin": "^0.9.0", 107 | "multispinner": "^0.2.1", 108 | "node-loader": "^0.6.0", 109 | "node-sass": "^4.9.2", 110 | "prettier": "^2.0.5", 111 | "sass-loader": "^8.0.2", 112 | "standard-version": "^8.0.0", 113 | "style-loader": "^1.2.1", 114 | "url-loader": "^4.1.0", 115 | "vue-html-loader": "^1.2.4", 116 | "vue-loader": "^15.2.4", 117 | "vue-style-loader": "^4.1.0", 118 | "vue-template-compiler": "^2.5.16", 119 | "webpack": "^4.15.1", 120 | "webpack-cli": "^3.0.8", 121 | "webpack-dev-server": "^3.1.4", 122 | "webpack-hot-middleware": "^2.22.2", 123 | "webpack-merge": "^4.1.3" 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | folderplayout 6 | <% if (htmlWebpackPlugin.options.nodeModules) { %> 7 | 8 | 11 | <% } %> 12 | 13 | 14 |
15 | 16 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/main/api.js: -------------------------------------------------------------------------------- 1 | const { ipcMain } = require('electron') 2 | const { PlayoutManager } = require('./playout') 3 | 4 | export class API { 5 | constructor(window) { 6 | console.log('init api') 7 | this.window = window 8 | 9 | ipcMain.once('init', (event, { schedule, settings }) => { 10 | console.log('init playout') 11 | this.playoutSchedule = schedule 12 | this.settings = settings 13 | 14 | this.playoutHandler = new PlayoutManager(this) 15 | }) 16 | ipcMain.on('schedule', (event, schedule) => { 17 | console.log('update schedule') 18 | this.playoutSchedule = schedule 19 | this.playoutHandler.createTimeline() 20 | }) 21 | ipcMain.on('settings', (event, settings) => { 22 | console.log('update settings') 23 | this.settings = settings 24 | this.playoutHandler.updateMappingsAndDevices() 25 | this.playoutHandler.createTimeline() 26 | }) 27 | 28 | console.log('send init') 29 | this.window.webContents.send('init') 30 | } 31 | 32 | dispose() { 33 | this.playoutHandler.dispose() 34 | delete this.playoutHandler 35 | ipcMain.removeAllListeners('init') 36 | ipcMain.removeAllListeners('schedule') 37 | ipcMain.removeAllListeners('settings') 38 | } 39 | 40 | setReadableTimeline(tl) { 41 | this.window.webContents.send('setReadableTimeline', tl) 42 | } 43 | 44 | setDeviceState(device, deviceStatus) { 45 | this.window.webContents.send('setDeviceState', device, deviceStatus) 46 | } 47 | 48 | removeDeviceState(device) { 49 | this.window.webContents.send('removeDeviceState', device) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/index.dev.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is used specifically and only for development. It installs 3 | * `electron-debug` & `vue-devtools`. There shouldn't be any need to 4 | * modify this file, but it can be used to extend your development 5 | * environment. 6 | */ 7 | 8 | /* eslint-disable */ 9 | 10 | // Install `electron-debug` with `devtron` 11 | require('electron-debug')({ showDevTools: true }) 12 | 13 | // Install `vue-devtools` 14 | require('electron').app.on('ready', () => { 15 | let installExtension = require('electron-devtools-installer') 16 | installExtension.default(installExtension.VUEJS_DEVTOOLS) 17 | .then(() => {}) 18 | .catch(err => { 19 | console.log('Unable to install `vue-devtools`: \n', err) 20 | }) 21 | }) 22 | 23 | // Require `main` process to boot app 24 | require('./index') -------------------------------------------------------------------------------- /src/main/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { app, BrowserWindow } from 'electron' 4 | import { API } from './api' 5 | 6 | /** 7 | * Set `__static` path to static files in production 8 | * https://simulatedgreg.gitbooks.io/electron-vue/content/en/using-static-assets.html 9 | */ 10 | if (process.env.NODE_ENV !== 'development') { 11 | global.__static = require('path').join(__dirname, '/static').replace(/\\/g, '\\\\') 12 | } 13 | 14 | let mainWindow 15 | let fatalErrorWindow 16 | const winURL = process.env.NODE_ENV === 'development' ? `http://localhost:9080` : `file://${__dirname}/index.html` 17 | const fatalErrWinURL = 18 | process.env.NODE_ENV === 'development' 19 | ? `http://localhost:9080/static/fatal.html` 20 | : `file://${__dirname}/static/fatal.html` 21 | 22 | // const fatalErrWinURL = `file://${__dirname}/static/fatal.html` 23 | 24 | function createFatalErrorWindow() { 25 | fatalErrorWindow = new BrowserWindow({ 26 | height: 563, 27 | useContentSize: true, 28 | width: 400, 29 | webPreferences: { 30 | nodeIntegration: true, // TODO This needs to be removed asap 31 | }, 32 | }) 33 | 34 | fatalErrorWindow.loadURL(fatalErrWinURL) 35 | } 36 | 37 | function createWindow() { 38 | /** 39 | * Initial window options 40 | */ 41 | mainWindow = new BrowserWindow({ 42 | height: 750, 43 | useContentSize: true, 44 | width: process.env.NODE_ENV !== 'development' ? 1000 : 1500, 45 | webPreferences: { 46 | nodeIntegration: true, // TODO This needs to be removed asap 47 | }, 48 | }) 49 | let api 50 | 51 | mainWindow.webContents.on('dom-ready', () => { 52 | console.log('create API') 53 | api = new API(mainWindow) 54 | }) 55 | 56 | mainWindow.loadURL(winURL) 57 | 58 | mainWindow.webContents.on('crashed', () => { 59 | console.log('mainWindow crashed') 60 | createFatalErrorWindow() 61 | mainWindow.close() 62 | }) 63 | 64 | if (process.env.NODE_ENV !== 'development') { 65 | mainWindow.webContents.on('before-input-event', (_e, input) => { 66 | if (input.type === 'keyDown' && input.key === 'I' && input.shift && input.control) { 67 | mainWindow.webContents.openDevTools() 68 | } 69 | }) 70 | } 71 | 72 | mainWindow.on('closed', () => { 73 | mainWindow = null 74 | api.dispose() 75 | }) 76 | } 77 | 78 | app.on('ready', createWindow) 79 | 80 | app.on('window-all-closed', () => { 81 | if (process.platform !== 'darwin') { 82 | app.quit() 83 | } 84 | }) 85 | 86 | app.on('activate', () => { 87 | if (mainWindow === null) { 88 | createWindow() 89 | } 90 | }) 91 | 92 | /** 93 | * Auto Updater 94 | * 95 | * Uncomment the following code below and install `electron-updater` to 96 | * support auto updating. Code Signing with a valid certificate is required. 97 | * https://simulatedgreg.gitbooks.io/electron-vue/content/en/using-electron-builder.html#auto-updating 98 | */ 99 | 100 | /* 101 | import { autoUpdater } from 'electron-updater' 102 | 103 | autoUpdater.on('update-downloaded', () => { 104 | autoUpdater.quitAndInstall() 105 | }) 106 | 107 | app.on('ready', () => { 108 | if (process.env.NODE_ENV === 'production') autoUpdater.checkForUpdates() 109 | }) 110 | */ 111 | -------------------------------------------------------------------------------- /src/main/media.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { EventEmitter } from 'events' 3 | 4 | const SCANNER_URL = 'http://127.0.0.1:8000/' 5 | 6 | export class MediaScanner extends EventEmitter { 7 | constructor(url) { 8 | super() 9 | 10 | this.lastSeq = 0 11 | this.media = [] 12 | this.connected = false 13 | this.url = '127.0.0.1' 14 | 15 | axios.defaults.baseURL = url || SCANNER_URL 16 | this._updateMedia() 17 | } 18 | 19 | getMediaTime(name) { 20 | for (const clip of this.media) { 21 | if (clip.name === name.toUpperCase()) { 22 | return clip.mediaTime 23 | } 24 | } 25 | 26 | return 0 27 | } 28 | 29 | getMediaDuration(name) { 30 | for (const clip of this.media) { 31 | if (clip.name === name.toUpperCase()) { 32 | return clip.format.duration 33 | } 34 | } 35 | 36 | return 0 37 | } 38 | 39 | getFolderContents(name) { 40 | const res = [] 41 | 42 | if (name.substr(-1) !== '/') name += '/' 43 | name = name.toUpperCase() 44 | 45 | for (const clip of this.media) { 46 | if (clip.name.search(name) === 0) { 47 | let clipName = clip.name 48 | clipName = clipName.replace(name, '') 49 | if (clipName.split('/').length === 1) { 50 | res.push(clip.name) 51 | } 52 | } 53 | } 54 | 55 | return res 56 | } 57 | 58 | getStatus() { 59 | if (this.connected) { 60 | return { 61 | statusCode: 1, // good 62 | messages: [], 63 | } 64 | } else { 65 | return { 66 | statusCode: 4, // bad 67 | messages: ['Unable to connect to media manager at ' + axios.defaults.baseURL], 68 | } 69 | } 70 | } 71 | 72 | async _updateMedia() { 73 | try { 74 | const res = await axios.get('/stat/seq') 75 | const lastSeq = res.data.update_seq 76 | 77 | if (lastSeq !== this.lastSeq) { 78 | this.lastSeq = lastSeq 79 | this.media = (await axios.get('/media')).data 80 | this.emit('changed') 81 | } 82 | 83 | if (!this.connected) { 84 | this.connected = true 85 | this.emit('connected') 86 | this.emit('connectionChanged', this.getStatus()) 87 | } 88 | } catch (e) { 89 | if (this.connected) { 90 | this.connected = false 91 | this.emit('disconnected') 92 | this.emit('connectionChanged', this.getStatus()) 93 | } 94 | } 95 | 96 | setTimeout(() => this._updateMedia(), 1000) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/playout.js: -------------------------------------------------------------------------------- 1 | import { RecurrenceParser, DateObj } from 'recurrence-parser' 2 | import { Conductor, DeviceType } from 'timeline-state-resolver' 3 | import { MediaScanner } from './media' 4 | import { MappingAtemType } from 'timeline-state-resolver/dist/types/src' 5 | 6 | export class PlayoutManager { 7 | constructor(API) { 8 | this.API = API 9 | this.conductor = new Conductor() 10 | this.scanner = new MediaScanner(API.settings.mediaScannerURL) 11 | this.parser = new RecurrenceParser( 12 | (name) => this.scanner.getMediaDuration(name), 13 | (name) => this.scanner.getMediaTime(name), 14 | (name) => this.scanner.getFolderContents(name), 15 | null, 16 | () => null 17 | ) 18 | 19 | this.conductor.on('error', (...err) => console.log(...err)) 20 | this.scanner.on('connectionChanged', (status) => this.updateDeviceStatus('mediascanner', status)) 21 | this.updateDeviceStatus('mediascanner', this.scanner.getStatus()) 22 | 23 | this.conductor 24 | .init() 25 | .then(() => { 26 | this.updateMappingsAndDevices() 27 | }) 28 | .then(() => { 29 | this.createTimeline() 30 | this.scanner.on('changed', () => this.createTimeline()) 31 | }) 32 | 33 | this.timeout = setTimeout(() => this.createTimeline(), 0) 34 | } 35 | 36 | dispose() { 37 | this.conductor.destroy() 38 | this.conductor.removeAllListeners() 39 | delete this.scanner 40 | delete this.conductor 41 | } 42 | 43 | createTimeline() { 44 | const settings = this.API.settings 45 | const tls = [] 46 | let time = Date.now() - 6 * 3600 * 1000 // 6 hrs ago 47 | let stopCondition = Date.now() + 18 * 3600 * 1000 // 18 hrs ahead 48 | 49 | this.parser.schedule = JSON.parse(JSON.stringify(this.API.playoutSchedule)) 50 | 51 | let tries = 0 52 | while (time < stopCondition && tries < 1000 && this.API.playoutSchedule.length > 0) { 53 | try { 54 | const tl = this.parser.getNextTimeline(new DateObj(time)) 55 | tls.push(tl) 56 | time = tl.end + 1000 57 | } catch (e) { 58 | console.log(e) // do something here 59 | return 60 | } 61 | tries++ 62 | } 63 | 64 | const timeline = [] 65 | const readableTimeline = [] 66 | for (const tl of tls) { 67 | const bg = [] 68 | for (let i = 0; i < tl.timeline.length; i++) { 69 | // make bg objects 70 | if (tl.timeline[i].content.deviceType === 2) continue // no bg objects for atem 71 | const obj = JSON.parse(JSON.stringify(tl.timeline[i])) 72 | delete obj.classes 73 | obj.id += '_bg' 74 | obj.lookaheadForLayer = obj.layer 75 | obj.layer += '_BG' 76 | obj.isLookahead = true 77 | if (i === 0) { 78 | obj.enable = { 79 | start: `#${tl.timeline[0].id}.start - 2000`, 80 | duration: 2000, 81 | } 82 | } else { 83 | obj.enable = { 84 | while: `#${tl.timeline[i - 1].id}`, 85 | } 86 | } 87 | bg.push(obj) 88 | } 89 | 90 | timeline.push(...tl.timeline) 91 | timeline.push(...bg) 92 | readableTimeline.push(...tl.readableTimeline) 93 | } 94 | // console.log(timeline) 95 | readableTimeline.sort((a, b) => a.start - b.start) 96 | 97 | this.API.setReadableTimeline(readableTimeline) 98 | 99 | timeline.push( 100 | { 101 | // decklink bg = always on 102 | id: 'decklink_bg', 103 | layer: 'bg', 104 | enable: { 105 | while: 1, 106 | }, 107 | content: { 108 | deviceType: 1, 109 | type: 'input', 110 | 111 | device: Number(this.API.settings.decklinkInput), 112 | mixer: { 113 | volume: 1, 114 | inTransition: { 115 | duration: 250, 116 | }, 117 | }, 118 | }, 119 | keyframes: [ 120 | // mute during unmuted playout 121 | { 122 | id: 'decklink_bg_kf0', 123 | enable: { 124 | while: '.PLAYOUT & !.MUTED', 125 | }, 126 | content: { 127 | mixer: { 128 | volume: 0, 129 | inTransition: { 130 | duration: 250, 131 | }, 132 | }, 133 | }, 134 | }, 135 | ], 136 | }, 137 | { 138 | // default audio = always on. this obj prevents a bug in ccg-state where it forgets something is muted. 139 | id: 'ccg_playout_audio', 140 | layer: 'PLAYOUT', 141 | enable: { 142 | while: 1, 143 | }, 144 | priority: -1, // as low as it gets 145 | content: { 146 | deviceType: 1, 147 | type: 'media', 148 | 149 | file: 'EMPTY', 150 | mixer: { 151 | volume: 1, 152 | inTransition: { 153 | duration: 0, 154 | }, 155 | }, 156 | }, 157 | }, 158 | { 159 | // atem input for infochannel = always enabled 160 | id: 'atem_input_infochannel', 161 | layer: 'ATEM', 162 | enable: { 163 | while: 1, 164 | }, 165 | priority: 1, 166 | content: { 167 | deviceType: 2, 168 | type: 'me', 169 | 170 | me: { 171 | programInput: Number(settings.infochannelAtemInput), 172 | }, 173 | }, 174 | }, 175 | { 176 | // atem input for playout = enabled while playout 177 | id: 'atem_input_playout', 178 | layer: 'ATEM', 179 | enable: { 180 | while: '!(.LIVE + 160) & .PLAYOUT + 160', // block during live inputs + 160 preroll decklink compensation 181 | }, 182 | priority: 2, 183 | content: { 184 | deviceType: 2, 185 | type: 'me', 186 | 187 | me: { 188 | programInput: settings.playoutAtemInput, 189 | }, 190 | }, 191 | }, 192 | { 193 | // atem audio from infochannel = outside of playout 194 | id: 'atem_audio_bg', 195 | layer: 'ATEM_AUDIO_BG', 196 | enable: { 197 | while: '!.PLAYOUT', // they need separate expression for some reason 198 | }, 199 | content: { 200 | deviceType: 2, 201 | type: 'audioChan', 202 | 203 | audioChannel: { 204 | mixOption: 1, // enabled 205 | }, 206 | }, 207 | }, 208 | { 209 | // atem audio from infochannel = when muted 210 | id: 'atem_audio_muted', 211 | layer: 'ATEM_AUDIO_BG', 212 | enable: { 213 | while: '.MUTED', // they need separate expression for some reason 214 | }, 215 | content: { 216 | deviceType: 2, 217 | type: 'audioChan', 218 | 219 | audioChannel: { 220 | mixOption: 1, // enabled 221 | }, 222 | }, 223 | }, 224 | { 225 | // atem audio from playout = when unmuted playout 226 | id: 'atem_audio_playout', 227 | layer: 'ATEM_AUDIO_PGM', 228 | enable: { 229 | while: '.PLAYOUT & !.MUTED & !.LIVE_AUDIO', 230 | }, 231 | content: { 232 | deviceType: 2, 233 | type: 'audioChan', 234 | 235 | audioChannel: { 236 | mixOption: 1, // enabled 237 | }, 238 | }, 239 | } 240 | ) 241 | 242 | this.conductor.timeline = timeline 243 | clearTimeout(this.timeout) 244 | this.timeout = setTimeout(() => this.createTimeline(), 12 * 3600 * 1000) // re-parse in 12 hours 245 | } 246 | 247 | async addCasparCG(settings) { 248 | this.updateDeviceStatus('ccg', { statusCode: 4, messages: ['CasparCG Disconnected'] }) // hack to make it get a status before first connection 249 | 250 | const device = await this.conductor.addDevice('ccg', { 251 | type: DeviceType.CASPARCG, 252 | options: { 253 | host: settings.casparcgHost || '127.0.0.1', 254 | port: settings.casparcgPort || 5250, 255 | useScheduling: false, 256 | }, 257 | }) 258 | 259 | this.updateDeviceStatus('ccg', await device.device.getStatus()) 260 | await device.device.on('connectionChanged', (deviceStatus) => this.updateDeviceStatus('ccg', deviceStatus)) 261 | } 262 | 263 | async addAtem(settings) { 264 | this.updateDeviceStatus('atem', { statusCode: 4, messages: ['Atem Disconnected'] }) // hack to make it get a status before first connection 265 | 266 | const device = await this.conductor.addDevice('atem', { 267 | type: DeviceType.ATEM, 268 | options: { 269 | host: settings.atemIp, 270 | }, 271 | }) 272 | this.updateDeviceStatus('atem', await device.device.getStatus()) 273 | await device.device.on('connectionChanged', (deviceStatus) => this.updateDeviceStatus('atem', deviceStatus)) 274 | } 275 | 276 | async updateMappingsAndDevices() { 277 | const settings = this.API.settings 278 | 279 | if (!this.conductor.getDevice('ccg')) { 280 | this.addCasparCG(settings) 281 | } 282 | 283 | if (!this.conductor.mapping['PLAYOUT']) { 284 | this.conductor.mapping['PLAYOUT'] = { 285 | device: DeviceType.CASPARCG, 286 | deviceId: 'ccg', 287 | channel: 1, 288 | layer: 20, 289 | } 290 | } 291 | 292 | if (settings.inputType === 0) { 293 | // decklink input 294 | if (this.conductor.mapping['ATEM']) { 295 | delete this.conductor.mapping['ATEM'] 296 | } 297 | if (this.conductor.mapping['ATEM_AUDIO']) { 298 | delete this.conductor.mapping['ATEM_AUDIO'] 299 | } 300 | if (this.conductor.getDevice('atem')) { 301 | this.conductor.removeDevice('atem') 302 | this.API.removeDeviceState('atem') 303 | } 304 | if (!this.conductor.mapping['bg']) { 305 | this.conductor.mapping['bg'] = { 306 | device: DeviceType.CASPARCG, 307 | deviceId: 'ccg', 308 | channel: 1, 309 | layer: 10, 310 | } 311 | } 312 | this.parser.liveMode = 'casparcg' 313 | } else if (settings.inputType === 1) { 314 | // atem input 315 | if (this.conductor.mapping['bg']) { 316 | delete this.conductor.mapping['bg'] 317 | } 318 | if (!this.conductor.getDevice('atem')) { 319 | this.addAtem(settings) 320 | } 321 | if (!this.conductor.mapping['ATEM']) { 322 | this.conductor.mapping['ATEM'] = { 323 | device: DeviceType.ATEM, 324 | deviceId: 'atem', 325 | mappingType: MappingAtemType.MixEffect, 326 | index: 0, 327 | } 328 | } 329 | if (!this.conductor.mapping['ATEM_AUDIO_BG']) { 330 | this.conductor.mapping['ATEM_AUDIO_BG'] = { 331 | device: DeviceType.ATEM, 332 | deviceId: 'atem', 333 | mappingType: MappingAtemType.AudioChannel, 334 | index: settings.infochannelAtemInput, 335 | } 336 | } 337 | for (let i = 1; i <= settings.playoutAtemChannels; i++) { 338 | if (!this.conductor.mapping['ATEM_AUDIO_' + i]) { 339 | this.conductor.mapping['ATEM_AUDIO_' + i] = { 340 | device: DeviceType.ATEM, 341 | deviceId: 'atem', 342 | mappingType: MappingAtemType.AudioChannel, 343 | index: i, 344 | } 345 | } 346 | } 347 | if (!this.conductor.mapping['ATEM_AUDIO_PGM']) { 348 | this.conductor.mapping['ATEM_AUDIO_PGM'] = { 349 | device: DeviceType.ATEM, 350 | deviceId: 'atem', 351 | mappingType: MappingAtemType.AudioChannel, 352 | index: settings.playoutAtemInput, 353 | } 354 | } 355 | this.parser.liveMode = 'atem' 356 | } 357 | } 358 | 359 | updateDeviceStatus(deviceName, deviceStatus) { 360 | this.API.setDeviceState(deviceName, deviceStatus) 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /src/renderer/App.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 82 | 83 | 86 | -------------------------------------------------------------------------------- /src/renderer/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mint-dewit/folderplayout/c2852735a680dd360b97c639cd4f411c5b55bc5d/src/renderer/assets/.gitkeep -------------------------------------------------------------------------------- /src/renderer/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mint-dewit/folderplayout/c2852735a680dd360b97c639cd4f411c5b55bc5d/src/renderer/assets/logo.png -------------------------------------------------------------------------------- /src/renderer/components/DashBoard.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 142 | -------------------------------------------------------------------------------- /src/renderer/components/DashBoard/ProgressBar.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 39 | -------------------------------------------------------------------------------- /src/renderer/components/DashBoard/StatusText.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 34 | -------------------------------------------------------------------------------- /src/renderer/components/DashBoard/TimingComponent.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 44 | 45 | 50 | -------------------------------------------------------------------------------- /src/renderer/components/EditSchedule.vue: -------------------------------------------------------------------------------- 1 | 139 | 140 | 386 | -------------------------------------------------------------------------------- /src/renderer/components/Schedule.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 167 | 168 | 190 | -------------------------------------------------------------------------------- /src/renderer/components/Settings.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 163 | -------------------------------------------------------------------------------- /src/renderer/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import BootstrapVue from 'bootstrap-vue' 3 | import { ipcRenderer } from 'electron' 4 | 5 | import 'bootstrap/dist/css/bootstrap.css' 6 | import 'bootstrap-vue/dist/bootstrap-vue.css' 7 | 8 | import { library } from '@fortawesome/fontawesome-svg-core' 9 | import { fas } from '@fortawesome/free-solid-svg-icons' 10 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' 11 | 12 | import App from './App' 13 | import router from './router' 14 | import store from './store' 15 | 16 | if (!process.env.IS_WEB) Vue.use(require('vue-electron')) 17 | Vue.config.productionTip = false 18 | 19 | Vue.use(BootstrapVue) 20 | 21 | library.add(fas) 22 | 23 | Vue.component('font-awesome-icon', FontAwesomeIcon) 24 | 25 | ipcRenderer.on('init', () => { 26 | ipcRenderer.send('init', { 27 | schedule: store.state.playoutSchedule, 28 | settings: store.state.settings, 29 | }) 30 | }) 31 | ipcRenderer.on('setReadableTimeline', (ev, tl) => { 32 | console.log('readable tl', tl) 33 | store.dispatch('setReadableTimeline', tl) 34 | }) 35 | ipcRenderer.on('setDeviceState', (ev, device, status) => { 36 | console.log('device status', device, status) 37 | store.dispatch('setDeviceState', { device, status }) 38 | }) 39 | ipcRenderer.on('removeDeviceState', (ev, device) => { 40 | console.log('remove device', device) 41 | store.dispatch('removeDeviceState', device) 42 | }) 43 | 44 | window.store = store 45 | 46 | /* eslint-disable no-new */ 47 | new Vue({ 48 | components: { App }, 49 | router, 50 | store, 51 | template: '', 52 | }).$mount('#app') 53 | -------------------------------------------------------------------------------- /src/renderer/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | 4 | Vue.use(Router) 5 | 6 | export default new Router({ 7 | routes: [ 8 | { 9 | path: '/dashboard', 10 | name: 'dashboard', 11 | component: require('../components/DashBoard').default, 12 | }, 13 | { 14 | path: '/schedule', 15 | name: 'schedule', 16 | component: require('../components/Schedule').default, 17 | }, 18 | { 19 | path: '/schedule/:id', 20 | name: 'schedule', 21 | component: require('../components/Schedule').default, 22 | children: [ 23 | { 24 | path: 'edit', 25 | component: require('../components/EditSchedule').default, 26 | }, 27 | ], 28 | }, 29 | { 30 | path: '/settings', 31 | name: 'settings', 32 | component: require('../components/Settings').default, 33 | }, 34 | { 35 | path: '*', 36 | redirect: '/dashboard', 37 | }, 38 | ], 39 | }) 40 | -------------------------------------------------------------------------------- /src/renderer/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import uid from 'uid' 4 | 5 | import storeState from './storeState' 6 | import fs from 'fs' 7 | 8 | import { ipcRenderer } from 'electron' 9 | 10 | Vue.use(Vuex) 11 | 12 | export default new Vuex.Store({ 13 | state: { 14 | schedule: [], 15 | playoutSchedule: [], // a buffer between the schedule editing and actual playout 16 | readableTimeline: [], 17 | settings: { 18 | inputType: 0, 19 | decklinkInput: 1, 20 | atemIp: '', 21 | infochannelAtemInput: 0, 22 | playoutAtemInput: 0, 23 | playoutAtemChannels: 0, 24 | mediaScannerURL: 'http://127.0.0.1:8000/', 25 | casparcgHost: '127.0.0.1', 26 | casparcgPort: 5250, 27 | }, 28 | deviceState: {}, 29 | }, 30 | getters: { 31 | /** 32 | * Finds a specific entry in the schedule by it's id. 33 | */ 34 | scheduleEntryById: (state) => (_id) => { 35 | // recursively find the right item: 36 | let findEntry = (parent) => { 37 | for (let item of parent) { 38 | if (item._id === _id) { 39 | return item 40 | } else if (item.children) { 41 | let found = findEntry(item.children) 42 | if (found) return found 43 | } 44 | } 45 | } 46 | 47 | return findEntry(state.schedule) 48 | }, 49 | 50 | /** 51 | * finds the children of an entry, or if entry is not a group, 52 | * this finds it's brothers and sisters. 53 | */ 54 | entryChildren: (state) => (_id) => { 55 | let findChildren = (parent) => { 56 | for (let child of parent) { 57 | if (child.type === 'group') { 58 | for (let childsChild of child.children) { 59 | if (childsChild._id === _id && childsChild.type !== 'group') { 60 | return child 61 | } else if (childsChild._id) { 62 | return childsChild 63 | } 64 | } 65 | let res = findChildren(child.children) 66 | if (res) return res 67 | } 68 | } 69 | } 70 | 71 | return findChildren(state.schedule) 72 | }, 73 | 74 | /** 75 | * searches schedule for entry with _id, returns the entry if it 76 | * is a group, or else, returns its parent 77 | */ 78 | findGroupOrParent: (state) => (_id) => { 79 | let findById = (parent) => { 80 | if (parent.children) { 81 | for (let child of parent.children) { 82 | if (child._id === _id && child.type === 'group') { 83 | // child we are editing, and child is a group 84 | return child 85 | } else if (child._id === _id) { 86 | // child we are editing, but child is not a group 87 | return parent 88 | } else if (child.type === 'group') { 89 | // not the child we are editing, but child is a group, therefore might contain what we are editing 90 | let res = findById(child) || null 91 | 92 | if (res) return res 93 | } 94 | } 95 | } 96 | } 97 | 98 | return findById({ children: state.schedule, _id: 'MAIN_ENTRY', name: 'Schedule' }) 99 | }, 100 | 101 | getPlayoutState: (state) => (t) => { 102 | const playoutState = { 103 | nextUpTime: 0, 104 | startTime: 0, 105 | nowPlaying: 'Nothing', 106 | nextUp: 'Unknown / nothing', 107 | } 108 | 109 | if (!state.readableTimeline || state.readableTimeline.length === 0) { 110 | return playoutState 111 | } 112 | 113 | const readableTimeline = JSON.parse(JSON.stringify(state.readableTimeline)) 114 | 115 | const previous = readableTimeline.reverse().find((o) => { 116 | return o.start + o.duration < t 117 | }) 118 | readableTimeline.reverse() // reverse back 119 | const curPlaying = readableTimeline.find((o) => { 120 | return o.start < t && o.start + o.duration > t 121 | }) 122 | const next = readableTimeline.find((o) => { 123 | return o.start > t 124 | }) 125 | 126 | // if (curPlaying) console.log(`CurPlaying: ${curPlaying.label} - ${new Date(curPlaying.start)}`) 127 | // if (next) console.log(`Next: ${next.label} - ${new Date(next.start)}`) 128 | 129 | const firstPlayout = next ? next.start : 0 130 | const previousPlayout = previous ? previous.start + previous.duration : 0 131 | 132 | if (firstPlayout) { 133 | playoutState.nextUpTime = firstPlayout 134 | } 135 | 136 | if (curPlaying) { 137 | playoutState.startTime = curPlaying.start 138 | if (curPlaying.label) playoutState.nowPlaying = curPlaying.label 139 | 140 | const end = curPlaying.start + curPlaying.duration 141 | 142 | if (!firstPlayout || end < firstPlayout) { 143 | playoutState.nextUp = 'Nothing' 144 | playoutState.nextUpTime = end 145 | } 146 | } else if (previousPlayout) { 147 | playoutState.startTime = previousPlayout 148 | } 149 | if (next) { 150 | playoutState.nextUp = next.label 151 | } 152 | 153 | return playoutState 154 | }, 155 | }, 156 | mutations: { 157 | /** 158 | * adds a new video file to the schedule 159 | * @param {Object} state 160 | * @param {Object} payload _id determines the parent of the new entry 161 | */ 162 | newEntry(state, payload) { 163 | const newEntry = { 164 | _id: payload.newId, 165 | type: payload.type, 166 | } 167 | 168 | if (payload.type === 'group') { 169 | newEntry.children = [] 170 | } else if (payload.type === 'input') { 171 | newEntry.input = 1 172 | newEntry.duration = 60 173 | } else { 174 | newEntry.path = '' 175 | } 176 | 177 | let findParent = (parent) => { 178 | for (let item of parent) { 179 | if (item._id === payload._id) { 180 | return item 181 | } else if (item.children) { 182 | let res = findParent(item.children) 183 | if (res) return res 184 | } 185 | } 186 | } 187 | let parent = payload._id ? findParent(state.schedule) : null 188 | 189 | if (parent) { 190 | parent.children.push(newEntry) 191 | } else { 192 | state.schedule.push(newEntry) 193 | } 194 | }, 195 | 196 | /** 197 | * remove an entry from the schedule 198 | * @param {Object} state 199 | * @param {Object} payload 200 | */ 201 | deleteEntry(state, payload) { 202 | let deleteId = (parent) => { 203 | for (let i in parent) { 204 | if (parent[i]._id === payload._id) { 205 | parent.splice(i, 1) 206 | } else if (parent[i].children) { 207 | deleteId(parent[i].children) 208 | } 209 | } 210 | } 211 | 212 | deleteId(state.schedule) 213 | }, 214 | 215 | toggleDay(_, payload) { 216 | const entry = this.getters.scheduleEntryById(payload._id) 217 | if (!entry) throw new Error('Could not find entry with id ' + payload._id) 218 | if (entry.days && entry.days.indexOf(payload.day) > -1) { 219 | entry.days.splice(entry.days.indexOf(payload.day), 1) 220 | } else { 221 | if (!entry.days) entry.days = [] 222 | entry.days.push(payload.day) 223 | } 224 | }, 225 | 226 | setMuted(_state, payload) { 227 | const entry = this.getters.scheduleEntryById(payload._id) 228 | if (payload.muted !== true) { 229 | delete entry.audio 230 | } else { 231 | entry.audio = false 232 | } 233 | }, 234 | 235 | /** 236 | * Adds a time entry to a specific entry, where the 237 | * entry is defined by payload._id 238 | * @param {Object} state 239 | * @param {Object} payload 240 | */ 241 | addTime(_state, payload) { 242 | const entry = this.getters.scheduleEntryById(payload._id) 243 | if (entry.times) { 244 | entry.times.push(payload.time) 245 | } else { 246 | Vue.set(entry, 'times', [payload.time]) 247 | } 248 | }, 249 | 250 | /** 251 | * Updates a time entry in a specific entry, where the 252 | * entry is defined by the payload._id, the time entry 253 | * is defined by payload.index and the new time is defined 254 | * by payload.time 255 | * @param {Object} state 256 | * @param {Object} payload 257 | */ 258 | updateTime(_state, payload) { 259 | const entry = this.getters.scheduleEntryById(payload._id) 260 | entry.times[payload.index] = payload.time 261 | }, 262 | 263 | /** 264 | * Deletes a time entry in a specific entry, where the 265 | * entry is defined by the payload._id, and the time entry 266 | * is defined by entry.index 267 | * @param {Object} state The store state 268 | * @param {Object} payload An object with parameters 269 | */ 270 | deleteTime(_state, payload) { 271 | const entry = this.getters.scheduleEntryById(payload._id) 272 | entry.times.splice(payload.index, 1) 273 | if (entry.times.length === 0) { 274 | Vue.set(entry, 'times', null) 275 | } 276 | }, 277 | 278 | addDateEntry(_state, payload) { 279 | const entry = this.getters.scheduleEntryById(payload._id) 280 | if (entry.dates) { 281 | entry.dates.push(payload.dateEntry) 282 | } else { 283 | Vue.set(entry, 'dates', [payload.dateEntry]) 284 | } 285 | }, 286 | 287 | updateDateEntry(_state, payload) { 288 | const entry = this.getters.scheduleEntryById(payload._id) 289 | const dates = entry.dates[payload.dateEntry] 290 | dates[payload.type] = payload.date 291 | 292 | Vue.set(entry.dates, payload.dateEntry, dates) 293 | }, 294 | 295 | deleteDateEntry(_state, payload) { 296 | const entry = this.getters.scheduleEntryById(payload._id) 297 | entry.dates.splice(payload.index, 1) 298 | if (entry.dates.length === 0) { 299 | Vue.set(entry, 'dates', null) 300 | } 301 | }, 302 | 303 | reorder(_, payload) { 304 | var list = this.getters.findGroupOrParent(payload.id) 305 | list = list.children 306 | 307 | const movedItem = list.splice(payload.oldIndex, 1)[0] 308 | list.splice(payload.newIndex, 0, movedItem) 309 | }, 310 | 311 | updateWeeks(state, payload) { 312 | const entry = this.getters.scheduleEntryById(payload._id) 313 | entry.weeks = payload.value 314 | // if (!entry.weeks || typeof entry.weeks !== 'object') entry.weeks = [] 315 | 316 | // const old = [ ...entry.weeks ] 317 | // for (let week of payload.value) { 318 | // let i = old.indexOf(week) 319 | // if (i < 0) { // new week added 320 | // console.log('NEW', week) 321 | // entry.weeks.push(week) 322 | // } else { // already exists 323 | // console.log('EXISTING', week) 324 | // old.splice(i, 1) 325 | // } 326 | // } 327 | // for (let week of old) { // deleted weeks 328 | // console.log('DELETED', week) 329 | // entry.weeks.splice(entry.weeks.indexOf(week), 1) 330 | // } 331 | }, 332 | 333 | updatePath(_state, payload) { 334 | const entry = this.getters.scheduleEntryById(payload._id) 335 | if (entry.type === 'group') { 336 | entry.name = payload.value 337 | } else { 338 | entry.path = payload.value 339 | } 340 | // let findEntry = (parent) => { 341 | // for (let child in parent) { 342 | // if (parent[child]._id === payload._id) { 343 | // if (parent[child].type === 'group') { 344 | // parent[child].name = payload.value 345 | // } else { 346 | // parent[child].path = payload.value 347 | // } 348 | 349 | // break 350 | // } 351 | 352 | // if (parent[child].type === 'group') { findEntry(parent[child].children) } 353 | // } 354 | // } 355 | 356 | // findEntry(state.schedule) 357 | }, 358 | 359 | updateInput(_state, payload) { 360 | const entry = this.getters.scheduleEntryById(payload._id) 361 | if (entry.type === 'input') { 362 | entry.input = payload.value 363 | } 364 | }, 365 | 366 | updateDuration(_state, payload) { 367 | const entry = this.getters.scheduleEntryById(payload._id) 368 | if (entry.type === 'input') { 369 | entry.duration = payload.value 370 | } 371 | }, 372 | 373 | updateSorting(_state, payload) { 374 | const entry = this.getters.scheduleEntryById(payload._id) 375 | if (entry.type === 'folder') { 376 | entry.sort = payload.value 377 | } 378 | }, 379 | 380 | updatePlayoutSchedule(state) { 381 | state.playoutSchedule = JSON.parse(JSON.stringify(state.schedule)) 382 | }, 383 | 384 | resetSchedule(state) { 385 | state.schedule = JSON.parse(JSON.stringify(state.playoutSchedule)) 386 | }, 387 | 388 | updatePlayoutState(state, payload) { 389 | state.playoutState = { ...state.playoutState, ...payload } 390 | }, 391 | 392 | resetScheduleTo(state, schedule) { 393 | state.schedule = schedule 394 | }, 395 | 396 | settingsUpdateDecklink(state, input) { 397 | state.settings.decklinkInput = input 398 | }, 399 | 400 | settingsSet(state, settings) { 401 | state.settings = settings 402 | }, 403 | 404 | setReadableTimeline(state, tl) { 405 | state.readableTimeline = tl 406 | }, 407 | 408 | setDeviceState(state, payload) { 409 | Vue.set(state.deviceState, payload.device, payload.status) 410 | }, 411 | removeDeviceState(state, device) { 412 | Vue.delete(state.deviceState, device) 413 | }, 414 | }, 415 | actions: { 416 | newEntry(context, payload) { 417 | context.commit('newEntry', { ...payload, newId: uid() }) 418 | }, 419 | 420 | deleteEntry(context, payload) { 421 | context.commit('deleteEntry', payload) 422 | }, 423 | 424 | toggleDay(context, payload) { 425 | context.commit('toggleDay', payload) 426 | }, 427 | 428 | setMuted(context, payload) { 429 | context.commit('setMuted', payload) 430 | }, 431 | 432 | addTime(context, payload) { 433 | context.commit('addTime', payload) 434 | }, 435 | 436 | updateTime(context, payload) { 437 | context.commit('updateTime', payload) 438 | }, 439 | 440 | deleteTime(context, payload) { 441 | context.commit('deleteTime', payload) 442 | }, 443 | 444 | addDateEntry(context, payload) { 445 | context.commit('addDateEntry', payload) 446 | }, 447 | 448 | updateDateEntry(context, payload) { 449 | context.commit('updateDateEntry', payload) 450 | }, 451 | 452 | deleteDateEntry(context, payload) { 453 | context.commit('deleteDateEntry', payload) 454 | }, 455 | 456 | reorder(context, payload) { 457 | context.commit('reorder', payload) 458 | }, 459 | 460 | setWeeks(context, payload) { 461 | context.commit('updateWeeks', payload) 462 | }, 463 | 464 | setPath(context, payload) { 465 | context.commit('updatePath', payload) 466 | }, 467 | 468 | setInput(context, payload) { 469 | context.commit('updateInput', payload) 470 | }, 471 | 472 | setDuration(context, payload) { 473 | context.commit('updateDuration', payload) 474 | }, 475 | 476 | setSorting(context, payload) { 477 | context.commit('updateSorting', payload) 478 | }, 479 | 480 | setPlayoutSchedule(context) { 481 | context.commit('updatePlayoutSchedule') 482 | 483 | ipcRenderer.send('schedule', context.state.schedule) // TODO - verify this is the updated schedule (because it runs after .commit??) 484 | }, 485 | 486 | resetSchedule(context) { 487 | context.commit('resetSchedule') 488 | }, 489 | 490 | updatePlayoutState(context, payload) { 491 | context.commit('updatePlayoutState', payload) 492 | }, 493 | 494 | exportSchedule(context, filename) { 495 | const schedule = JSON.stringify(context.state.schedule, null, 2) 496 | fs.writeFile(filename, schedule, {}, () => undefined) 497 | }, 498 | 499 | importSchedule(context, filename) { 500 | try { 501 | const rawData = fs.readFileSync(filename) 502 | const schedule = JSON.parse(rawData) 503 | 504 | const makeWeekDaysNumbers = (el) => { 505 | if (el.days) { 506 | for (let i = 0; i < el.days.length; i++) { 507 | el.days.splice(i, 1, Number(el.days[i])) 508 | } 509 | } 510 | if (el.children) { 511 | for (const child of el.children) makeWeekDaysNumbers(child) 512 | } 513 | } 514 | for (const el of schedule) makeWeekDaysNumbers(el) 515 | 516 | context.commit('resetScheduleTo', schedule) 517 | } catch (e) { 518 | console.error(e) 519 | } 520 | }, 521 | 522 | // deprecated: 523 | settingsSetDecklink(context, input) { 524 | context.commit('settingsUpdateDecklink', input) 525 | }, 526 | 527 | settingsUpdate(context, input) { 528 | context.commit('settingsSet', { ...context.state.settings, ...input }) 529 | 530 | ipcRenderer.send('settings', { ...context.state.settings, ...input }) 531 | }, 532 | 533 | setReadableTimeline(context, tl) { 534 | context.commit('setReadableTimeline', tl) 535 | }, 536 | 537 | setDeviceState({ commit }, payload) { 538 | commit('setDeviceState', payload) 539 | }, 540 | 541 | removeDeviceState({ commit }, device) { 542 | commit('removeDeviceState', device) 543 | }, 544 | }, 545 | plugins: [storeState()], 546 | strict: process.env.NODE_ENV !== 'production', 547 | }) 548 | -------------------------------------------------------------------------------- /src/renderer/store/storeState.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Note that this is a pretty blatant copy from the vuex-electron project. That 3 | * project however suffers from a bug that has to do with the underlying storage 4 | * library used. Since I don't require any usage of the main process, I just 5 | * replace the storage with localStorage from the browser. 6 | */ 7 | import merge from 'deepmerge' 8 | 9 | const STORAGE_KEY = 'state' // Note - change this in assets/fatal.html too 10 | 11 | class PersistedState { 12 | constructor(options, store) { 13 | this.options = options 14 | this.store = store 15 | } 16 | 17 | loadOptions() { 18 | if (!this.options.storageKey) this.options.storageKey = STORAGE_KEY 19 | 20 | this.whitelist = this.loadFilter(this.options.whitelist, 'whitelist') 21 | this.blacklist = this.loadFilter(this.options.blacklist, 'blacklist') 22 | } 23 | 24 | getState() { 25 | return JSON.parse(window.localStorage.getItem(this.options.storageKey)) 26 | } 27 | 28 | setState(state) { 29 | window.localStorage.setItem(this.options.storageKey, JSON.stringify(state)) 30 | } 31 | 32 | loadFilter(filter, name) { 33 | if (!filter) { 34 | return null 35 | } else if (filter instanceof Array) { 36 | return this.filterInArray(filter) 37 | } else if (typeof filter === 'function') { 38 | return filter 39 | } else { 40 | throw new Error(`[Vuex Electron] Filter "${name}" should be Array or ' 41 | Function. Please, read the docs.`) 42 | } 43 | } 44 | 45 | filterInArray(list) { 46 | return (mutation) => { 47 | return list.includes(mutation.type) 48 | } 49 | } 50 | 51 | checkStorage() { 52 | if (!window || !window.localStorage) { 53 | throw new Error('Could not find ' + 'localStorage, which is required by storeState.js') 54 | } 55 | } 56 | 57 | combineMerge(target, source, options) { 58 | const emptyTarget = (value) => (Array.isArray(value) ? [] : {}) 59 | const clone = (value, options) => merge(emptyTarget(value), value, options) 60 | const destination = target.slice() 61 | 62 | source.forEach(function (e, i) { 63 | if (typeof destination[i] === 'undefined') { 64 | const cloneRequested = options.clone !== false 65 | const shouldClone = cloneRequested && options.isMergeableObject(e) 66 | destination[i] = shouldClone ? clone(e, options) : e 67 | } else if (options.isMergeableObject(e)) { 68 | destination[i] = merge(target[i], e, options) 69 | } else if (target.indexOf(e) === -1) { 70 | destination.push(e) 71 | } 72 | }) 73 | 74 | return destination 75 | } 76 | 77 | loadInitialState() { 78 | const state = this.getState(this.options.storage, this.options.storageKey) 79 | 80 | if (state) { 81 | const mergedState = merge(this.store.state, state, { arrayMerge: this.combineMerge }) 82 | this.store.replaceState(mergedState) 83 | } 84 | } 85 | 86 | subscribeOnChanges() { 87 | this.store.subscribe((mutation, state) => { 88 | if (this.blacklist && this.blacklist(mutation)) return 89 | if (this.whitelist && !this.whitelist(mutation)) return 90 | 91 | this.setState(state) 92 | }) 93 | } 94 | } 95 | 96 | export default (options = {}) => (store) => { 97 | const persistedState = new PersistedState(options, store) 98 | 99 | persistedState.loadOptions() 100 | persistedState.checkStorage() 101 | persistedState.loadInitialState() 102 | persistedState.subscribeOnChanges() 103 | } 104 | -------------------------------------------------------------------------------- /static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mint-dewit/folderplayout/c2852735a680dd360b97c639cd4f411c5b55bc5d/static/.gitkeep -------------------------------------------------------------------------------- /static/fatal.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Oops

4 |

5 | Unfortunately the app has fatally crashed and could not recover. Please proceed as follows. 6 | 7 |

15 |

16 | 17 |

18 | Use the following button to backup your schedule: 19 |

20 | 21 |

22 | Then use the following button to reset the app: 23 |

24 | 25 | 51 | 52 | --------------------------------------------------------------------------------