leapjs-plugins
Version:
A collection of useful plugins for LeapJS
1,857 lines • 72.3 kB
JavaScript
/*
* LeapJS Playback - v0.2.1 - 2014-05-14
* http://github.com/leapmotion/leapjs-playback/
*
* Copyright 2014 LeapMotion, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
;(function( window, undefined ){
'use strict';
// see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions_and_function_scope/Strict_mode
// Copyright (c) 2013 Pieroxy <pieroxy@pieroxy.net>
// This work is free. You can redistribute it and/or modify it
// under the terms of the WTFPL, Version 2
// For more information see LICENSE.txt or http://www.wtfpl.net/
//
// For more information, the home page:
// http://pieroxy.net/blog/pages/lz-string/testing.html
//
// LZ-based compression algorithm, version 1.3.3
var LZString = {
// private property
_keyStr : "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
_f : String.fromCharCode,
compressToBase64 : function (input) {
if (input == null) return "";
var output = "";
var chr1, chr2, chr3, enc1, enc2, enc3, enc4;
var i = 0;
input = LZString.compress(input);
while (i < input.length*2) {
if (i%2==0) {
chr1 = input.charCodeAt(i/2) >> 8;
chr2 = input.charCodeAt(i/2) & 255;
if (i/2+1 < input.length)
chr3 = input.charCodeAt(i/2+1) >> 8;
else
chr3 = NaN;
} else {
chr1 = input.charCodeAt((i-1)/2) & 255;
if ((i+1)/2 < input.length) {
chr2 = input.charCodeAt((i+1)/2) >> 8;
chr3 = input.charCodeAt((i+1)/2) & 255;
} else
chr2=chr3=NaN;
}
i+=3;
enc1 = chr1 >> 2;
enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
enc4 = chr3 & 63;
if (isNaN(chr2)) {
enc3 = enc4 = 64;
} else if (isNaN(chr3)) {
enc4 = 64;
}
output = output +
LZString._keyStr.charAt(enc1) + LZString._keyStr.charAt(enc2) +
LZString._keyStr.charAt(enc3) + LZString._keyStr.charAt(enc4);
}
return output;
},
decompressFromBase64 : function (input) {
if (input == null) return "";
var output = "",
ol = 0,
output_,
chr1, chr2, chr3,
enc1, enc2, enc3, enc4,
i = 0, f=LZString._f;
input = input.replace(/[^A-Za-z0-9\+\/\=]/g, "");
while (i < input.length) {
enc1 = LZString._keyStr.indexOf(input.charAt(i++));
enc2 = LZString._keyStr.indexOf(input.charAt(i++));
enc3 = LZString._keyStr.indexOf(input.charAt(i++));
enc4 = LZString._keyStr.indexOf(input.charAt(i++));
chr1 = (enc1 << 2) | (enc2 >> 4);
chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
chr3 = ((enc3 & 3) << 6) | enc4;
if (ol%2==0) {
output_ = chr1 << 8;
if (enc3 != 64) {
output += f(output_ | chr2);
}
if (enc4 != 64) {
output_ = chr3 << 8;
}
} else {
output = output + f(output_ | chr1);
if (enc3 != 64) {
output_ = chr2 << 8;
}
if (enc4 != 64) {
output += f(output_ | chr3);
}
}
ol+=3;
}
return LZString.decompress(output);
},
compressToUTF16 : function (input) {
if (input == null) return "";
var output = "",
i,c,
current,
status = 0,
f = LZString._f;
input = LZString.compress(input);
for (i=0 ; i<input.length ; i++) {
c = input.charCodeAt(i);
switch (status++) {
case 0:
output += f((c >> 1)+32);
current = (c & 1) << 14;
break;
case 1:
output += f((current + (c >> 2))+32);
current = (c & 3) << 13;
break;
case 2:
output += f((current + (c >> 3))+32);
current = (c & 7) << 12;
break;
case 3:
output += f((current + (c >> 4))+32);
current = (c & 15) << 11;
break;
case 4:
output += f((current + (c >> 5))+32);
current = (c & 31) << 10;
break;
case 5:
output += f((current + (c >> 6))+32);
current = (c & 63) << 9;
break;
case 6:
output += f((current + (c >> 7))+32);
current = (c & 127) << 8;
break;
case 7:
output += f((current + (c >> 8))+32);
current = (c & 255) << 7;
break;
case 8:
output += f((current + (c >> 9))+32);
current = (c & 511) << 6;
break;
case 9:
output += f((current + (c >> 10))+32);
current = (c & 1023) << 5;
break;
case 10:
output += f((current + (c >> 11))+32);
current = (c & 2047) << 4;
break;
case 11:
output += f((current + (c >> 12))+32);
current = (c & 4095) << 3;
break;
case 12:
output += f((current + (c >> 13))+32);
current = (c & 8191) << 2;
break;
case 13:
output += f((current + (c >> 14))+32);
current = (c & 16383) << 1;
break;
case 14:
output += f((current + (c >> 15))+32, (c & 32767)+32);
status = 0;
break;
}
}
return output + f(current + 32);
},
decompressFromUTF16 : function (input) {
if (input == null) return "";
var output = "",
current,c,
status=0,
i = 0,
f = LZString._f;
while (i < input.length) {
c = input.charCodeAt(i) - 32;
switch (status++) {
case 0:
current = c << 1;
break;
case 1:
output += f(current | (c >> 14));
current = (c&16383) << 2;
break;
case 2:
output += f(current | (c >> 13));
current = (c&8191) << 3;
break;
case 3:
output += f(current | (c >> 12));
current = (c&4095) << 4;
break;
case 4:
output += f(current | (c >> 11));
current = (c&2047) << 5;
break;
case 5:
output += f(current | (c >> 10));
current = (c&1023) << 6;
break;
case 6:
output += f(current | (c >> 9));
current = (c&511) << 7;
break;
case 7:
output += f(current | (c >> 8));
current = (c&255) << 8;
break;
case 8:
output += f(current | (c >> 7));
current = (c&127) << 9;
break;
case 9:
output += f(current | (c >> 6));
current = (c&63) << 10;
break;
case 10:
output += f(current | (c >> 5));
current = (c&31) << 11;
break;
case 11:
output += f(current | (c >> 4));
current = (c&15) << 12;
break;
case 12:
output += f(current | (c >> 3));
current = (c&7) << 13;
break;
case 13:
output += f(current | (c >> 2));
current = (c&3) << 14;
break;
case 14:
output += f(current | (c >> 1));
current = (c&1) << 15;
break;
case 15:
output += f(current | c);
status=0;
break;
}
i++;
}
return LZString.decompress(output);
//return output;
},
compress: function (uncompressed) {
if (uncompressed == null) return "";
var i, value,
context_dictionary= {},
context_dictionaryToCreate= {},
context_c="",
context_wc="",
context_w="",
context_enlargeIn= 2, // Compensate for the first entry which should not count
context_dictSize= 3,
context_numBits= 2,
context_data_string="",
context_data_val=0,
context_data_position=0,
ii,
f=LZString._f;
for (ii = 0; ii < uncompressed.length; ii += 1) {
context_c = uncompressed.charAt(ii);
if (!Object.prototype.hasOwnProperty.call(context_dictionary,context_c)) {
context_dictionary[context_c] = context_dictSize++;
context_dictionaryToCreate[context_c] = true;
}
context_wc = context_w + context_c;
if (Object.prototype.hasOwnProperty.call(context_dictionary,context_wc)) {
context_w = context_wc;
} else {
if (Object.prototype.hasOwnProperty.call(context_dictionaryToCreate,context_w)) {
if (context_w.charCodeAt(0)<256) {
for (i=0 ; i<context_numBits ; i++) {
context_data_val = (context_data_val << 1);
if (context_data_position == 15) {
context_data_position = 0;
context_data_string += f(context_data_val);
context_data_val = 0;
} else {
context_data_position++;
}
}
value = context_w.charCodeAt(0);
for (i=0 ; i<8 ; i++) {
context_data_val = (context_data_val << 1) | (value&1);
if (context_data_position == 15) {
context_data_position = 0;
context_data_string += f(context_data_val);
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
} else {
value = 1;
for (i=0 ; i<context_numBits ; i++) {
context_data_val = (context_data_val << 1) | value;
if (context_data_position == 15) {
context_data_position = 0;
context_data_string += f(context_data_val);
context_data_val = 0;
} else {
context_data_position++;
}
value = 0;
}
value = context_w.charCodeAt(0);
for (i=0 ; i<16 ; i++) {
context_data_val = (context_data_val << 1) | (value&1);
if (context_data_position == 15) {
context_data_position = 0;
context_data_string += f(context_data_val);
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn == 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
delete context_dictionaryToCreate[context_w];
} else {
value = context_dictionary[context_w];
for (i=0 ; i<context_numBits ; i++) {
context_data_val = (context_data_val << 1) | (value&1);
if (context_data_position == 15) {
context_data_position = 0;
context_data_string += f(context_data_val);
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn == 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
// Add wc to the dictionary.
context_dictionary[context_wc] = context_dictSize++;
context_w = String(context_c);
}
}
// Output the code for w.
if (context_w !== "") {
if (Object.prototype.hasOwnProperty.call(context_dictionaryToCreate,context_w)) {
if (context_w.charCodeAt(0)<256) {
for (i=0 ; i<context_numBits ; i++) {
context_data_val = (context_data_val << 1);
if (context_data_position == 15) {
context_data_position = 0;
context_data_string += f(context_data_val);
context_data_val = 0;
} else {
context_data_position++;
}
}
value = context_w.charCodeAt(0);
for (i=0 ; i<8 ; i++) {
context_data_val = (context_data_val << 1) | (value&1);
if (context_data_position == 15) {
context_data_position = 0;
context_data_string += f(context_data_val);
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
} else {
value = 1;
for (i=0 ; i<context_numBits ; i++) {
context_data_val = (context_data_val << 1) | value;
if (context_data_position == 15) {
context_data_position = 0;
context_data_string += f(context_data_val);
context_data_val = 0;
} else {
context_data_position++;
}
value = 0;
}
value = context_w.charCodeAt(0);
for (i=0 ; i<16 ; i++) {
context_data_val = (context_data_val << 1) | (value&1);
if (context_data_position == 15) {
context_data_position = 0;
context_data_string += f(context_data_val);
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn == 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
delete context_dictionaryToCreate[context_w];
} else {
value = context_dictionary[context_w];
for (i=0 ; i<context_numBits ; i++) {
context_data_val = (context_data_val << 1) | (value&1);
if (context_data_position == 15) {
context_data_position = 0;
context_data_string += f(context_data_val);
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn == 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
}
// Mark the end of the stream
value = 2;
for (i=0 ; i<context_numBits ; i++) {
context_data_val = (context_data_val << 1) | (value&1);
if (context_data_position == 15) {
context_data_position = 0;
context_data_string += f(context_data_val);
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
// Flush the last char
while (true) {
context_data_val = (context_data_val << 1);
if (context_data_position == 15) {
context_data_string += f(context_data_val);
break;
}
else context_data_position++;
}
return context_data_string;
},
decompress: function (compressed) {
if (compressed == null) return "";
if (compressed == "") return null;
var dictionary = [],
next,
enlargeIn = 4,
dictSize = 4,
numBits = 3,
entry = "",
result = "",
i,
w,
bits, resb, maxpower, power,
c,
f = LZString._f,
data = {string:compressed, val:compressed.charCodeAt(0), position:32768, index:1};
for (i = 0; i < 3; i += 1) {
dictionary[i] = i;
}
bits = 0;
maxpower = Math.pow(2,2);
power=1;
while (power!=maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = 32768;
data.val = data.string.charCodeAt(data.index++);
}
bits |= (resb>0 ? 1 : 0) * power;
power <<= 1;
}
switch (next = bits) {
case 0:
bits = 0;
maxpower = Math.pow(2,8);
power=1;
while (power!=maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = 32768;
data.val = data.string.charCodeAt(data.index++);
}
bits |= (resb>0 ? 1 : 0) * power;
power <<= 1;
}
c = f(bits);
break;
case 1:
bits = 0;
maxpower = Math.pow(2,16);
power=1;
while (power!=maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = 32768;
data.val = data.string.charCodeAt(data.index++);
}
bits |= (resb>0 ? 1 : 0) * power;
power <<= 1;
}
c = f(bits);
break;
case 2:
return "";
}
dictionary[3] = c;
w = result = c;
while (true) {
if (data.index > data.string.length) {
return "";
}
bits = 0;
maxpower = Math.pow(2,numBits);
power=1;
while (power!=maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = 32768;
data.val = data.string.charCodeAt(data.index++);
}
bits |= (resb>0 ? 1 : 0) * power;
power <<= 1;
}
switch (c = bits) {
case 0:
bits = 0;
maxpower = Math.pow(2,8);
power=1;
while (power!=maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = 32768;
data.val = data.string.charCodeAt(data.index++);
}
bits |= (resb>0 ? 1 : 0) * power;
power <<= 1;
}
dictionary[dictSize++] = f(bits);
c = dictSize-1;
enlargeIn--;
break;
case 1:
bits = 0;
maxpower = Math.pow(2,16);
power=1;
while (power!=maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = 32768;
data.val = data.string.charCodeAt(data.index++);
}
bits |= (resb>0 ? 1 : 0) * power;
power <<= 1;
}
dictionary[dictSize++] = f(bits);
c = dictSize-1;
enlargeIn--;
break;
case 2:
return result;
}
if (enlargeIn == 0) {
enlargeIn = Math.pow(2, numBits);
numBits++;
}
if (dictionary[c]) {
entry = dictionary[c];
} else {
if (c === dictSize) {
entry = w + w.charAt(0);
} else {
return null;
}
}
result += entry;
// Add w+entry[0] to the dictionary.
dictionary[dictSize++] = w + entry.charAt(0);
enlargeIn--;
w = entry;
if (enlargeIn == 0) {
enlargeIn = Math.pow(2, numBits);
numBits++;
}
}
}
};
if( typeof module !== 'undefined' && module != null ) {
module.exports = LZString
}
// https://gist.github.com/paulirish/5438650
// relies on Date.now() which has been supported everywhere modern for years.
// as Safari 6 doesn't have support for NavigationTiming, we use a Date.now() timestamp for relative values
// if you want values similar to what you'd get with real perf.now, place this towards the head of the page
// but in reality, you're just getting the delta between now() calls, so it's not terribly important where it's placed
(function(){
// prepare base perf object
if (typeof window.performance === 'undefined') {
window.performance = {};
}
if (!window.performance.now){
var nowOffset = Date.now();
if (performance.timing && performance.timing.navigationStart){
nowOffset = performance.timing.navigationStart
}
window.performance.now = function now(){
return Date.now() - nowOffset;
}
}
})();
function Recording (options){
this.options = options || (options = {});
this.loading = false;
this.timeBetweenLoops = options.timeBetweenLoops || 50;
// see https://github.com/leapmotion/leapjs/blob/master/Leap_JSON.rst
this.packingStructure = [
'id',
'timestamp',
// this should be replace/upgraded with a whitelist instead of a blacklist.
// leaving out r,s,y, and gestures
{hands: [[
'id',
'type',
'direction',
'palmNormal',
'palmPosition',
'palmVelocity',
'stabilizedPalmPosition',
'pinchStrength',
'grabStrength',
'confidence',
'armBasis',
'armWidth',
'elbow',
'wrist'
// leaving out r, s, t, sphereCenter, sphereRadius
]]},
{pointables: [[
'id',
'direction',
'handId',
'length',
'stabilizedTipPosition',
'tipPosition',
'tipVelocity',
'tool',
'carpPosition',
'mcpPosition',
'pipPosition',
'dipPosition',
'btipPosition',
'bases',
'type'
// leaving out touchDistance, touchZone
]]},
{interactionBox: [
'center', 'size'
]}
];
this.setFrames(options.frames || [])
}
Recording.prototype = {
setFrames: function (frames) {
this.frameData = frames;
this.frameIndex = 0;
this.frameCount = frames.length;
this.leftCropPosition = 0;
this.rightCropPosition = this.frameCount;
this.setMetaData();
},
addFrame: function(frameData){
this.frameData.push(frameData);
},
currentFrame: function () {
return this.frameData[this.frameIndex];
},
nextFrame: function () {
var frameIndex = this.frameIndex + 1;
// || 1 to prevent `mod 0` error when finishing recording before setFrames has been called.
frameIndex = frameIndex % (this.rightCropPosition || 1);
if ((frameIndex < this.leftCropPosition)) {
frameIndex = this.leftCropPosition;
}
return this.frameData[frameIndex];
},
advanceFrame: function () {
this.frameIndex++;
if (this.frameIndex >= this.rightCropPosition && !this.options.loop) {
this.frameIndex--;
// there is currently an issue where angular watching the right handle position
// will cause this to fire prematurely
// when switching to an earlier recording
return false
}
this.frameIndex = this.frameIndex % (this.rightCropPosition || 1);
if ((this.frameIndex < this.leftCropPosition)) {
this.frameIndex = this.leftCropPosition;
}
return true
},
// resets to beginning if at end
readyPlay: function(){
this.frameIndex++;
if (this.frameIndex >= this.rightCropPosition) {
this.frameIndex = this.frameIndex % (this.rightCropPosition || 1);
if ((this.frameIndex < this.leftCropPosition)) {
this.frameIndex = this.leftCropPosition;
}
}else{
this.frameIndex--;
}
},
cloneCurrentFrame: function(){
return JSON.parse(JSON.stringify(this.currentFrame()));
},
// this method would be well-moved to its own object/class -.-
// for every point, lerp as appropriate
// note: currently hand and finger props are hard coded, but things like stabilizedPalmPosition should be optional
// should have this be set from the packingStructure or some such, but only for vec3s.
createLerpFrameData: function(t){
// http://stackoverflow.com/a/5344074/478354
var currentFrame = this.currentFrame(),
nextFrame = this.nextFrame(),
handProps = ['palmPosition', 'stabilizedPalmPosition', 'sphereCenter', 'direction', 'palmNormal', 'palmVelocity'],
fingerProps = ['mcpPosition', 'pipPosition', 'dipPosition', 'tipPosition', 'direction'],
frameData = this.cloneCurrentFrame(),
numHands = frameData.hands.length,
numPointables = frameData.pointables.length,
len1 = handProps.length,
len2 = fingerProps.length,
prop, hand, pointable;
for (var i = 0; i < numHands; i++){
hand = frameData.hands[i];
for (var j = 0; j < len1; j++){
prop = handProps[j];
if (!currentFrame.hands[i][prop]){
continue;
}
if (!nextFrame.hands[i]){
continue;
}
Leap.vec3.lerp(
hand[prop],
currentFrame.hands[i][prop],
nextFrame.hands[i][prop],
t
);
// console.assert(hand[prop]);
}
}
for ( i = 0; i < numPointables; i++){
pointable = frameData.pointables[i];
for ( j = 0; j < len2; j++){
prop = fingerProps[j];
if (!currentFrame.pointables[i][prop]){
continue;
}
if (!nextFrame.hands[i]){
continue;
}
Leap.vec3.lerp(
pointable[prop],
currentFrame.pointables[i][prop],
nextFrame.pointables[i][prop],
0
);
// console.assert(t >= 0 && t <= 1);
// if (t > 0) debugger;
}
}
return frameData;
},
// returns ms
timeToNextFrame: function () {
var elapsedTime = (this.nextFrame().timestamp - this.currentFrame().timestamp) / 1000;
if (elapsedTime < 0) {
elapsedTime = this.timeBetweenLoops; //arbitrary pause at slightly less than 30 fps.
}
// console.assert(!isNaN(elapsedTime));
return elapsedTime;
},
blank: function(){
return this.frameData.length === 0;
},
// sets the crop-point of the current recording to the current position.
leftCrop: function () {
this.leftCropPosition = this.frameIndex
},
// sets the crop-point of the current recording to the current position.
rightCrop: function () {
this.rightCropPosition = this.frameIndex
},
// removes every other frame from the array
// Accepts an optional `factor` integer, which is the number of frames
// discarded for every frame kept.
cullFrames: function (factor) {
console.log('cull frames', factor);
factor || (factor = 1);
for (var i = 0; i < this.frameData.length; i++) {
this.frameData.splice(i, factor);
}
this.setMetaData();
},
// Returns the average frames per second of the recording
frameRate: function () {
if (this.frameData.length == 0) {
return 0
}
return this.frameData.length / (this.frameData[this.frameData.length - 1].timestamp - this.frameData[0].timestamp) * 1000000;
},
// returns frames without any circular references
croppedFrameData: function () {
return this.frameData.slice(this.leftCropPosition, this.rightCropPosition);
},
setMetaData: function () {
var newMetaData = {
formatVersion: 2,
generatedBy: 'LeapJS Playback 0.2.0',
frames: this.rightCropPosition - this.leftCropPosition,
protocolVersion: this.options.requestProtocolVersion,
serviceVersion: this.options.serviceVersion,
frameRate: this.frameRate().toPrecision(2),
modified: (new Date).toString()
};
this.metadata || (this.metadata = {});
for (var key in newMetaData) {
this.metadata[key] = newMetaData[key];
}
},
// returns an array
// the first item is the keys of the following items
// nested arrays are expected to have idententical siblings
packedFrameData: function(){
var frameData = this.croppedFrameData(),
packedFrames = [],
frameDatum;
packedFrames.push(this.packingStructure);
for (var i = 0, len = frameData.length; i < len; i++){
frameDatum = frameData[i];
packedFrames.push(
this.packArray(
this.packingStructure,
frameDatum
)
);
}
return packedFrames;
},
// recursive method
// creates a structure of frame data matching packing structure
// there may be an issue here where hands/pointables are wrapped in one more array than necessary
packArray: function(structure, data){
var out = [], nameOrHash;
for (var i = 0, len1 = structure.length; i < len1; i++){
// e.g., nameOrHash is either 'id' or {hand: [...]}
nameOrHash = structure[i];
if ( typeof nameOrHash === 'string'){
out.push(
data[nameOrHash]
);
}else if (Object.prototype.toString.call(nameOrHash) == "[object Array]") {
// nested array, such as hands or fingers
for (var j = 0, len2 = data.length; j < len2; j++){
out.push(
this.packArray(
nameOrHash,
data[j]
)
);
}
} else { // key-value (nested object) such as interactionBox
// console.assert(nameOrHash);
for (var key in nameOrHash) break;
// console.assert(key);
// console.assert(nameOrHash[key]);
// console.assert(data[key]);
out.push(this.packArray(
nameOrHash[key],
data[key]
));
}
}
return out;
},
// expects the first array element to describe the following arrays
// this algorithm copies frames to a new array
// could there be merit in something which would do an in-place substitution?
unPackFrameData: function(packedFrames){
var packingStructure = packedFrames[0];
var frameData = [],
frameDatum;
for (var i = 1, len = packedFrames.length; i < len; i++) {
frameDatum = packedFrames[i];
frameData.push(
this.unPackArray(
packingStructure,
frameDatum
)
);
}
return frameData;
},
// data is a frame or subset of frame
// returns a frame object
// this is the structure of the array
// gets unfolded to key-value pairs
// e.g.:
// this.packingStructure = [
// 'id',
// 'timestamp',
// {hands: [[
// 'id',
// 'direction',
// 'palmNormal',
// 'palmPosition',
// 'palmVelocity'
// ]]},
// {pointables: [[
// 'direction',
// 'handId',
// 'length',
// 'stabilizedTipPosition',
// 'tipPosition',
// 'tipVelocity',
// 'tool'
// ]]},
// {interactionBox: [
// 'center', 'size'
// ]}
// ];
unPackArray: function(structure, data){
var out = {}, nameOrHash;
for (var i = 0, len1 = structure.length; i < len1; i++){
// e.g., nameOrHash is either 'id' or {hand: [...]}
nameOrHash = structure[i];
if ( typeof nameOrHash === 'string'){
out[nameOrHash] = data[i];
}else if (Object.prototype.toString.call(nameOrHash) == "[object Array]") {
// nested array, such as hands or fingers
// nameOrHash ["id", "direction", "palmNormal", "palmPosition", "palmVelocity"]
// data [ [ 31, [vec3], [vec3], ...] ]
var subArray = [];
for (var j = 0, len2 = data.length; j < len2; j++){
subArray.push(
this.unPackArray(
nameOrHash,
data[j]
)
);
}
return subArray;
} else { // key-value (nested object) such as interactionBox
for (var key in nameOrHash) break;
out[key] = this.unPackArray(
nameOrHash[key],
data[i]
);
}
}
return out;
},
toHash: function () {
this.setMetaData();
return {
metadata: this.metadata,
frames: this.packedFrameData()
}
},
// Returns the cropped data as JSON or compressed
// http://pieroxy.net/blog/pages/lz-string/index.html
export: function (format) {
var json = JSON.stringify(this.toHash());
if (format == 'json') return json;
return LZString.compressToBase64(json);
},
save: function(format){
var filename;
filename = this.metadata.title ? this.metadata.title.replace(/\s/g, '') : 'leap-playback-recording';
if (this.metadata.frameRate) {
filename += "-" + (Math.round(this.metadata.frameRate)) + "fps";
}
if (format === 'json') {
saveAs(new Blob([this.export('json')], {
type: "text/JSON;charset=utf-8"
}), filename + ".json");
} else {
saveAs(new Blob([this.export('lz')], {
type: "application/x-gzip;charset=utf-8"
}), filename + ".json.lz");
}
},
decompress: function (data) {
return LZString.decompressFromBase64(data);
},
loaded: function(){
return !!(this.frameData && this.frameData.length)
},
// optional callback once frames are loaded, will have a context of player
loadFrameData: function (callback) {
var xhr = new XMLHttpRequest(),
url = this.url,
recording = this,
contentLength = 0;
xhr.onreadystatechange = function () {
if (xhr.readyState === xhr.DONE) {
if (xhr.status === 200 || xhr.status === 0) {
if (xhr.responseText) {
recording.finishLoad(xhr.responseText, callback);
} else {
console.error('Leap Playback: "' + url + '" seems to be unreachable or the file is empty.');
}
} else {
console.error('Leap Playback: Couldn\'t load "' + url + '" (' + xhr.status + ')');
}
}
};
xhr.addEventListener('progress', function(oEvent){
if ( recording.options.loadProgress ) {
if (oEvent.lengthComputable) {
var percentComplete = oEvent.loaded / oEvent.total;
recording.options.loadProgress( recording, percentComplete, oEvent );
}
}
});
this.loading = true;
xhr.open("GET", url, true);
xhr.send(null);
},
finishLoad: function(responseData, callback){
var url = this.url;
if (url.split('.')[url.split('.').length - 1] == 'lz') {
responseData = this.decompress(responseData);
}
responseData = JSON.parse(responseData);
if (responseData.metadata.formatVersion == 2) {
responseData.frames = this.unPackFrameData(responseData.frames);
}
this.metadata = responseData.metadata;
console.log('Recording loaded:', this.metadata);
this.loading = false;
if (callback) {
callback.call(this, responseData.frames);
}
},
loadCompressedRecording: function(compressedData, callback) {
var decompressedData = this.decompress(compressedData);
decompressedData = JSON.parse(decompressedData);
if (decompressedData.metadata.formatVersion == 2) {
decompressedData.frames = this.unPackFrameData(decompressedData.frames);
}
this.metadata = decompressedData.metadata;
console.log('Recording loaded:', this.metadata);
this.loading = false;
if (callback) {
callback.call(this, decompressedData.frames);
}
}
};
(function () {
var CONNECT_LEAP_ICON = '<img class="playback-connect-leap" style="margin: 0px 2px -2px 0px; max-width: 100%;" src="data:image/gif;base64,R0lGODlh9AHmAPf/AOnxtbS0tc3eVTMzNM3NztvnhtbX1/Hy86ioqezs7Z6entzd3r7UJdHhZcnKzOjo6sXZO319fkJCQurq6xMTFPj65q2treHh4vT42uXl5/Lz9JCQkfP09FxcXebm54yMjsvMzSgoKdfleLGxsUtLTPDx8cHCwlNTVSIiI6CgoYSEhAUFBpmZmtHS1Li5ucHDxNHR0err7HZ2duDrl8XFxvP09vv98/Dx8trb3MjIyN7e3x4eHmZmZj4+PrzTHtDQ0uvs7dra2uzt7ZWVldfY2cPExtbX2AsLDImJiff4+MbHysjIyoCBgsXGyHp6er6+vsDVKm5ucOTk5XFxc83O0MbGx2FhYm1tbsDWLKWlpu70xRkZGkVFRtPU1pydoM/Q0bKztKamqCQkJeLi5O/w8Glpab/AwtjY2f7+/WtrbcfIytjZ26qrrDc4OdHS0nBxc7/VJ5KTlI6Oj9LS0/v8/b7UI3JzdMPDxIqKi5aWl7y9vi4uL8fHx3BwcNvc3pKSk62usCsrLN/f4Ly8vZydnp2dndXV1snJycXGym5vcefn6IuMjYWFhYODhiMjJCYmJ+fo6UhISR8fH/7+/hsbHP39/ejo6cHWLu/v8Lq7vPv7++nq6+3u7q+wsby9wL6/wvj4+Pz8/Pr6+vLy8+bn6O7u7vj4+e3u7/f3+PT19fn5+ff39/T09Pb29+jo6MHWL/b29vLy8vT09e7v8L2+v/Pz9O7u7/39/vHy9L2+wbi6ve/v7+no6fz9/fn6+vj5+fv7/Pr7+////v7//8XGx/X19vX29vb39+7v7/z8/enp6uPk5fr6+/n5+g8PENTU1Hh4eYeHh05OT+fo6JGSk0BAQOTl6CAgIS8vML+/wHNzc5eYmLa3t+Xm54OEhfn4+Y+PkFdXV1hZWv3++IiIiJGRkZubm3t8ff7+/35/gOjp6ru8vmRkZMTFyLO0tWJiY6eoqycnKGxsbkNDQ77TIl5eX6Kjo+Xm6QcHBwgICZSUlvLz8y0tLv///wAAAP///yH/C1hNUCBEYXRhWE1QPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS4zLWMwMTEgNjYuMTQ1NjYxLCAyMDEyLzAyLzA2LTE0OjU2OjI3ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ1M2IChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjQyMUExNDAzQjE2NzExRTNBMUE1RTA3Qjc0NDg0NDMxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjQyMUExNDA0QjE2NzExRTNBMUE1RTA3Qjc0NDg0NDMxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NDIxQTE0MDFCMTY3MTFFM0ExQTVFMDdCNzQ0ODQ0MzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NDIxQTE0MDJCMTY3MTFFM0ExQTVFMDdCNzQ0ODQ0MzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4B//79/Pv6+fj39vX08/Lx8O/u7ezr6uno5+bl5OPi4eDf3t3c29rZ2NfW1dTT0tHQz87NzMvKycjHxsXEw8LBwL++vby7urm4t7a1tLOysbCvrq2sq6qpqKempaSjoqGgn56dnJuamZiXlpWUk5KRkI+OjYyLiomIh4aFhIOCgYB/fn18e3p5eHd2dXRzcnFwb25tbGtqaWhnZmVkY2JhYF9eXVxbWllYV1ZVVFNSUVBPTk1MS0pJSEdGRURDQkFAPz49PDs6OTg3NjU0MzIxMC8uLSwrKikoJyYlJCMi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">';
var MOVE_HAND_OVER_LEAP_ICON = '<img class="playback-move-hand" style="margin: 0px 2px -2px 0px; max-width: 100%;" src="data:image/gif;base64,R0lGODlh9AHtAPf/AJ2dnfT09KWlpZWVlejo6bGxsRwcHHl5eYmJiRMTFOrq66GhoYWFhcHCxObm54GBgdjY2GFhYcXGydPT0+Dg4OPj4319fQsLC0ZGRuTk5GlpaVVVVV5eXpGRkXFxcd7e3kFBQXV1ddbW1lJSUsDAwNvb221tbk1OTgMDAykpKaurqzw9PdTU1FlZWdzc3JiYmMjKzLu7u8jIyJubnPLy9M7Ozr2/wUlJSTo6OsLCwmRkZDY2NtDQ0DIyMr29vaytsKysrC4uLra2ttHS1CUlJtXX2MrKy6+vr5qamiEhIczMzM3O0LS0tMTExMbGxsbIyt7e4I2Njby8vOTl5qioqMPEx+rr7KqqrOjo6np6fNTV17i4uMzNzo+Pj3Z3ePHx8rm7veDh4uzt7r/AwoeHh8PDxM/Q0sTFx42OkMDBw+fo6bW3utjZ2vHy8tHR0sjJyru8v+Xl5d3d3dzc3mNjZJ+fn2pra+np6t/f4OLj5NPT1V9fYFdXV8HCxsDBxbO0tq6vsqqsrk9PT0tLS5KTlGdnZ4uMjn1+gHd4e1JTUzs7PEJCQzMzMysrK+fn6P7+/v////39/fPz8+7u7/z8/Pv7+/f39/b29vr6+vj4+Pn5+e7u7vLy8u/v7+3t7ezs7PDw8Ozs7fHx8fDw8fb29/Ly8/X19fr6+/z8/fn5+tvc3e/v8HNzdKmpqvf3+CMjJPDx8fP09OPk5ZmZmvj5+Xt8flBQUP39/mtsbvLz8+/w8Nra3NXW15+govr7+x8fH7m5uicnJ+fn6ezt7Zqcn7Kzs7S0s1hYWLe4uvz9/aanqq2trf3+/ouLi2NkZqOjo5aXmqKkppeYmmBgYPv7/JSVl4ODg4eHihcXFw8PD/7//9HS0kBAQOHh4mhoa0RERDAwMPf4+AcHB+vr7NfX11xcXKenp5manJqameXm6ExMTcjHyfv8/G9vb/j4+UdHRywsLHN0dj4+PsrLzTQ0NObm6Obn6efm6Ly9v39/fzc4OL2+v+3s7fb39wAAAP///yH/C1hNUCBEYXRhWE1QPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS4zLWMwMTEgNjYuMTQ1NjYxLCAyMDEyLzAyLzA2LTE0OjU2OjI3ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ1M2IChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjQyMUExNDA3QjE2NzExRTNBMUE1RTA3Qjc0NDg0NDMxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjQyMUExNDA4QjE2NzExRTNBMUE1RTA3Qjc0NDg0NDMxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NDIxQTE0MDVCMTY3MTFFM0ExQTVFMDdCNzQ0ODQ0MzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NDIxQTE0MDZCMTY3MTFFM0ExQTVFMDdCNzQ0ODQ0MzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4B//79/Pv6+fj39vX08/Lx8O/u7ezr6uno5+bl5OPi4eDf3t3c29rZ2NfW1dTT0tHQz87NzMvKycjHxsXEw8LBwL++vby7urm4t7a1tLOysbCvrq2sq6qpqKempaSjoqGgn56dnJuamZiXlpWUk5KRkI+OjYyLiomIh4aFhIOCgYB/fn18e3p5eHd2dXRzcnFwb25tbGtqaWhnZmVkY2JhYF9eXVxbWllYV1ZVVFNSUVBPTk1MS0pJSEdGRURDQkFAPz49PDs6OTg3NjU0MzIxMC8uLSwrKikoJyYlJCMi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" />';
function Player(controller, options) {
var player = this;
options || (options = {});
// this.frameData = [];
this.options = options;
this.recording = options.recording;
this.controller = controller;
this.resetTimers();
this.setupLoops();
this.controller.connection.on('ready', function () {
player.setupProtocols();
});
this.userHasControl = false;
if (options.recording) {
if (Object.prototype.toString.call(options.recording) == '[object String]') {
options.recording = {
url: options.recording
}
}
this.setRecording(options.recording);
}
document.addEventListener("DOMContentLoaded", function(event) {
document.body.addEventListener('keydown', function (e) {
if (e.which === player.options.pauseHotkey) {
player.toggle();
}
}, false);
});
}
Player.prototype = {
resetTimers: function (){
this.timeSinceLastFrame = 0;
this.lastFrameTime = null;
},
setupLoops: function () {
var player = this;
// Loop with explicit frame timing
this.stepFrameLoop = function (timestamp) {
if (player.state != 'playing') return;
player.sendFrameAt(timestamp || performance.now());
requestAnimationFrame(player.stepFrameLoop);
};
},
// This is how we intercept frame data early
// By hooking in before Frame creation, we get data exactly as the frame sends it.
setupProtocols: function () {
var player = this;
// This is the original normal protocol, used while in record mode but not recording.
this.stopProtocol = this.controller.connection.protocol;
// This consumes all frame data, making the device act as if not streaming
this.playbackProtocol = function (data) {
// The old protocol still needs to emit events, so we use it, but intercept Frames
var eventOrFrame = player.stopProtocol(data);
if (eventOrFrame instanceof Leap.Frame) {
if (player.pauseOnHand) {
if (data.hands.length > 0) {
player.userHasControl = true;
player.controller.emit('playback.userTakeControl');
player.setGraphic();
player.idle();
} else if (data.hands.length == 0) {
if (player.userHasControl && player.resumeOnHandLost) {
player.userHasControl = false;
player.controller.emit('playback.userReleaseControl');
player.setGraphic('wave');
}
}
}
// prevent the actual frame from getting through
return {type: 'playback'}
} else {
return eventOrFrame;
}
};
// This pushes frame data, and watches for hands to auto change state.
// Returns the eventOrFrame without modifying it.
this.recordProtocol = function (data) {
var eventOrFrame = player.stopProtocol(data);
if (eventOrFrame instanceof Leap.Frame) {
player.recordFrameHandler(data);
}
return eventOrFrame;
};
// Copy methods/properties from the default protocol over
for (var property in this.stopProtocol) {
if (this.stopProtocol.hasOwnProperty(property)) {
this.playbackProtocol[property] = this.stopProtocol[property]
this.recordProtocol[property] = this.stopProtocol[property]
}
}
// todo: this is messy. Should cover all cases, not just active playback!
if (this.state == 'playing') {
this.controller.connection.protocol = this.playbackProtocol
}
},
// Adds playback = true to artificial frames
sendFrameAt: function (now) {
if (this.lastFrameTime){
// chrome bug, see: https://code.google.com/p/chromium/issues/detail?id=268213
// http://jsfiddle.net/pehrlich/35pTx/
// console.assert(this.lastFrameTime < now);
if (now < this.lastFrameTime){
// this fix will cause an extra animation frame before the lerp frame advances. no big.
this.lastFrameTime = now;
}else{
this.timeSinceLastFrame += (now - this.lastFrameTime);
}
}
this.lastFrameTime = now;
// console.assert(!isNaN(this.timeSinceLastFrame));
var timeToNextFrame;
// handle frame dropping, etc
while ( this.timeSinceLastFrame > ( timeToNextFrame = this.recording.timeToNextFrame() ) ){
this.timeSinceLastFrame -= timeToNextFrame;
if (!this.recording.advanceFrame()){
this.pause();
this.controller.emit('playback.playbackFinished', this);
return
}
}
this.sendFrame(
this.recording.createLerpFrameData(this.timeSinceLastFrame / timeToNextFrame)
);
},
sendFrame: function(frameData){
if (!frameData) throw "Frame data not provided";
var frame = new Leap.Frame(frameData);
// send a deviceFrame to the controller:
// this frame gets picked up by the controllers own animation loop.
this.controller.processFrame(frame);
return true
},
sendImmediateFrame: function(frameData){
if (!frameData) throw "Frame data not provided";
var frame = new Leap.Frame(frameData);
// sends an animation frame to the controller
this.controller.processFinishedFrame(frame);
return true
},
setFrameIndex: function (frameIndex) {
if (frameIndex != this.recording.frameIndex) {
this.recording.frameIndex = frameIndex % this.recording.frameCount;
this.sendFrame(this.recording.currentFrame());
}
},
// used after record
stop: function () {
this.idle();
delete this.recording;
this.recording = new Recording({
timeBetweenLoops: this.options.timeBetweenLoops,
loop: this.options.loop,
requestProtocolVersion: this.controller.connection.opts.requestProtocolVersion,
serviceVersion: this.controller.connection.protocol.serviceVersion
});
this.controller.emit('playback.stop', this);
},
// used after play
pause: function () {
// todo: we should change this idle state to paused or leave it as playback with a pause flag
// state should correspond always to protocol handler (through a setter)?
this.state = 'idle';
this.hideOverlay();
this.controller.emit('playback.pause', this);
},
idle: function () {
this.state = 'idle';
this.controller.connection.protocol = this.stopProtocol;
},
toggle: function () {
if (this.state == 'idle') {
this.play();
} else if (this.state == 'playing') {
this.pause();
}
},
// switches to record mode, which will be begin capturing data when a hand enters the frame,
// and stop when a hand leaves
// Todo: replace frameData with a full fledged recording, including metadata.
record: function () {
this.clear();
this.stop();
this.state = 'recording';
this.controller.connection.protocol = this.recordProtocol;
this.setGraphic('connect');
this.controller.emit('playback.record', this)
},
// if there is existing frame data, sends a frame with nothing in it
clear: function () {
if (!this.recording || this.recording.blank()) return;
var finalFrame = this.recording.cloneCurrentFrame();
finalFrame.hands = [];
finalFrame.fingers = [];
finalFrame.pointables = [];
finalFrame.tools = [];
this.sendImmediateFrame(finalFrame);
},
recordPending: function () {
return this.state == 'recording' && this.recording.blank()
},
isRecording: function () {
return this.state == 'recording' && !this.recording.blank()
},
finishRecording: function () {
// change to the playbackHandler which suppresses frames:
this.controller.connection.protocol = this.playbackProtocol;
this.recording.setFrames(this.recording.frameData);
this.controller.emit('playback.recordingFinished', this)
},
loaded: function () {
return this.recording.loaded();
},
loading: function(){
return this.recording.loading;
},
/* Plays back the provided frame data
* Params {object|boolean}:
* - frames: previously recorded frame json
* - loop: whether or not to loop playback. Defaults to true.
*/
play: function () {
if (this.state === 'playing') return;
if ( this.loading() || this.recording.blank() ) return;
this.state = 'playing';
this.controller.connection.protocol = this.playbackProtocol;
var player = this;
// prevent the normal controller response while playing
this.controller.connection.removeAllListeners('frame');
this.controller.connection.on('frame', function (frame) {
// resume play when hands are removed:
if (player.resumeOnHandLost && player.autoPlay && player.state == 'idle' && frame.hands.length == 0) {
player.play();
}
// The default LeapJS callback processes the frame, which is what we do now:
player.controller.processFrame(frame);
});
// Kick off
this.resetTimers();
this.recording.readyPlay();
this.stepFrameLoop();
this.controller.emit('playback.play', this);
},
// this method replaces connection.handleData when in record mode
// It accepts the raw connection data which is used to make a frame.
recordFrameHandler: function (frameData) {
// Would be better to check controller.streaming() in showOverlay, but that method doesn't exist, yet.
this.setGraphic('wave');
if (frameData.hands.length > 0) {
this.recording.addFrame(frameData);
this.hideOverlay();
} else if ( !this.recording.blank() ) {
this.finishRecording();
}
},
// Accepts a hash with any of
// URL, recording, metadata, compressedRecording
// once loaded, the recording is immediately activated
setRecording: function (options) {
var player = this;
// otherwise, the animation loop may try and play non-existant frames:
this.pause();
// this is called on the context of the recording
var loadComplete = function (frames) {
this.setFrames(frames);
if (player.recording != this){
console.log('recordings changed during load');
return
}
if (player.autoPlay) {
player.play();
if (player.pauseOnHand && !player.controller.streaming() ) {
player.setGraphic('connect');
}
}
player.controller.emit('playback.recordingSet', this);
};
this.recording = options;
// Here we turn the existing argument in to a recording
// this allows frames to be added to the existing object via ajax
// saving ajax requests
if (!(options instanceof Recording)){
this.recording.__proto__ = Recording.prototype;
Recording.call(this.recording, {
timeBetweenLoops: this.options.timeBetweenLoops,
loop: this.options.loop,
loadProgress: function(recording, percentage, oEvent){
player.controller.emit('playback.ajax:progress', recording, percentage, oEvent);
}
});
}
if ( this.recording.loaded() ) {
loadComplete.call(this.recording, this.recording.frameData);
} else if (options.url) {
this.controller.emit('playback.ajax:begin', this, this.recording);
// called in the context of the recording
this.recording.loadFrameData(function(frames){
loadComplete.call(this, frames);
player.controller.emit('playback.ajax:complete', player, this);
});
} else if (options.compressedRecording) {
this.recording.loadCompressedRecording(options.compressedRecording, function(frames){
loadComplete.call(this, frames);
});
}
return this;
},
hideOverlay: function () {
if (!this.overlay) return;
this.overlay.style.display = 'none';
},
// Accepts either "connect", "wave", or undefined.
setGraphic: function (graphicName) {
if (!this.overlay) return;
if (this.graphicName == graphicName) return;
this.graphicName = graphicName;
switch (graphicName) {
case 'connect':
this.overlay.style.display = 'block';
this.overlay.innerHTML = CONNECT_LEAP_ICON;
break;
case 'wave':
this.overlay.style.display = 'block';
this.overlay.innerHTML = MOVE_HAND_OVER_LEAP_ICON;
break;
case undefined:
this.overlay.innerHTML = '';
break;
}
}
};
// will only play back if device is disconnected
// Accepts options:
// - frames: [string] URL of .json frame data
// - autoPlay: [boolean true] Whether to turn on and off playback based off of connection state
// - overlay: [boolean or DOM element] Whether or not to show the overlay: "Connect your Leap Motion Controller"
// if a DOM element is passed, that will be shown/hidden instead of the default message.
// - pauseOnHand: [boolean true] Whether to stop playback when a hand is in field of view
// - resumeOnHandLost: [boolean true] Whether to resume playback after the hand leaves the frame
// - requiredProtocolVersion: clients connected with a lower protocol number will not be able to take control of the
// - timeBetweenLoops: [number, ms] delay between looping playback
// controller with their device. This option, if set, ovverrides autoPlay
// - pauseHotkey: [number or false, default: 32 (spacebar)] - keycode for pause, bound to body
var playback = function (scope) {
var controller = this;
var autoPlay = scope.autoPlay;
if (autoPlay === undefined) autoPlay = true;
var pauseOnHand = scope.pauseOnHand;
if (pauseOnHand === undefined) pauseOnHand = true;
var resumeOnHandLost = scope.resumeOnHandLost;
if (resumeOnHandLost === undefined) resumeOnHandLost = true;
var timeBetweenLoops = scope.timeBetweenLoops;
if (timeBetweenLoops === undefined) timeBetweenLoops = 50;
var requiredProtocolVersion = scope.requiredProtocolVersion;
var pauseHotkey = scope.pauseHotkey;
if (pauseHotkey === undefined) pauseHotkey = 32; // spacebar
var loop = scope.loop;
if (loop === undefined) loop = true;
var overlay = scope.overlay;
// A better fix would be to set an onload handler for this, rather than disable the overlay.
if (overlay === undefined && document.body) {
overlay = document.createElement('div');
document.body.appendChild(overlay);
overlay.style.width = '100%';
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '-' + window.getComputedStyle(document.body).getPropertyValue('margin');
overlay.style.padding = '10px';
overlay.style.textAlign = 'center';
overlay.style.fontSize = '18px';
overlay.style.opacity = '0.8';
overlay.style.display = 'none';
overlay.style.zIndex = '10';
overlay.id = 'connect-leap';
overlay.style.cursor = 'pointer';
overlay.addEventListener("click", function () {
this.style.display = 'none';
return false;
}, false);
}
scope.player = new Player(this, {
recording: scope.recording,
loop: loop,
pauseHotkey: pauseHotkey,
timeBetweenLoops: timeBetweenLoops
});
// By doing this, we allow player methods to be accessible on the scope
// this is the controller
scope.player.overlay = overlay;
scope.player.pauseOnHand = pauseOnHand;
scope.player.resumeOnHandLost = resumeOnHandLost;
scope.player.requiredProtocolVersion = requiredProtocolVersion;
scope.player.autoPlay = autoPlay;
var setupStreamingEvents = function () {
if (scope.player.pauseOnHand && controller.connection.opts.requestProtocolVersion < scope.requiredProtocolVersion) {
console.log('Protocol Version too old (' + controller.connection.opts.requestProtocolVersion + '), disabling device interaction.');
scope.player.pauseOnHand = false;
return
}
if (autoPlay) {
controller.on('streamingStarted', function () {
if (scope.player.state == 'recording') {
scope.player.pause();
scope.player.setGraphic('wave');
} else {
if (pauseOnHand) {
scope.player.setGraphic('wave');
} else {
scope.player.setGraphic();
}
}
});
controller.on('streamingStopped', function () {
scope.player.play();
});
}
controller.on('streamingStopped', function () {
scope.player.setGraphic('connect');
});
}
// ready happens before streamingStarted, allowing us to check the version before responding to streamingStart/Stop
// we can't call this any earlier, or protcol version won't be available
if (!!this.connection.connected) {
setupStreamingEvents()
} else {
this.on('ready', setupStreamingEvents)
}
return {}
}
if ((typeof Leap !== 'undefined') && Leap.Controller) {
Leap.Controller.plugin('playback', playback);
} else if (typeof module !== 'undefined') {
module.exports.playback = playback;
} else {
throw 'leap.js not included';
}
}).call(this);
}( window ));