├── .gitignore ├── dist ├── whitewater.zip ├── whitewater.min.js ├── whitewater.min.js.map └── whitewater.js ├── package.json ├── LICENSE.txt ├── source ├── whitewater.worker.js └── whitewater.js ├── Gruntfile.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac 2 | .DS_STORE 3 | 4 | # Node 5 | node_modules 6 | .sass-cache 7 | -------------------------------------------------------------------------------- /dist/whitewater.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samiare/whitewater-mobile-video/HEAD/dist/whitewater.zip -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whitewater-player", 3 | "version": "1.0.0", 4 | "description": "Decodes video files created by Whitewater Encoder.", 5 | "author": "Samir Zahran", 6 | "devDependencies": { 7 | "grunt": "^0.4.5", 8 | "grunt-contrib-clean": "^0.7.0", 9 | "grunt-contrib-compress": "^1.3.0", 10 | "grunt-contrib-jshint": "^0.10.0", 11 | "grunt-contrib-uglify": "^0.5.1", 12 | "grunt-include-replace": "^3.2.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Samir Zahran 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 | -------------------------------------------------------------------------------- /source/whitewater.worker.js: -------------------------------------------------------------------------------- 1 | 2 | workerIsIncluded = true; 3 | 4 | onmessage = function(e) { 5 | var frames = e.data; 6 | var videoData = []; 7 | 8 | for (var i = 0; i < frames.length; i++) { 9 | var frame = frames[i]; 10 | var frameData = []; 11 | 12 | if (frame !== "") { 13 | var map = frame.match(/.{1,5}/g); 14 | var mapLength = map.length; 15 | 16 | for (var j = 0; j < mapLength; j++) { 17 | var position = toBase10(map[j].substr(0, 3)); 18 | var consecutive = toBase10(map[j].substr(3, 2)); 19 | 20 | frameData.push([position, consecutive]); 21 | } 22 | } 23 | 24 | videoData.push(frameData); 25 | } 26 | 27 | postMessage(videoData); 28 | }; 29 | 30 | function toBase10(val) { 31 | var order = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; 32 | var num = 0, r; 33 | while (val.length) { 34 | r = order.indexOf(val.charAt(0)); 35 | val = val.substr(1); 36 | num *= 64; 37 | num += r; 38 | } 39 | return num; 40 | } 41 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | 3 | grunt.initConfig({ 4 | 5 | pkg: grunt.file.readJSON('package.json'), 6 | 7 | includereplace: { 8 | build: { 9 | src: 'source/whitewater.js', 10 | dest: 'dist/whitewater.js', 11 | options: { 12 | prefix: '// @@' 13 | } 14 | } 15 | }, 16 | 17 | uglify: { 18 | options: { 19 | banner: '/*! <%= pkg.name %> - v<%= pkg.version %> - <%= grunt.template.today("yyyy-mm-dd") %> */\n', 20 | footer: '\n', 21 | sourceMap: true 22 | }, 23 | build: { 24 | src: 'dist/whitewater.js', 25 | dest: 'dist/whitewater.min.js' 26 | } 27 | }, 28 | 29 | clean: { 30 | build: ['dist/*'] 31 | }, 32 | 33 | jshint: { 34 | files: ['gruntfile.js', 'source/*.js'], 35 | options: { 36 | validthis: true 37 | } 38 | }, 39 | 40 | compress: { 41 | build: { 42 | options: { 43 | archive: 'dist/whitewater.zip', 44 | mode: 'zip' 45 | }, 46 | files: [{ 47 | cwd: 'dist/', 48 | expand: true, 49 | src: '*' 50 | }] 51 | } 52 | } 53 | 54 | }); 55 | 56 | 57 | grunt.loadNpmTasks('grunt-contrib-uglify'); 58 | grunt.loadNpmTasks('grunt-contrib-jshint'); 59 | grunt.loadNpmTasks('grunt-contrib-clean'); 60 | grunt.loadNpmTasks('grunt-contrib-compress'); 61 | grunt.loadNpmTasks('grunt-include-replace'); 62 | 63 | grunt.registerTask('hint', ['jshint']); 64 | grunt.registerTask('build', ['clean:build', 'includereplace:build', 'uglify', 'compress:build']); 65 | grunt.registerTask('compile', ['includereplace:build', 'uglify']); 66 | 67 | }; 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### :warning: IMPORTANT UPDATE :warning: 2 | 3 | This project (and the corresponding [encoder](https://github.com/samiare/whitewater-encoder)) are **no longer maintained**. 4 | 5 | This was an enormously fun project to create, but I never had adequate time for updates and it was made almost immediately obsolete by the adoption of auto-play HTML5 video in most mobile web browsers soon after it's initial release. 6 | 7 | # [Whitewater Mobile Video](http://samiare.github.io/whitewater-mobile-video/) 8 | 9 | A new encoding system for playing inline videos on the mobile web. 10 | 11 | 1. **Whitewater Player** 12 | 13 | A Javascript library for playing videos prepared by encoder (standard video files will not work). It offer a flexible API with playback methods, data about the current video and events. 14 | 15 | 2. **Whitewater Encoder** 16 | 17 | A command line tool and Python module that encodes videos into a bundle of files that can be read by the player to recrate video in an HTML `` tag. 18 | 19 | **→ [View on GitHub](https://github.com/samiare/whitewater-encoder)** 20 | 21 | 22 | # Features and Limitations 23 | 24 | ## 😀 25 | 26 | - **Can** play, pause and stop video on mobile 27 | - **Can** slow down video 28 | - **Can** expose DOM events for developer use 29 | - **Can** encode videos with various compression settings 30 | - **Can** be used as a Python module in your own programs 31 | 32 | ## 😞 33 | 34 | - **Cannot** seek to arbitrary points within a video 35 | - **Cannot** play in reverse 36 | - **Cannot** play audio 37 | 38 | 39 | # Quick Start 40 | 41 | ## Manual Download 42 | 43 | 1. [Download the latest build](https://github.com/samiare/whitewater-mobile-video/releases/latest) 44 | 2. Unpack `whitewater.zip` it and copy `whitewater.min.js` into your project files 45 | 3. Include `whitewater.min.js` at the end of your ``: 46 | `` 47 | 4. Initialize an instance of `Whitewater()`. 48 | 49 | >Note: To play videos, they must first be encoded with [Whitewater Encoder](https://github.com/samiare/whitewater-encoder). 50 | 51 | 52 | ## Initializing Videos 53 | 54 | ```javascript 55 | var video = new Whitewater(canvas, source [, options]); 56 | ``` 57 | 58 | **Example** 59 | 60 | ```html 61 | 62 | 63 | 73 | ``` 74 | 75 | → Initialization options and usage details can be found in the **[Player Documentation](https://github.com/samiare/whitewater-mobile-video/wiki)**. 76 | -------------------------------------------------------------------------------- /dist/whitewater.min.js: -------------------------------------------------------------------------------- 1 | /*! whitewater-player - v1.0.0 - 2016-07-13 */ 2 | 3 | function Whitewater(a,b,c){"use strict";function d(){this.canvas.play=this.play,this.canvas.pause=this.pause,this.canvas.playpause=this.playpause,this.canvas.stop=this.stop}function e(){"hidden"in document?(L="hidden",document.addEventListener("visibilitychange",u.bind(this),!1)):"mozHidden"in document?(L="mozHidden",document.addEventListener("mozvisibilitychange",u.bind(this),!1)):"msHidden"in document?(L="msHidden",document.addEventListener("msvisibilitychange",u.bind(this),!1)):"webkitHidden"in document?(L="webkitHidden",document.addEventListener("webkitvisibilitychange",u.bind(this),!1)):"onfocusin"in document?(document.addEventListener("focusin",u.bind(this,!1),!1),document.addEventListener("focusout",u.bind(this,!0),!1)):"onpageshow"in window?(window.addEventListener("pageshow",u.bind(this,!1),!1),window.addEventListener("pagehide",u.bind(this,!0),!1)):(window.addEventListener("focus",u.bind(this,!1),!1),window.addEventListener("blur",u.bind(this,!0),!1))}function f(){if(D++,D>K.imagesRequired){this.canvas.setAttribute("data-state","ready"),this.state="ready";var a=new CustomEvent("whitewaterload",h.call(this));this.canvas.dispatchEvent(a),c.autoplay&&this.play()}}function g(){var a=null;a=0===this.currentFrame?E:i(H[this.currentFrame-1]),A.drawImage(a,0,0),this.currentFrame++,r.call(this)}function h(){return{detail:{video:this,currentFrame:this.currentFrame,progress:this.progress,timestamp:this.timestamp,maxTime:this.maxTime,state:this.state,secondsElapsed:this.secondsElapsed},bubbles:!0,cancelable:!1}}function i(a){var b=document.createElement("canvas");b.width=K.videoWidth,b.height=K.videoHeight;for(var c=0;c=K.sourceGrid&&(C.x=0,C.y++,C.y>=K.sourceGrid&&(C.y=0,B++,B>=F.length)))throw"imageIndex exceeded diffImages.length\n\nmapLength = "+a.length+"\nj = "+c}return b}function j(){var a=this;E.addEventListener("load",function(){f.call(a),q.call(a)},!1),E.src=J+"first."+K.format;for(var b=1;b<=K.imagesRequired;b++){var c=new Image;c.addEventListener("load",f.bind(this),!1),b>99?c.src=J+"diff_"+b+"."+K.format:b>9?c.src=J+"diff_0"+b+"."+K.format:c.src=J+"diff_00"+b+"."+K.format,F.push(c)}}function k(a){function b(){try{I=JSON.parse(f.responseText)}catch(a){return void this.constructor._throwError(a)}t.call(this),s.call(this);var b=null,d=function(){function a(a){for(var b,c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",d=0;a.length;)b=c.indexOf(a.charAt(0)),a=a.substr(1),d*=64,d+=b;return d}var b=!1;return b=!0,onmessage=function(b){for(var c=b.data,d=[],e=0;e=f){if(N.currentFrame settings.imagesRequired) { 82 | 83 | this.canvas.setAttribute('data-state', 'ready'); 84 | this.state = 'ready'; 85 | 86 | var loadEvent = new CustomEvent('whitewaterload', getEventOptions.call(this)); 87 | this.canvas.dispatchEvent(loadEvent); 88 | 89 | if (options.autoplay) { 90 | this.play(); 91 | } 92 | 93 | } 94 | 95 | } 96 | 97 | function drawFrame() { 98 | 99 | var frameToDraw = null; 100 | 101 | if (this.currentFrame === 0) { 102 | frameToDraw = firstImage; 103 | } else { 104 | frameToDraw = getPrecompositedFrame(frames[this.currentFrame - 1]); 105 | } 106 | 107 | context.drawImage(frameToDraw, 0, 0); 108 | this.currentFrame++; 109 | setProgress.call(this); 110 | 111 | } 112 | 113 | function getEventOptions() { 114 | return { 115 | detail: { 116 | video: this, 117 | currentFrame: this.currentFrame, 118 | progress: this.progress, 119 | timestamp: this.timestamp, 120 | maxTime: this.maxTime, 121 | state: this.state, 122 | secondsElapsed: this.secondsElapsed 123 | }, 124 | bubbles: true, 125 | cancelable: false 126 | }; 127 | } 128 | 129 | function getPrecompositedFrame(frameToRender) { 130 | 131 | var buffer = document.createElement('canvas'); 132 | buffer.width = settings.videoWidth; 133 | buffer.height = settings.videoHeight; 134 | 135 | for (var j = 0; j < frameToRender.length; j++) { 136 | var position = frameToRender[j][0]; 137 | var consecutive = frameToRender[j][1]; 138 | var positionArray = getCoordinatesFromPosition(position); 139 | var chunkWidth = consecutive * settings.blockSize; 140 | 141 | buffer.getContext('2d').drawImage( 142 | diffImages[imageIndex], 143 | coordinates.x * settings.blockSize, 144 | coordinates.y * settings.blockSize, 145 | chunkWidth, 146 | settings.blockSize, 147 | positionArray[0] * settings.blockSize, 148 | positionArray[1] * settings.blockSize, 149 | chunkWidth, 150 | settings.blockSize 151 | ); 152 | 153 | coordinates.x += consecutive; 154 | if (coordinates.x >= settings.sourceGrid) { 155 | // Jump to next row 156 | coordinates.x = 0; 157 | coordinates.y++; 158 | if (coordinates.y >= settings.sourceGrid) { 159 | // Jump to next diffmap 160 | coordinates.y = 0; 161 | imageIndex++; 162 | if (imageIndex >= diffImages.length) { 163 | throw 'imageIndex exceeded diffImages.length\n\nmapLength = ' + frameToRender.length + '\nj = ' + j; 164 | } 165 | } 166 | } 167 | } 168 | 169 | return buffer; 170 | 171 | } 172 | 173 | function loadRequiredImages() { 174 | var Video = this; 175 | firstImage.addEventListener('load', function() { 176 | checkImagesLoaded.call(Video); 177 | setPosterImage.call(Video); 178 | }, false); 179 | firstImage.src = path + 'first.' + settings.format; 180 | 181 | for (var i = 1; i <= settings.imagesRequired; i++) { 182 | var image = new Image(); 183 | image.addEventListener('load', checkImagesLoaded.bind(this), false); 184 | if (i > 99) { 185 | image.src = path + 'diff_' + i + '.' + settings.format; 186 | } else if (i > 9) { 187 | image.src = path + 'diff_0' + i + '.' + settings.format; 188 | } else { 189 | image.src = path + 'diff_00' + i + '.' + settings.format; 190 | } 191 | diffImages.push(image); 192 | } 193 | 194 | } 195 | 196 | function parseManifestFile(callbacks) { 197 | 198 | var Video = this; 199 | var request = new XMLHttpRequest(); 200 | 201 | request.open('GET', path + 'manifest.json', false); 202 | request.addEventListener('load', onManifestLoad.bind(this), false); 203 | request.addEventListener('error', onManifestError.bind(this), false); 204 | request.send(); 205 | 206 | function onManifestLoad() { 207 | 208 | try { 209 | manifest = JSON.parse(request.responseText); 210 | } catch(error) { 211 | this.constructor._throwError(error); 212 | return; 213 | } 214 | 215 | setVideoOptions.call(this); 216 | setSize.call(this); 217 | 218 | var myWorker = null; 219 | 220 | var webWorker = function() { 221 | var workerIsIncluded = false; 222 | // @@include('whitewater.worker.js') 223 | return workerIsIncluded; 224 | }; 225 | 226 | if (webWorker()) { 227 | 228 | var URL = window.URL || window.webkitURL; 229 | var workerBlob = new Blob(['(' + webWorker.toString() + ')()'], { 230 | type: 'text/javascript' 231 | }); 232 | 233 | myWorker = new Worker(URL.createObjectURL(workerBlob)); 234 | 235 | } else { 236 | 237 | myWorker = new Worker('whitewater.worker.js'); 238 | 239 | } 240 | 241 | myWorker.postMessage(manifest.frames); 242 | 243 | myWorker.onmessage = function(event) { 244 | frames = event.data; 245 | myWorker.terminate(); 246 | 247 | for (var i = 0; i < callbacks.length; i++) { 248 | callbacks[i](); 249 | } 250 | 251 | if (options.controls) { 252 | setPlayPauseControls.call(Video); 253 | } 254 | }; 255 | 256 | } 257 | 258 | function onManifestError() { 259 | 260 | try { 261 | throw this.constructor.errors.MANIFEST; 262 | } catch(error) { 263 | this.constructor._throwError(error); 264 | return; 265 | } 266 | 267 | } 268 | 269 | } 270 | 271 | function resetVideo() { 272 | 273 | imageIndex = 0; 274 | coordinates.x = 0; 275 | coordinates.y = 0; 276 | this.currentFrame = 0; 277 | 278 | context.clearRect(0, 0, settings.videoWidth, settings.videoHeight); 279 | 280 | } 281 | 282 | function setCanvasElement() { 283 | 284 | if (canvas instanceof HTMLCanvasElement) { 285 | this.canvas = canvas; 286 | context = this.canvas.getContext('2d'); 287 | } else { 288 | throw this.constructor.errors.CANVAS; 289 | } 290 | 291 | } 292 | 293 | function setFilePath() { 294 | 295 | if (typeof inputPath === 'string') { 296 | path = inputPath; 297 | if (inputPath.substr(-1) !== '/') { 298 | path += '/'; 299 | } 300 | } else { 301 | throw this.constructor.errors.PATH; 302 | } 303 | 304 | } 305 | 306 | function setOptions() { 307 | 308 | if (options) { 309 | 310 | var speed = 1; 311 | if (options.speed && options.speed < 1) { 312 | speed = options.speed; 313 | } 314 | 315 | options = { 316 | loop: options.loop || false, 317 | autoplay: options.autoplay || false, 318 | controls: options.controls || false, 319 | speed: speed 320 | }; 321 | 322 | } 323 | 324 | } 325 | 326 | function setPlayPauseControls() { 327 | var element = this.canvas; 328 | 329 | if (typeof options.controls !== 'boolean') { 330 | element = options.controls; 331 | } 332 | 333 | var clickEvent = getClickEvent(); 334 | element.addEventListener(clickEvent, this.playpause); 335 | } 336 | 337 | function setPosterImage() { 338 | 339 | var src = firstImage.src; 340 | var top = this.canvas.style.paddingTop; 341 | 342 | this.canvas.style.background = 'transparent url(' + src + ') no-repeat center ' + top; 343 | this.canvas.style.backgroundSize = 'contain'; 344 | 345 | } 346 | 347 | function setProgress() { 348 | 349 | this.progress = getNumberWithDecimals(this.currentFrame / settings.frameCount * 100, 3); 350 | 351 | var currentTime = this.currentFrame / settings.framesPerSecond; 352 | this.timestamp = getFormattedTime(currentTime); 353 | this.secondsElapsed = getNumberWithDecimals(currentTime, 3); 354 | 355 | var playingEvent = new CustomEvent('whitewaterprogressupdate', getEventOptions.call(this)); 356 | this.canvas.dispatchEvent(playingEvent); 357 | 358 | } 359 | 360 | function setSize() { 361 | this.canvas.setAttribute('width', settings.videoWidth + 'px'); 362 | this.canvas.setAttribute('height', settings.videoHeight + 'px'); 363 | } 364 | 365 | function setVideoOptions() { 366 | var format = ''; 367 | 368 | switch (manifest.format) { 369 | case 'JPEG': 370 | format = 'jpg'; 371 | break; 372 | case 'PNG': 373 | format = 'png'; 374 | break; 375 | case 'GIF': 376 | format = 'gif'; 377 | break; 378 | default: 379 | format = 'jpg'; 380 | break; 381 | } 382 | 383 | settings = { 384 | videoWidth: manifest.videoWidth, 385 | videoHeight: manifest.videoHeight, 386 | imagesRequired: manifest.imagesRequired, 387 | frameCount: manifest.frameCount - 1, 388 | blockSize: manifest.blockSize, 389 | sourceGrid: manifest.sourceGrid, 390 | framesPerSecond: Math.round(manifest.framesPerSecond), 391 | format: format 392 | }; 393 | 394 | var lengthInSeconds = settings.frameCount / settings.framesPerSecond; 395 | this.maxTime = getFormattedTime(lengthInSeconds); 396 | } 397 | 398 | function softPause(hidden) { 399 | if (hidden !== undefined) { 400 | documentHidden = hidden; 401 | } 402 | 403 | if ((document[hiddenProperty] || documentHidden === true) && Video.state === 'playing') { 404 | this.state = 'suspended'; 405 | this.pause(); 406 | } else if (Video.state === 'suspended') { 407 | this.play(); 408 | } 409 | } 410 | 411 | function init() { 412 | try { 413 | setCanvasElement.call(this); 414 | setFilePath(); 415 | setOptions(); 416 | 417 | var callAfterManifest = [ 418 | loadRequiredImages.bind(this), 419 | addCanvasMethods.bind(this), 420 | addVisibilityListener.bind(this) 421 | ]; 422 | 423 | parseManifestFile.call(this, callAfterManifest); 424 | 425 | } catch(error) { 426 | this.constructor._throwError(error); 427 | return; 428 | } 429 | 430 | } 431 | 432 | 433 | // Helper Functions 434 | 435 | function getClickEvent() { 436 | var isTouchDevice = 'ontouchstart' in document.documentElement; 437 | //var startEvent = isTouchDevice ? 'touchstart' : 'mousedown'; 438 | var endEvent = isTouchDevice ? 'touchend' : 'mouseup'; 439 | 440 | return endEvent; 441 | } 442 | 443 | function getCoordinatesFromPosition(position) { 444 | 445 | var coordinates = []; 446 | var columns = Math.ceil(settings.videoWidth / settings.blockSize); 447 | 448 | if (position < columns) { 449 | coordinates = [position, 0]; 450 | } else { 451 | coordinates = [position % columns, Math.floor(position / columns)]; 452 | } 453 | 454 | return coordinates; 455 | 456 | } 457 | 458 | function getFormattedTime(time) { 459 | 460 | var minutes = Math.floor(time / 60); 461 | var seconds = Math.floor(time % 60); 462 | var milliseconds = Math.floor((time % 60 % 1) * 1000); 463 | 464 | if (minutes < 10) { 465 | minutes = '0' + minutes; 466 | } 467 | 468 | if (seconds < 10) { 469 | seconds = '0' + seconds; 470 | } 471 | 472 | if (milliseconds < 10) { 473 | milliseconds = '00' + milliseconds; 474 | } else if (milliseconds < 100) { 475 | milliseconds = '0' + milliseconds; 476 | } 477 | 478 | return minutes + ':' + seconds + '.' + milliseconds; 479 | 480 | } 481 | 482 | function getNumberWithDecimals(number, digits) { 483 | var multiplier = Math.pow(10, digits); 484 | return Math.round(number * multiplier) / multiplier; 485 | } 486 | 487 | 488 | // Public Functions 489 | 490 | var Video = this; 491 | 492 | this.pause = function() { 493 | if (Video.state === 'paused') { 494 | return; 495 | } 496 | 497 | if (Video.state !== 'suspended') { 498 | Video.canvas.setAttribute('data-state', 'paused'); 499 | Video.state = 'paused'; 500 | 501 | var pauseEvent = new CustomEvent('whitewaterpause', getEventOptions.call(Video)); 502 | Video.canvas.dispatchEvent(pauseEvent); 503 | } 504 | 505 | cancelAnimationFrame(animationFrame); 506 | }; 507 | 508 | this.play = function() { 509 | if (Video.state === 'playing') { 510 | return; 511 | } else if (Video.state === 'ended') { 512 | resetVideo.call(Video); 513 | } 514 | 515 | var resume = Video.state === 'suspended'; 516 | 517 | Video.canvas.setAttribute('data-state', 'playing'); 518 | Video.state = 'playing'; 519 | 520 | if (!resume) { 521 | var playEvent = new CustomEvent('whitewaterplay', getEventOptions.call(Video)); 522 | Video.canvas.dispatchEvent(playEvent); 523 | } 524 | 525 | var milliseconds = 1 / settings.framesPerSecond * 1000; 526 | var interval = getNumberWithDecimals(milliseconds / options.speed, 2); 527 | var previousTime = window.performance.now(); 528 | 529 | animate(previousTime); 530 | 531 | function animate(currentTime) { 532 | 533 | var timeSinceLastDraw = currentTime - previousTime; 534 | 535 | if (timeSinceLastDraw >= interval) { 536 | 537 | if (Video.currentFrame < settings.frameCount + 1) { 538 | 539 | drawFrame.call(Video); 540 | 541 | } else if (options.loop) { 542 | 543 | resetVideo.call(Video); 544 | drawFrame.call(Video); 545 | 546 | var loopEvent = new CustomEvent('whitewaterloop', getEventOptions.call(Video)); 547 | Video.canvas.dispatchEvent(loopEvent); 548 | 549 | } else { 550 | 551 | Video.stop(); 552 | 553 | Video.canvas.setAttribute('data-state', 'ended'); 554 | Video.state = 'ended'; 555 | 556 | var endEvent = new CustomEvent('whitewaterend', getEventOptions.call(Video)); 557 | Video.canvas.dispatchEvent(endEvent); 558 | 559 | } 560 | 561 | var lag = timeSinceLastDraw - interval; 562 | previousTime = currentTime - lag; 563 | 564 | } 565 | 566 | if (!(document[hiddenProperty] || documentHidden === true) && Video.state === 'playing') { 567 | animationFrame = requestAnimationFrame(animate); 568 | } 569 | } 570 | }; 571 | 572 | this.playpause = function() { 573 | 574 | if (Video.state === 'playing') { 575 | Video.pause(); 576 | } else if (Video.state !== 'loading') { 577 | Video.play(); 578 | } 579 | }; 580 | 581 | this.stop = function() { 582 | 583 | if (Video.state === 'ready') { 584 | return; 585 | } 586 | 587 | Video.canvas.setAttribute('data-state', 'ready'); 588 | Video.state = 'ready'; 589 | 590 | var stopEvent = new CustomEvent('whitewaterend', getEventOptions.call(Video)); 591 | Video.canvas.dispatchEvent(stopEvent); 592 | 593 | cancelAnimationFrame(animationFrame); 594 | 595 | resetVideo.call(Video); 596 | setProgress.call(Video); 597 | 598 | }; 599 | 600 | } 601 | 602 | Whitewater.errors = { 603 | pre : 'Whitewater: ', 604 | MISC : 'Whatever.', 605 | WEBWORKERS : 'This browser does not support Web Workers.', 606 | BLOBCONSTRUCTOR : 'This browser does not support the Blob() constructor.', 607 | VISIBILITYAPI : 'This browser does not support the Visiblity API', 608 | CANVAS : '"canvas" must be a valid HTML canvas element.', 609 | PATH : '"path" must be a path to a directory containing a manifest.json file', 610 | MANIFEST : 'A manifest.json file could not be found.' 611 | }; 612 | 613 | Whitewater._checkSupport = function() { 614 | try { 615 | if (!window.Blob) { 616 | throw this.errors.WEBWORKERS; 617 | } else if (!window.Worker) { 618 | throw this.errors.BLOBCONSTRUCTOR; 619 | } else if (!(('hidden' in document) || 620 | ('mozHidden' in document) || 621 | ('msHidden' in document) || 622 | ('webkitHidden' in document))) { 623 | throw this.errors.VISIBILITYAPI; 624 | } else { 625 | return true; 626 | } 627 | } catch(error) { 628 | this._throwError(error); 629 | return false; 630 | } 631 | }; 632 | 633 | Whitewater._throwError = function(error) { 634 | console.warn(this.errors.pre + error); 635 | }; 636 | 637 | Whitewater.supported = Whitewater._checkSupport(); 638 | -------------------------------------------------------------------------------- /dist/whitewater.js: -------------------------------------------------------------------------------- 1 | // Whitewater object 2 | function Whitewater(canvas, inputPath, options) { 3 | "use strict"; 4 | 5 | 6 | var context = null; 7 | var imageIndex = 0; 8 | var coordinates = {x: 0, y: 0}; 9 | var imagesLoaded = 0; 10 | var firstImage = new Image(); 11 | var diffImages = []; 12 | var animationFrame = null; 13 | var frames = null; 14 | var manifest = null; 15 | var path = ''; 16 | var settings = {}; 17 | 18 | // Page Visibility API compatibility 19 | var hiddenProperty = null; 20 | var documentHidden = false; 21 | 22 | 23 | // Public members 24 | 25 | this.state = 'loading'; 26 | this.currentFrame = 0; 27 | this.progress = 0; 28 | this.timestamp = '00:00.000'; 29 | this.maxTime = '00:00.000'; 30 | this.secondsElapsed = 0.0; 31 | 32 | 33 | // Check dependencies and initialize video 34 | 35 | if (Whitewater.supported) { 36 | init.call(this); 37 | } 38 | 39 | 40 | // ---------------------------------------------------------------------- // 41 | 42 | 43 | // Private Functions 44 | 45 | function addCanvasMethods() { 46 | this.canvas.play = this.play; 47 | this.canvas.pause = this.pause; 48 | this.canvas.playpause = this.playpause; 49 | this.canvas.stop = this.stop; 50 | } 51 | 52 | function addVisibilityListener() { 53 | if ('hidden' in document) { 54 | hiddenProperty = 'hidden'; 55 | document.addEventListener('visibilitychange', softPause.bind(this), false); 56 | } else if ('mozHidden' in document) { 57 | hiddenProperty = 'mozHidden'; 58 | document.addEventListener('mozvisibilitychange', softPause.bind(this), false); 59 | } else if ('msHidden' in document) { 60 | hiddenProperty = 'msHidden'; 61 | document.addEventListener('msvisibilitychange', softPause.bind(this), false); 62 | } else if ('webkitHidden' in document) { 63 | hiddenProperty = 'webkitHidden'; 64 | document.addEventListener('webkitvisibilitychange', softPause.bind(this), false); 65 | } else if ('onfocusin' in document) { 66 | document.addEventListener('focusin', softPause.bind(this, false), false); 67 | document.addEventListener('focusout', softPause.bind(this, true), false); 68 | } else if ('onpageshow' in window) { 69 | window.addEventListener('pageshow', softPause.bind(this, false), false); 70 | window.addEventListener('pagehide', softPause.bind(this, true), false); 71 | } else { 72 | window.addEventListener('focus', softPause.bind(this, false), false); 73 | window.addEventListener('blur', softPause.bind(this, true), false); 74 | } 75 | } 76 | 77 | function checkImagesLoaded() { 78 | 79 | imagesLoaded++; 80 | 81 | if (imagesLoaded > settings.imagesRequired) { 82 | 83 | this.canvas.setAttribute('data-state', 'ready'); 84 | this.state = 'ready'; 85 | 86 | var loadEvent = new CustomEvent('whitewaterload', getEventOptions.call(this)); 87 | this.canvas.dispatchEvent(loadEvent); 88 | 89 | if (options.autoplay) { 90 | this.play(); 91 | } 92 | 93 | } 94 | 95 | } 96 | 97 | function drawFrame() { 98 | 99 | var frameToDraw = null; 100 | 101 | if (this.currentFrame === 0) { 102 | frameToDraw = firstImage; 103 | } else { 104 | frameToDraw = getPrecompositedFrame(frames[this.currentFrame - 1]); 105 | } 106 | 107 | context.drawImage(frameToDraw, 0, 0); 108 | this.currentFrame++; 109 | setProgress.call(this); 110 | 111 | } 112 | 113 | function getEventOptions() { 114 | return { 115 | detail: { 116 | video: this, 117 | currentFrame: this.currentFrame, 118 | progress: this.progress, 119 | timestamp: this.timestamp, 120 | maxTime: this.maxTime, 121 | state: this.state, 122 | secondsElapsed: this.secondsElapsed 123 | }, 124 | bubbles: true, 125 | cancelable: false 126 | }; 127 | } 128 | 129 | function getPrecompositedFrame(frameToRender) { 130 | 131 | var buffer = document.createElement('canvas'); 132 | buffer.width = settings.videoWidth; 133 | buffer.height = settings.videoHeight; 134 | 135 | for (var j = 0; j < frameToRender.length; j++) { 136 | var position = frameToRender[j][0]; 137 | var consecutive = frameToRender[j][1]; 138 | var positionArray = getCoordinatesFromPosition(position); 139 | var chunkWidth = consecutive * settings.blockSize; 140 | 141 | buffer.getContext('2d').drawImage( 142 | diffImages[imageIndex], 143 | coordinates.x * settings.blockSize, 144 | coordinates.y * settings.blockSize, 145 | chunkWidth, 146 | settings.blockSize, 147 | positionArray[0] * settings.blockSize, 148 | positionArray[1] * settings.blockSize, 149 | chunkWidth, 150 | settings.blockSize 151 | ); 152 | 153 | coordinates.x += consecutive; 154 | if (coordinates.x >= settings.sourceGrid) { 155 | // Jump to next row 156 | coordinates.x = 0; 157 | coordinates.y++; 158 | if (coordinates.y >= settings.sourceGrid) { 159 | // Jump to next diffmap 160 | coordinates.y = 0; 161 | imageIndex++; 162 | if (imageIndex >= diffImages.length) { 163 | throw 'imageIndex exceeded diffImages.length\n\nmapLength = ' + frameToRender.length + '\nj = ' + j; 164 | } 165 | } 166 | } 167 | } 168 | 169 | return buffer; 170 | 171 | } 172 | 173 | function loadRequiredImages() { 174 | var Video = this; 175 | firstImage.addEventListener('load', function() { 176 | checkImagesLoaded.call(Video); 177 | setPosterImage.call(Video); 178 | }, false); 179 | firstImage.src = path + 'first.' + settings.format; 180 | 181 | for (var i = 1; i <= settings.imagesRequired; i++) { 182 | var image = new Image(); 183 | image.addEventListener('load', checkImagesLoaded.bind(this), false); 184 | if (i > 99) { 185 | image.src = path + 'diff_' + i + '.' + settings.format; 186 | } else if (i > 9) { 187 | image.src = path + 'diff_0' + i + '.' + settings.format; 188 | } else { 189 | image.src = path + 'diff_00' + i + '.' + settings.format; 190 | } 191 | diffImages.push(image); 192 | } 193 | 194 | } 195 | 196 | function parseManifestFile(callbacks) { 197 | 198 | var Video = this; 199 | var request = new XMLHttpRequest(); 200 | 201 | request.open('GET', path + 'manifest.json', false); 202 | request.addEventListener('load', onManifestLoad.bind(this), false); 203 | request.addEventListener('error', onManifestError.bind(this), false); 204 | request.send(); 205 | 206 | function onManifestLoad() { 207 | 208 | try { 209 | manifest = JSON.parse(request.responseText); 210 | } catch(error) { 211 | this.constructor._throwError(error); 212 | return; 213 | } 214 | 215 | setVideoOptions.call(this); 216 | setSize.call(this); 217 | 218 | var myWorker = null; 219 | 220 | var webWorker = function() { 221 | var workerIsIncluded = false; 222 | 223 | workerIsIncluded = true; 224 | 225 | onmessage = function(e) { 226 | var frames = e.data; 227 | var videoData = []; 228 | 229 | for (var i = 0; i < frames.length; i++) { 230 | var frame = frames[i]; 231 | var frameData = []; 232 | 233 | if (frame !== "") { 234 | var map = frame.match(/.{1,5}/g); 235 | var mapLength = map.length; 236 | 237 | for (var j = 0; j < mapLength; j++) { 238 | var position = toBase10(map[j].substr(0, 3)); 239 | var consecutive = toBase10(map[j].substr(3, 2)); 240 | 241 | frameData.push([position, consecutive]); 242 | } 243 | } 244 | 245 | videoData.push(frameData); 246 | } 247 | 248 | postMessage(videoData); 249 | }; 250 | 251 | function toBase10(val) { 252 | var order = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; 253 | var num = 0, r; 254 | while (val.length) { 255 | r = order.indexOf(val.charAt(0)); 256 | val = val.substr(1); 257 | num *= 64; 258 | num += r; 259 | } 260 | return num; 261 | } 262 | 263 | return workerIsIncluded; 264 | }; 265 | 266 | if (webWorker()) { 267 | 268 | var URL = window.URL || window.webkitURL; 269 | var workerBlob = new Blob(['(' + webWorker.toString() + ')()'], { 270 | type: 'text/javascript' 271 | }); 272 | 273 | myWorker = new Worker(URL.createObjectURL(workerBlob)); 274 | 275 | } else { 276 | 277 | myWorker = new Worker('whitewater.worker.js'); 278 | 279 | } 280 | 281 | myWorker.postMessage(manifest.frames); 282 | 283 | myWorker.onmessage = function(event) { 284 | frames = event.data; 285 | myWorker.terminate(); 286 | 287 | for (var i = 0; i < callbacks.length; i++) { 288 | callbacks[i](); 289 | } 290 | 291 | if (options.controls) { 292 | setPlayPauseControls.call(Video); 293 | } 294 | }; 295 | 296 | } 297 | 298 | function onManifestError() { 299 | 300 | try { 301 | throw this.constructor.errors.MANIFEST; 302 | } catch(error) { 303 | this.constructor._throwError(error); 304 | return; 305 | } 306 | 307 | } 308 | 309 | } 310 | 311 | function resetVideo() { 312 | 313 | imageIndex = 0; 314 | coordinates.x = 0; 315 | coordinates.y = 0; 316 | this.currentFrame = 0; 317 | 318 | context.clearRect(0, 0, settings.videoWidth, settings.videoHeight); 319 | 320 | } 321 | 322 | function setCanvasElement() { 323 | 324 | if (canvas instanceof HTMLCanvasElement) { 325 | this.canvas = canvas; 326 | context = this.canvas.getContext('2d'); 327 | } else { 328 | throw this.constructor.errors.CANVAS; 329 | } 330 | 331 | } 332 | 333 | function setFilePath() { 334 | 335 | if (typeof inputPath === 'string') { 336 | path = inputPath; 337 | if (inputPath.substr(-1) !== '/') { 338 | path += '/'; 339 | } 340 | } else { 341 | throw this.constructor.errors.PATH; 342 | } 343 | 344 | } 345 | 346 | function setOptions() { 347 | 348 | if (options) { 349 | 350 | var speed = 1; 351 | if (options.speed && options.speed < 1) { 352 | speed = options.speed; 353 | } 354 | 355 | options = { 356 | loop: options.loop || false, 357 | autoplay: options.autoplay || false, 358 | controls: options.controls || false, 359 | speed: speed 360 | }; 361 | 362 | } 363 | 364 | } 365 | 366 | function setPlayPauseControls() { 367 | var element = this.canvas; 368 | 369 | if (typeof options.controls !== 'boolean') { 370 | element = options.controls; 371 | } 372 | 373 | var clickEvent = getClickEvent(); 374 | element.addEventListener(clickEvent, this.playpause); 375 | } 376 | 377 | function setPosterImage() { 378 | 379 | var src = firstImage.src; 380 | var top = this.canvas.style.paddingTop; 381 | 382 | this.canvas.style.background = 'transparent url(' + src + ') no-repeat center ' + top; 383 | this.canvas.style.backgroundSize = 'contain'; 384 | 385 | } 386 | 387 | function setProgress() { 388 | 389 | this.progress = getNumberWithDecimals(this.currentFrame / settings.frameCount * 100, 3); 390 | 391 | var currentTime = this.currentFrame / settings.framesPerSecond; 392 | this.timestamp = getFormattedTime(currentTime); 393 | this.secondsElapsed = getNumberWithDecimals(currentTime, 3); 394 | 395 | var playingEvent = new CustomEvent('whitewaterprogressupdate', getEventOptions.call(this)); 396 | this.canvas.dispatchEvent(playingEvent); 397 | 398 | } 399 | 400 | function setSize() { 401 | this.canvas.setAttribute('width', settings.videoWidth + 'px'); 402 | this.canvas.setAttribute('height', settings.videoHeight + 'px'); 403 | } 404 | 405 | function setVideoOptions() { 406 | var format = ''; 407 | 408 | switch (manifest.format) { 409 | case 'JPEG': 410 | format = 'jpg'; 411 | break; 412 | case 'PNG': 413 | format = 'png'; 414 | break; 415 | case 'GIF': 416 | format = 'gif'; 417 | break; 418 | default: 419 | format = 'jpg'; 420 | break; 421 | } 422 | 423 | settings = { 424 | videoWidth: manifest.videoWidth, 425 | videoHeight: manifest.videoHeight, 426 | imagesRequired: manifest.imagesRequired, 427 | frameCount: manifest.frameCount - 1, 428 | blockSize: manifest.blockSize, 429 | sourceGrid: manifest.sourceGrid, 430 | framesPerSecond: Math.round(manifest.framesPerSecond), 431 | format: format 432 | }; 433 | 434 | var lengthInSeconds = settings.frameCount / settings.framesPerSecond; 435 | this.maxTime = getFormattedTime(lengthInSeconds); 436 | } 437 | 438 | function softPause(hidden) { 439 | if (hidden !== undefined) { 440 | documentHidden = hidden; 441 | } 442 | 443 | if ((document[hiddenProperty] || documentHidden === true) && Video.state === 'playing') { 444 | this.state = 'suspended'; 445 | this.pause(); 446 | } else if (Video.state === 'suspended') { 447 | this.play(); 448 | } 449 | } 450 | 451 | function init() { 452 | try { 453 | setCanvasElement.call(this); 454 | setFilePath(); 455 | setOptions(); 456 | 457 | var callAfterManifest = [ 458 | loadRequiredImages.bind(this), 459 | addCanvasMethods.bind(this), 460 | addVisibilityListener.bind(this) 461 | ]; 462 | 463 | parseManifestFile.call(this, callAfterManifest); 464 | 465 | } catch(error) { 466 | this.constructor._throwError(error); 467 | return; 468 | } 469 | 470 | } 471 | 472 | 473 | // Helper Functions 474 | 475 | function getClickEvent() { 476 | var isTouchDevice = 'ontouchstart' in document.documentElement; 477 | //var startEvent = isTouchDevice ? 'touchstart' : 'mousedown'; 478 | var endEvent = isTouchDevice ? 'touchend' : 'mouseup'; 479 | 480 | return endEvent; 481 | } 482 | 483 | function getCoordinatesFromPosition(position) { 484 | 485 | var coordinates = []; 486 | var columns = Math.ceil(settings.videoWidth / settings.blockSize); 487 | 488 | if (position < columns) { 489 | coordinates = [position, 0]; 490 | } else { 491 | coordinates = [position % columns, Math.floor(position / columns)]; 492 | } 493 | 494 | return coordinates; 495 | 496 | } 497 | 498 | function getFormattedTime(time) { 499 | 500 | var minutes = Math.floor(time / 60); 501 | var seconds = Math.floor(time % 60); 502 | var milliseconds = Math.floor((time % 60 % 1) * 1000); 503 | 504 | if (minutes < 10) { 505 | minutes = '0' + minutes; 506 | } 507 | 508 | if (seconds < 10) { 509 | seconds = '0' + seconds; 510 | } 511 | 512 | if (milliseconds < 10) { 513 | milliseconds = '00' + milliseconds; 514 | } else if (milliseconds < 100) { 515 | milliseconds = '0' + milliseconds; 516 | } 517 | 518 | return minutes + ':' + seconds + '.' + milliseconds; 519 | 520 | } 521 | 522 | function getNumberWithDecimals(number, digits) { 523 | var multiplier = Math.pow(10, digits); 524 | return Math.round(number * multiplier) / multiplier; 525 | } 526 | 527 | 528 | // Public Functions 529 | 530 | var Video = this; 531 | 532 | this.pause = function() { 533 | if (Video.state === 'paused') { 534 | return; 535 | } 536 | 537 | if (Video.state !== 'suspended') { 538 | Video.canvas.setAttribute('data-state', 'paused'); 539 | Video.state = 'paused'; 540 | 541 | var pauseEvent = new CustomEvent('whitewaterpause', getEventOptions.call(Video)); 542 | Video.canvas.dispatchEvent(pauseEvent); 543 | } 544 | 545 | cancelAnimationFrame(animationFrame); 546 | }; 547 | 548 | this.play = function() { 549 | if (Video.state === 'playing') { 550 | return; 551 | } else if (Video.state === 'ended') { 552 | resetVideo.call(Video); 553 | } 554 | 555 | var resume = Video.state === 'suspended'; 556 | 557 | Video.canvas.setAttribute('data-state', 'playing'); 558 | Video.state = 'playing'; 559 | 560 | if (!resume) { 561 | var playEvent = new CustomEvent('whitewaterplay', getEventOptions.call(Video)); 562 | Video.canvas.dispatchEvent(playEvent); 563 | } 564 | 565 | var milliseconds = 1 / settings.framesPerSecond * 1000; 566 | var interval = getNumberWithDecimals(milliseconds / options.speed, 2); 567 | var previousTime = window.performance.now(); 568 | 569 | animate(previousTime); 570 | 571 | function animate(currentTime) { 572 | 573 | var timeSinceLastDraw = currentTime - previousTime; 574 | 575 | if (timeSinceLastDraw >= interval) { 576 | 577 | if (Video.currentFrame < settings.frameCount + 1) { 578 | 579 | drawFrame.call(Video); 580 | 581 | } else if (options.loop) { 582 | 583 | resetVideo.call(Video); 584 | drawFrame.call(Video); 585 | 586 | var loopEvent = new CustomEvent('whitewaterloop', getEventOptions.call(Video)); 587 | Video.canvas.dispatchEvent(loopEvent); 588 | 589 | } else { 590 | 591 | Video.stop(); 592 | 593 | Video.canvas.setAttribute('data-state', 'ended'); 594 | Video.state = 'ended'; 595 | 596 | var endEvent = new CustomEvent('whitewaterend', getEventOptions.call(Video)); 597 | Video.canvas.dispatchEvent(endEvent); 598 | 599 | } 600 | 601 | var lag = timeSinceLastDraw - interval; 602 | previousTime = currentTime - lag; 603 | 604 | } 605 | 606 | if (!(document[hiddenProperty] || documentHidden === true) && Video.state === 'playing') { 607 | animationFrame = requestAnimationFrame(animate); 608 | } 609 | } 610 | }; 611 | 612 | this.playpause = function() { 613 | 614 | if (Video.state === 'playing') { 615 | Video.pause(); 616 | } else if (Video.state !== 'loading') { 617 | Video.play(); 618 | } 619 | }; 620 | 621 | this.stop = function() { 622 | 623 | if (Video.state === 'ready') { 624 | return; 625 | } 626 | 627 | Video.canvas.setAttribute('data-state', 'ready'); 628 | Video.state = 'ready'; 629 | 630 | var stopEvent = new CustomEvent('whitewaterend', getEventOptions.call(Video)); 631 | Video.canvas.dispatchEvent(stopEvent); 632 | 633 | cancelAnimationFrame(animationFrame); 634 | 635 | resetVideo.call(Video); 636 | setProgress.call(Video); 637 | 638 | }; 639 | 640 | } 641 | 642 | Whitewater.errors = { 643 | pre : 'Whitewater: ', 644 | MISC : 'Whatever.', 645 | WEBWORKERS : 'This browser does not support Web Workers.', 646 | BLOBCONSTRUCTOR : 'This browser does not support the Blob() constructor.', 647 | VISIBILITYAPI : 'This browser does not support the Visiblity API', 648 | CANVAS : '"canvas" must be a valid HTML canvas element.', 649 | PATH : '"path" must be a path to a directory containing a manifest.json file', 650 | MANIFEST : 'A manifest.json file could not be found.' 651 | }; 652 | 653 | Whitewater._checkSupport = function() { 654 | try { 655 | if (!window.Blob) { 656 | throw this.errors.WEBWORKERS; 657 | } else if (!window.Worker) { 658 | throw this.errors.BLOBCONSTRUCTOR; 659 | } else if (!(('hidden' in document) || 660 | ('mozHidden' in document) || 661 | ('msHidden' in document) || 662 | ('webkitHidden' in document))) { 663 | throw this.errors.VISIBILITYAPI; 664 | } else { 665 | return true; 666 | } 667 | } catch(error) { 668 | this._throwError(error); 669 | return false; 670 | } 671 | }; 672 | 673 | Whitewater._throwError = function(error) { 674 | console.warn(this.errors.pre + error); 675 | }; 676 | 677 | Whitewater.supported = Whitewater._checkSupport(); 678 | --------------------------------------------------------------------------------