├── .babelrc ├── .gitignore ├── LICENSE.md ├── casparcg_output └── .gitignore ├── gulpfile.js ├── package.json ├── readme.md ├── src ├── CasparCGHelper.js ├── app.jsx ├── countdown_timer.html ├── countdown_timer.jsx └── themes │ └── base │ ├── css │ └── styles.less │ ├── js │ └── readme.md │ └── lib.json └── template_settings.png /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | # IDE 36 | .idea 37 | *.iws 38 | 39 | # Compiled files 40 | casparcg_output/* -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Blair 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /casparcg_output/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var browserify = require('browserify'); 3 | var babelify = require('babelify'); 4 | var source = require('vinyl-source-stream'); 5 | var less = require('gulp-less'); 6 | var watch = require('gulp-watch'); 7 | var path = require('path'); 8 | var glob = require('glob'); 9 | var gulpUtil = require('gulp-util'); 10 | var replace = require('gulp-replace'); 11 | var concat = require('gulp-concat'); 12 | var cssnano = require('gulp-cssnano'); 13 | var sourcemaps = require('gulp-sourcemaps'); 14 | var fs = require('fs'); 15 | var del = require('del'); 16 | var newer = require('gulp-newer'); 17 | var watchify = require('watchify'); 18 | var jsonfile = require('jsonfile'); 19 | 20 | var sourcePath = './src'; 21 | var destPath = './casparcg_output'; 22 | var themeBasePath = sourcePath + '/themes'; 23 | /** @var {string} The name of the theme to build for */ 24 | var themeName = 'base'; 25 | var themePath = themeBasePath + '/' + themeName; 26 | var cssPath = destPath + '/css'; 27 | var cssFile = 'main.css'; 28 | var filesToCopy = [ 29 | sourcePath + '/*.html' 30 | ]; 31 | 32 | /** 33 | * Makes the error pretty, and prevents watch from failing 34 | */ 35 | function handleBuildErrors() { 36 | var args = Array.prototype.slice.call(arguments); 37 | displayConsoleError('Error compiling!'); 38 | displayConsoleError(args.toString()); 39 | this.emit('end'); 40 | } 41 | 42 | function displayConsoleError(message) { 43 | gulpUtil.log(gulpUtil.colors.bgRed.white(" " + message + " ")); 44 | } 45 | 46 | function displayConsoleSuccess(message) { 47 | gulpUtil.log(gulpUtil.colors.bgGreen.black(" " + message + " ")); 48 | } 49 | 50 | function displayConsoleInfo(message) { 51 | gulpUtil.log(gulpUtil.colors.cyan(message)); 52 | } 53 | 54 | /** 55 | * Check that the user has provided a theme name, and that it is valid 56 | * @returns {boolean} 57 | */ 58 | function checkForTheme() { 59 | themeName = gulpUtil.env.theme; 60 | if (typeof themeName == 'undefined') { 61 | themeName = 'base'; 62 | } 63 | try { 64 | var dir = themeBasePath + '/' + themeName; 65 | var stats = fs.lstatSync(dir); 66 | if (stats.isDirectory()) { 67 | displayConsoleInfo('Using theme: "' + themeName + '"'); 68 | themePath = themeBasePath + '/' + themeName; 69 | return true; 70 | } 71 | } catch (e) { 72 | displayConsoleError('Error: Theme "' + themeName + '" does not exist at ' + dir); 73 | return false; 74 | } 75 | } 76 | 77 | function getFolders(dir) { 78 | return fs.readdirSync(dir) 79 | .filter(function (file) { 80 | return fs.statSync(path.join(dir, file)).isDirectory(); 81 | }); 82 | } 83 | 84 | gulp.task('default', function () { 85 | var themes = getThemes(themeBasePath); 86 | displayConsoleInfo('List of commands to use'); 87 | displayConsoleInfo(''); 88 | displayConsoleInfo('gulp build: Compiles the React and theme files and then watches for changes.'); 89 | displayConsoleInfo(''); 90 | displayConsoleInfo('Specify the theme that you want to use with: gulp build --theme ""'); 91 | displayConsoleInfo(' e.g. gulp build --theme "' + themes[Math.floor(Math.random() * themes.length)] + '"'); 92 | displayConsoleInfo(''); 93 | listThemes(); 94 | displayConsoleInfo(''); 95 | displayConsoleSuccess('Happy Coding!'); 96 | }); 97 | 98 | function getThemes(dir) { 99 | return getFolders(dir); 100 | } 101 | 102 | function listThemes() { 103 | var themes = getThemes(themeBasePath); 104 | displayConsoleInfo('List of themes available: "' + themes.join('", "') + '"'); 105 | } 106 | 107 | gulp.task('build', ['check-for-theme', 'clean', 'compile-scripts', 'copy-all', 'copy-lib', 'compile-styles'], function () { 108 | displayConsoleSuccess('Built. Check "' + destPath + '" for output.'); 109 | displayConsoleInfo('Watching for changes...'); 110 | gulp.watch(themePath + '/css/*.+(less|css)', ['compile-styles']).on('change', function(file) { 111 | var filename = file.path.split('/').pop(); 112 | displayConsoleInfo('Detected change in ' + filename + ', re-compiling...'); 113 | }); 114 | gulp.watch(filesToCopy.concat([themePath + '/!(css|js)/**']), ['copy-all']).on('change', function(file) { 115 | var filename = file.path.split('/').pop(); 116 | displayConsoleInfo('Detected change in ' + filename + ', re-compiling...'); 117 | }); 118 | }); 119 | 120 | gulp.task('compile-scripts', function () { 121 | var files = glob.sync(themePath + '/**/*.js'); 122 | files.unshift('./src/app.jsx'); 123 | var props = { 124 | entries: files, 125 | //extensions: ['.jsx'], 126 | debug: true, 127 | transform: babelify.configure(), 128 | cache: {}, 129 | packageCache: {}, 130 | fullPaths: true 131 | }; 132 | var bundler = watchify(browserify(props)); 133 | var jsFilePath = destPath + '/js'; 134 | var jsFileName = 'main.js'; 135 | 136 | function rebundle() { 137 | var stream = bundler.bundle(); 138 | return stream 139 | .on('error', handleBuildErrors) 140 | .pipe(source(jsFileName)) 141 | .pipe(gulp.dest(jsFilePath)); 142 | } 143 | 144 | // listen for an update and run rebundle 145 | bundler.on('update', function () { 146 | displayConsoleInfo('Changes detected, re-compiling...'); 147 | rebundle(); 148 | displayConsoleSuccess(jsFilePath + '/' + jsFileName + ' re-compiled!'); 149 | }); 150 | 151 | // run it once the first time buildScript is called 152 | return rebundle(); 153 | }); 154 | 155 | gulp.task('copy-all', false, function () { 156 | return gulp 157 | .src(filesToCopy.concat([themePath + '/!(css|js)/**'])) 158 | .pipe(newer(destPath)) 159 | .pipe(gulp.dest(destPath)); 160 | }); 161 | 162 | gulp.task('copy-lib', false, function () { 163 | return jsonfile.readFile(themePath + '/lib.json', function (err, obj) { 164 | if (obj) { 165 | if (obj.js && obj.js.length) { 166 | gulp.src(obj.js) 167 | .pipe(newer(destPath + '/js')) 168 | .pipe(gulp.dest(destPath + '/js')); 169 | } 170 | if (obj.css && obj.css.length) { 171 | gulp.src(obj.css) 172 | .pipe(newer(destPath + '/css')) 173 | .pipe(gulp.dest(destPath + '/css')); 174 | } 175 | if (obj.path && obj.path.length) { 176 | gulp.src(obj.path) 177 | .pipe(newer(destPath)) 178 | .pipe(gulp.dest(destPath)); 179 | } 180 | } 181 | }); 182 | }); 183 | 184 | gulp.task('check-for-theme', function (cb) { 185 | checkForTheme(); 186 | cb(); 187 | }); 188 | 189 | gulp.task('clean', function (cb) { 190 | del([destPath + '/*']); 191 | cb(); 192 | }); 193 | 194 | gulp.task('compile-styles', function () { 195 | return gulp.src(themePath + '/css/*.+(less|css)') 196 | .pipe(sourcemaps.init()) 197 | .pipe(less({ 198 | paths: [path.join(__dirname, 'less', 'includes')] 199 | }).on('error', handleBuildErrors)) 200 | .pipe(sourcemaps.write()) 201 | .pipe(concat(cssFile)) 202 | .pipe(gulp.dest(cssPath)); 203 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "countdown_timer", 3 | "version": "1.0.0", 4 | "description": "Countdown Timer Overlay for use in CasparCG", 5 | "private": true, 6 | "author": "Blair McMillan (blair@mcmillan.id.au)", 7 | "devDependencies": { 8 | "babel-preset-es2015": "^6.3.13", 9 | "babel-preset-react": "^6.3.13", 10 | "babel-preset-stage-0": "^6.3.13", 11 | "babelify": "^7.2.0", 12 | "browserify": "^12.0.1", 13 | "del": "^2.2.0", 14 | "glob": "^6.0.4", 15 | "gulp": "^3.9.0", 16 | "gulp-concat": "^2.6.0", 17 | "gulp-cssnano": "^2.1.0", 18 | "gulp-less": "^3.0.5", 19 | "gulp-newer": "^1.1.0", 20 | "gulp-replace": "^0.5.4", 21 | "gulp-sourcemaps": "^1.6.0", 22 | "gulp-util": "^3.0.7", 23 | "gulp-watch": "^4.3.5", 24 | "path": "^0.12.7", 25 | "jsonfile": "^2.2.3", 26 | "vinyl-source-stream": "^1.1.0", 27 | "watchify": "^3.7.0" 28 | }, 29 | "dependencies": { 30 | "classnames": "^2.2.3", 31 | "events": "^1.1.0", 32 | "react": "^0.14.6", 33 | "react-dom": "^0.14.6" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | countdown_timer 2 | ========= 3 | 4 | ## Installation 5 | 6 | 1. Install [Node.js](https://nodejs.org/en/download/) 7 | 1. Install Gulp 8 | - `npm install --global gulp` 9 | 1. Install required packages 10 | - `npm install` 11 | 1. Run gulp to list available comments 12 | - `gulp` 13 | 1. Run gulp build the build using the provided theme 14 | - `gulp build --theme "base"` 15 | 16 | ## Folder structure 17 | 18 | - casparcg_output 19 | - Copy the contents of this folder onto the CasparCG server 20 | - src 21 | - This is where the source to the CasparCG template lives. If you want to make changes to the Countdown Timer, this is where you should do it 22 | 23 | ## Themes 24 | 25 | Theme support is available. To make a new theme, duplicate an existing theme (e.g. "base") from the `./src/themes` directory and then make any required adjustments. Most likely you'll be wanting to edit `./src/themes//css/styles.less` to edit the font size, add a background image etc. 26 | 27 | - base 28 | - css `any css/less files in this directory will be merged into ./casparcg_output/css/main.css` 29 | - styles.less `the main styles for the theme` 30 | - images `any files in this directory will be copied into ./casparcg_output/images` 31 | - js `any js files in this directory will be merged into ./casparcg_output/js/main.js` 32 | - lib.json `Use this file to list any additional third-party libraries that you want to include. They will be copied as is into the appropriate build directory.` 33 | - * `any other folder will be copied into ./casparcg_output` 34 | 35 | ## CasparCG Client Settings 36 | 37 | Times should be passed through using either the `f0` or `time` key as number of seconds, or using the "HH:MM:SS" or "MM:SS" format. 38 | 39 | By default the template will hide itself at the end of the countdown, you can pass through `0` or `false` to the `f1` or `hideOnEnd` key to keep the template visible. 40 | 41 | ![](template_settings.png?raw=true) 42 | 43 | ## Debugging and Creating a New Theme 44 | 45 | While the primary purpose of the countdown-timer is for use with [CasparCG](http://www.casparcg.com/), you don't need to copy the changes to a CasparCG server each time you adjust a theme. You can view your changes by simply opening the *compiled* source (`./casparcg_output/countdown_timer.html`) in a modern browser (e.g. Chrome - since CasparCG is built on an old Chrome version). -------------------------------------------------------------------------------- /src/CasparCGHelper.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | class CasparCGHelper extends EventEmitter { 4 | 5 | constructor() { 6 | super(); 7 | 8 | /** 9 | * Set up our global methods called by CasparCG in a way that other modules can be triggered. 10 | */ 11 | let methods = ['play', 'update', 'stop', 'next']; 12 | for (let i = 0; i < methods.length; i++) { 13 | let method = methods[i]; 14 | if (typeof window[method] === 'function' && !(/\{\s*\[native code\]\s*\}/).test('' + window[method])) { 15 | let existingMethod = window[method]; 16 | this.on(method, existingMethod); 17 | } 18 | window[method] = this[method].bind(this); 19 | } 20 | } 21 | 22 | /** 23 | * Determines whether we are running inside CasparCG or a web browser by looking for the injected function via the CasparCG server 24 | * modules/html/html.cpp:OnContextCreated() 25 | * @param {boolean} populateWindowProperties Whether to populate `window.isCasparCG` and BODY class 26 | * @returns {boolean} Whether we are running inside CasparCG or not 27 | */ 28 | static isCasgparCG(populateWindowProperties) { 29 | let isCasparCG = typeof window !== 'undefined' && typeof window.tickAnimations !== 'undefined'; 30 | if (populateWindowProperties) { 31 | window.isCasparCG = isCasparCG; 32 | if (typeof document !== 'undefined') { 33 | document.getElementsByTagName('body')[0].classList.toggle('not-casparcg', !isCasparCG); 34 | } 35 | } 36 | return isCasparCG; 37 | }; 38 | 39 | /** 40 | * Called by CasparCG 41 | */ 42 | play() { 43 | this.emit('play'); 44 | }; 45 | 46 | /** 47 | * Called by CasparCG 48 | */ 49 | stop() { 50 | this.emit('stop'); 51 | }; 52 | 53 | /** 54 | * Called by CasparCG 55 | * @param {string} data 56 | */ 57 | update(data) { 58 | this.emit('update', CasparCGHelper.parseData(data)); 59 | }; 60 | 61 | /** 62 | * Called by CasparCG 63 | */ 64 | next() { 65 | this.emit('next'); 66 | }; 67 | 68 | /** 69 | * Attempts to parse template data passed in by CasparCG 70 | * @param data 71 | * @returns {Object} Template data in "key": "value" pairs or an empty object {} 72 | */ 73 | static parseData(data) { 74 | let values = {}; 75 | if (typeof data === 'object') { 76 | values = data; 77 | } else if (typeof data === 'string') { 78 | try { 79 | // Hopefully we were sent the data in JSON format 80 | values = JSON.parse(data); 81 | } catch (error) { 82 | // Maybe we were passed XML 83 | if (data && data.substr(0, 14) == '') { 84 | let parser = new DOMParser(); 85 | try { 86 | let xmlDoc = parser.parseFromString(data, "text/xml"); 87 | // We expect CasparCG to send us XML with "componentData" nodes 88 | let dataNodes = xmlDoc.getElementsByTagName('componentData'); 89 | for (let i = 0; i < dataNodes.length; i++) { 90 | let node = dataNodes[i]; 91 | let key = node.getAttribute('id'); 92 | let value = node.getElementsByTagName('data'); 93 | if (value.length) { 94 | value = value[0]; 95 | if (value) { 96 | value = value.getAttribute('value'); 97 | if (key && value) { 98 | // We are good, put the "key": "value" pair together 99 | values[key.trim()] = value.trim(); 100 | } 101 | } 102 | } 103 | } 104 | } catch (xmlError) { 105 | // Couldn't parse the data :( 106 | } 107 | } 108 | } 109 | } 110 | return values; 111 | }; 112 | } 113 | 114 | module.exports = CasparCGHelper; -------------------------------------------------------------------------------- /src/app.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import classNames from 'classnames'; 4 | import CountdownTimer from './countdown_timer.jsx'; 5 | import CasparCGHelper from './CasparCGHelper.js'; 6 | 7 | class App extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | 11 | let self = this; 12 | self.state = { 13 | time: '3:00', 14 | visible: false, 15 | hideOnEnd: true 16 | }; 17 | 18 | let casparCGHelper = new CasparCGHelper(); 19 | casparCGHelper.on('play', function () { 20 | self.setState({visible: true}); 21 | }); 22 | casparCGHelper.on('stop', function () { 23 | self.setState({visible: false}); 24 | }); 25 | casparCGHelper.on('update', function (data) { 26 | let partialState = {}; 27 | let time = 0; 28 | if (data.f0) { 29 | time = data.f0 + ''; 30 | } else if (data.time) { 31 | time = data.time + ''; 32 | } 33 | if (time) { 34 | partialState.time = time; 35 | } 36 | 37 | if (typeof data.f1 !== 'undefined') { 38 | partialState.hideOnEnd = data.f1; 39 | } else if (typeof data.hideOnEnd !== 'undefined') { 40 | partialState.hideOnEnd = data.hideOnEnd; 41 | } 42 | if (partialState.hideOnEnd) { 43 | // Make sure we are true/false 44 | if (typeof partialState.hideOnEnd === 'string') { 45 | partialState.hideOnEnd = (partialState.hideOnEnd.toLowerCase() === 'true' && partialState.hideOnEnd.toLowerCase() !== 'false' && !!partialState.hideOnEnd); 46 | } else { 47 | partialState.hideOnEnd = !!partialState.hideOnEnd; 48 | } 49 | } 50 | 51 | if (Object.keys(partialState).length) { 52 | self.setState(partialState); 53 | } 54 | }); 55 | if (!CasparCGHelper.isCasgparCG(true)) { 56 | if (typeof window.isCasparCG !== 'undefined' && !window.isCasparCG) { 57 | // Running in a browser, trigger some default values 58 | window.setTimeout(function () { 59 | casparCGHelper.emit('update', {'time': '3:00'}); 60 | casparCGHelper.emit('play'); 61 | }, 500); 62 | } 63 | } 64 | } 65 | 66 | onCountdownComplete() { 67 | if (this.state.hideOnEnd) { 68 | this.setState({visible: false}); 69 | } 70 | } 71 | 72 | render() { 73 | return ( 74 | 75 | ); 76 | } 77 | } 78 | 79 | ReactDOM.render( 80 | , 81 | document.getElementById('countdown') 82 | ); -------------------------------------------------------------------------------- /src/countdown_timer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Countdown Timer 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/countdown_timer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Generic Countdown Timer UI component 4 | // 5 | // Based on https://github.com/uken/react-countdown-timer 6 | // 7 | // props: 8 | // - initialTimeRemaining: Number|String 9 | // Number: The time remaining for the countdown (in seconds). 10 | // String: A countdown of format hh:mm:ss or mm:ss 11 | // 12 | // - showMinutes: Boolean 13 | // Whether to show minutes in the default formatFunc (overridden by the format passed into initialTimeRemaining if provided) 14 | // 15 | // - showHours: Boolean 16 | // Whether to show minutes in the default formatFunc (overridden by the format passed into initialTimeRemaining if provided) 17 | // 18 | // - interval: Number (optional -- default: 1000ms) 19 | // The time between timer ticks (in ms). 20 | // 21 | // - formatFunc(timeRemaining): Function (optional) 22 | // A function that formats the timeRemaining. 23 | // 24 | // - tickCallback(timeRemaining): Function (optional) 25 | // A function to call each tick. 26 | // 27 | // - completeCallback(): Function (optional) 28 | // A function to call when the countdown completes. 29 | // 30 | class CountdownTimer extends React.Component { 31 | 32 | constructor(props) { 33 | super(props); 34 | 35 | // Determine whether to show hours and minutes based on the properties passed in 36 | this.showMinutes = !!this.props.showMinutes && this.props.showMinutes !== 'false'; 37 | this.showHours = !!this.props.showHours && this.props.showHours !== 'false'; 38 | 39 | let timeRemaining = this.parseTimeString(this.props.initialTimeRemaining); 40 | let validTimeRemaining = !isNaN(timeRemaining); 41 | this.state = { 42 | timeRemaining: validTimeRemaining ? timeRemaining : 0, 43 | timeoutId: null, 44 | prevTime: null, 45 | visible: validTimeRemaining && this.props.visible 46 | }; 47 | 48 | this.tick = this.tick.bind(this); 49 | } 50 | 51 | static propTypes = { 52 | initialTimeRemaining: React.PropTypes.string.isRequired, 53 | interval: React.PropTypes.number, 54 | formatFunc: React.PropTypes.func, 55 | tickCallback: React.PropTypes.func, 56 | completeCallback: React.PropTypes.func 57 | }; 58 | 59 | static defaultProps = { 60 | interval: 1000, 61 | formatFunc: null, 62 | tickCallback: null, 63 | completeCallback: null, 64 | showMinutes: true, 65 | showHours: false 66 | }; 67 | 68 | /** 69 | * Parse a potential time string 70 | * @param {string|int} time The time in HH:MM:SS format or seconds 71 | * @returns {int} The time in milliseconds 72 | */ 73 | parseTimeString(time) { 74 | // Check to see whether we were passed only digits (seconds) 75 | if (!time.match(/[^\d]/) && !isNaN(parseInt(time, 10))) { 76 | return time * 1000; // Convert seconds to milliseconds 77 | } 78 | if (time.match(/:/)) { 79 | // Format is of type hh:mm:ss 80 | let segments = time.split(':'); 81 | time = 0; 82 | let seconds = segments.pop(); 83 | let minutes = segments.pop(); 84 | let hours = segments.pop(); 85 | if (seconds) { 86 | time += seconds * 1; 87 | } 88 | if (typeof minutes !== 'undefined') { 89 | this.showMinutes = true; // The format provided has minutes shown, so make sure we are showing them 90 | time += minutes * 60; 91 | } 92 | if (typeof hours !== 'undefined') { 93 | this.showHours = true; // The format provided has hours shown, so make sure we are showing them 94 | time += hours * 60 * 60; 95 | } 96 | } 97 | time = time * 1000; // Convert seconds to milliseconds 98 | return isNaN(time) ? 0 : time; 99 | } 100 | 101 | componentDidMount() { 102 | this.tick(); 103 | } 104 | 105 | componentWillReceiveProps(newProps) { 106 | if (this.state.timeoutId) { 107 | clearTimeout(this.state.timeoutId); 108 | } 109 | let timeRemaining = this.parseTimeString(newProps.initialTimeRemaining); 110 | this.setState({prevTime: null, timeRemaining: timeRemaining}); 111 | } 112 | 113 | componentDidUpdate() { 114 | if ((!this.state.prevTime) && this.state.timeRemaining > 0) { 115 | this.tick(); 116 | } 117 | } 118 | 119 | componentWillUnmount() { 120 | clearTimeout(this.state.timeoutId); 121 | } 122 | 123 | tick() { 124 | let currentTime = Date.now(); 125 | let dt = this.state.prevTime ? (currentTime - this.state.prevTime) : 0; 126 | let interval = this.props.interval; 127 | 128 | // correct for small variations in actual timeout time 129 | let timeRemainingInInterval = (interval - (dt % interval)); 130 | let timeout = timeRemainingInInterval; 131 | 132 | if (timeRemainingInInterval < (interval / 2.0)) { 133 | timeout += interval; 134 | } 135 | 136 | let timeRemaining = Math.max(this.state.timeRemaining - dt, 0); 137 | let countdownComplete = (this.state.prevTime && timeRemaining <= 0); 138 | 139 | if (this.state.timeoutId) { 140 | clearTimeout(this.state.timeoutId); 141 | } 142 | this.setState({ 143 | timeoutId: countdownComplete ? null : setTimeout(this.tick, timeout), 144 | prevTime: currentTime, 145 | timeRemaining: timeRemaining 146 | }); 147 | 148 | if (countdownComplete) { 149 | if (this.props.completeCallback) { 150 | this.props.completeCallback(); 151 | } 152 | return; 153 | } 154 | 155 | if (this.props.tickCallback) { 156 | this.props.tickCallback(timeRemaining); 157 | } 158 | } 159 | 160 | getFormattedTime(milliseconds) { 161 | if (this.props.formatFunc) { 162 | return this.props.formatFunc(milliseconds); 163 | } 164 | 165 | let totalSeconds = Math.round(milliseconds / 1000); 166 | 167 | let seconds = parseInt(totalSeconds % 60, 10); 168 | let minutes = parseInt(totalSeconds / 60, 10) % 60; 169 | let hours = parseInt(totalSeconds / 3600, 10); 170 | 171 | seconds = seconds < 10 ? '0' + seconds : seconds; 172 | minutes = minutes < 10 ? '0' + minutes : minutes; 173 | hours = hours < 10 ? '0' + hours : hours; 174 | 175 | let response = ''; 176 | if (this.showHours) { 177 | response += hours + ':'; 178 | } 179 | if (this.showHours || this.showMinutes) { 180 | response += minutes + ':'; 181 | } 182 | response += seconds; 183 | 184 | return response; 185 | } 186 | 187 | render() { 188 | let style = {}; 189 | if (!this.props.visible) { 190 | style.display = 'none'; 191 | } 192 | return ( 193 | 194 | {this.getFormattedTime(this.state.timeRemaining)} 195 | 196 | ); 197 | } 198 | } 199 | 200 | module.exports = CountdownTimer; -------------------------------------------------------------------------------- /src/themes/base/css/styles.less: -------------------------------------------------------------------------------- 1 | /* 2 | START Main configuration values 3 | */ 4 | 5 | @padding-size: 20px; 6 | 7 | /* 8 | END Main configuration values 9 | */ 10 | 11 | // give us a background colour when we aren't running in CasparCG 12 | body.not-casparcg { 13 | background: black; 14 | } 15 | 16 | .hide { 17 | visibility: hidden; 18 | } 19 | 20 | #countdown .timer { 21 | background: #3cb8ff; 22 | border: 1px solid #000000; 23 | padding: (@padding-size / 2) @padding-size; 24 | position: absolute; 25 | bottom: 10%; 26 | right: 10%; 27 | } 28 | 29 | .vertical-align { 30 | display: block; 31 | position: relative; 32 | top: 50%; 33 | -webkit-transform: translateY(-50%); 34 | transform: translateY(-50%); 35 | } 36 | -------------------------------------------------------------------------------- /src/themes/base/js/readme.md: -------------------------------------------------------------------------------- 1 | Place any javascript files that your theme needs into this folder. 2 | 3 | They will be bundled up and included in `js/main.js` as part of the build. 4 | 5 | *If you add or remove files from this directory, you will need to re-run `gulp build` as it will not detect new files. Changes to **existing** files will be detected.* -------------------------------------------------------------------------------- /src/themes/base/lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "information": "Use this file to list any additional third-party libraries that you want to include. They will be copied as is into the appropriate build directory.", 3 | "css": [], 4 | "js": [], 5 | "folder": [] 6 | } -------------------------------------------------------------------------------- /template_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sneat/casparcg-countdown-timer/edc6ff35189a7467bb66dcfdb41421ad3e716475/template_settings.png --------------------------------------------------------------------------------