├── .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 | 
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Dashboard
9 | Schedule
10 | Settings
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | Device Status
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | Playlist
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
142 |
--------------------------------------------------------------------------------
/src/renderer/components/DashBoard/ProgressBar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
39 |
--------------------------------------------------------------------------------
/src/renderer/components/DashBoard/StatusText.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | NOW PLAYING: {{ playoutState.nowPlaying }}
5 |
6 |
7 | NEXT UP: {{ playoutState.nextUp }}
8 |
9 |
10 |
11 |
12 |
34 |
--------------------------------------------------------------------------------
/src/renderer/components/DashBoard/TimingComponent.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ curTime }}
5 |
6 |
7 | {{ countdown }}
8 |
9 |
10 |
11 |
12 |
44 |
45 |
50 |
--------------------------------------------------------------------------------
/src/renderer/components/EditSchedule.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Properties
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | type: {{ editObject.type }}
16 |
17 |
18 |
19 |
20 |
21 |
22 | Muted
23 |
24 |
25 |
26 |
27 | Path
28 |
29 |
30 |
31 | Name
32 |
33 |
34 |
35 |
36 | Playback order
37 |
38 |
39 |
40 | Input
41 |
42 |
43 |
44 | Duration
45 |
46 |
47 |
48 |
49 |
50 | Days
51 | {{ day.toUpperCase() }}
59 |
60 |
61 |
62 |
63 |
64 | Weeks
65 |
66 | Week numbers (separate with ,)
67 |
68 |
69 |
70 |
71 |
72 | Dates
73 |
74 |
75 |
76 |
77 |
78 |
79 |
81 |
87 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | Add daterange
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 | Times
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 | Add time
123 |
124 |
125 |
126 |
127 |
128 |
129 |
138 |
139 |
140 |
386 |
--------------------------------------------------------------------------------
/src/renderer/components/Schedule.vue:
--------------------------------------------------------------------------------
1 |
2 |
43 |
44 |
45 |
167 |
168 |
190 |
--------------------------------------------------------------------------------
/src/renderer/components/Settings.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Settings
7 |
8 |
9 |
10 |
13 |
14 |
15 |
16 |
17 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
31 |
32 |
33 |
34 |
37 |
38 |
39 |
40 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
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 |
8 | - Export the schedule and save it to a safe place
9 | - Factory reset the app, this will clear ALL settings and your schedule
10 | - Restart the app
11 | - Import the schedule from where you saved it
12 | - Before saving the schedule in the app, please double check EVERY entry on possible flaws
13 | - If the issues persist, contact the author with a copy of your schedule backup
14 |
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 |
--------------------------------------------------------------------------------