diff --git a/CMakeLists.txt b/CMakeLists.txt index 388aab0..f55403b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -116,10 +116,10 @@ find_package(GitVersion) configure_file("${PROJECT_SOURCE_DIR}/HyperionConfig.h.in" "${PROJECT_BINARY_DIR}/HyperionConfig.h") include_directories("${PROJECT_BINARY_DIR}") -if(ENABLE_QT5) - ADD_DEFINITIONS ( -DENABLE_QT5 ) +if( NOT ENABLE_QT5) + #ADD_DEFINITIONS ( -DENABLE_QT5 ) #find_package(Qt5Widgets) -else() +#else() # Add specific cmake modules to find qt4 (default version finds first available QT which might not be qt4) set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_CURRENT_SOURCE_DIR}/cmake/qt4) endif() diff --git a/HyperionConfig.h.in b/HyperionConfig.h.in index 3ce776b..6f9abff 100644 --- a/HyperionConfig.h.in +++ b/HyperionConfig.h.in @@ -32,6 +32,7 @@ // Define to enable profiler for development purpose #cmakedefine ENABLE_PROFILER +#cmakedefine ENABLE_QT5 // the hyperion build id string #define HYPERION_VERSION_ID "${HYPERION_VERSION_ID}" diff --git a/assets/webconfig/css/index.css b/assets/webconfig/css/index.css new file mode 100644 index 0000000..89d347e --- /dev/null +++ b/assets/webconfig/css/index.css @@ -0,0 +1,609 @@ +@font-face { + font-family: 'fontello'; + src: url('../res/fontello.ttf') format('truetype'), url('../res/fontello.woff') format('woff'); + font-weight: normal; + font-style: normal; +} + +html, body { + height: 100%; + width: 100%; + padding: 0; + margin: 0; + color: #A6B4B4; + background-color: #2C2C2C; +} + +* { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-box-sizing: border-box; + box-sizing: border-box; + -webkit-touch-callout: none; + -webkit-text-size-adjust: none; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + font-family: 'Lucida Grande', Helvetica, Arial, Roboto, serif; +} + +#app { + height: 100%; + width: 100%; + display: -webkit-flex; + -webkit-flex-direction: column; + -webkit-flex-wrap: nowrap; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + + background-color: #2C2C2C; + min-width: 320px; + min-height: 460px; +} + +.work { + -webkit-flex: 1; + flex: 1; + position: relative; + overflow: hidden; +} + +.footer { + border-top: 1px solid #919F9F; + display: -webkit-flex; + -webkit-justify-content: space-around; + display: flex; + justify-content: space-around; +} + +.footer .button { + width: 100%; + text-align: center; + position: relative; + padding: 5px; +} + +.touchrect { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 10; +} + +.icon { + font-family: "fontello"; +} + +.footer .button .icon { + font-size: 25px; + margin-top: 8px; +} + +.footer .button .title { + font-size: 12px; + margin-top: 8px; + margin-bottom: 8px; +} + +.footer .button:active, +.footer .button.selected { + color: #FFFFFF; +} + +.container { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 400%; + display: flex; + display: -webkit-flex; + -webkit-transition: left 0.5s ease-in-out; + transition: left 0.5s ease-in-out; +} + +.contentarea { + width: 100%; + height: 100%; + overflow-y: auto; + -ms-overflow-y: auto; + -ms-overflow-style: -ms-autohiding-scrollbar; + position: relative; + -webkit-overflow-scrolling: touch; +} + +#color { + display: -webkit-flex; + -webkit-flex-direction: column; + display: flex; + flex-direction: column; +} + +#colorpicker { + position: relative; + -webkit-flex: 1; + flex: 1; +} + +#colorwheelbg { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: auto; +} + +#pointer { + width: 30px; + height: 30px; + position: absolute; + border: 2px solid #FFFFFF; + border-radius: 15px; + left: calc(50% - 15px); + top: calc(50% - 15px); +} + +ul { + list-style: none; + padding: 0; + margin: 0; +} + +li { + display: block; +} + +.horizontal { + display: -webkit-flex; + -webkit-flex: 1; + display: flex; + flex: 1; + position: relative; +} + +li .delete_icon { + font-family: "fontello"; + font-size: 20px; + color: red; + height: 40px; + width: 40px; + line-height: 40px; + text-align: center; +} + +li .edit_icon { + font-family: "fontello"; + font-size: 20px; + height: 40px; + width: 40px; + line-height: 40px; + text-align: center; +} + +.locked .edit_icon, +.locked .delete_icon { + display: none; +} + +li .titlebox { + -webkit-flex: 1; + flex: 1; +} + +.titlebox label { + display: block; + line-height: 20px; + height: 20px; +} + +.titlebox label.title { + font-size: 16px; +} + +.titlebox label.subtitle { + font-size: 10px; +} + +/* +li:not(:last-child) { + border-bottom: 1px solid #919F9F; +} +*/ + +li { + border-bottom: 1px solid #919F9F; +} + +li input[type=range] { + width: 100%; +} + +#transform li { + padding: 0; +} + +#transform .icon { + width: 40px; + text-align: center; +} + +.group, +.grouplist { + margin-top: 10px; +} + +.grouplist .header { + display: flex; + justify-content: space-between; + border-bottom: 1px solid gray; + align-items: center; + padding-left: 15px; + font-size: 16px; + display: -webkit-flex; + -webkit-justify-content: space-between; + -webkit-align-items: center; +} + +.group ul, +.grouplist ul { + overflow-y: hidden; + -webkit-transition: all 0.5s ease-in-out; + transition: all 0.5s ease-in-out; +} + +.group[collapsed=true] ul { + max-height: 0; +} + +.group li label { + margin: 4px 0 0 50px; + display: block; + background: none; +} + +.group > .header { + border-bottom: 1px solid gray; + padding: 3px 0 3px 10px; + position: relative; +} + +.group > .header label { + display: block; + background: none; +} + +.group > .header label:first-child { + font-size: 16px; +} + +.group > .header label:last-child { + font-size: 12px; +} + +.group > .header label:first-child:after { + font-family: "fontello"; + content: '\e808'; + font-size: 20px; + transition: all 0.5s ease-in-out; + -webkit-transform-origin: 50% 33%; + -webkit-transform: rotateZ(180deg); + transform-origin: 50% 33%; + transform: rotateZ(180deg); + -ms-transform-origin: 50% 33%; + -ms-transform: rotateZ(180deg); + position: absolute; + right: 24px; + top: 0; + bottom: 0; +} + +.grouplist .header .callout { + font-family: "fontello"; + font-size: 20px; + padding: 10px; +} + +.grouplist .header .callout:active { + color: #FFFFFF; +} + +.group[collapsed=true] > .header label:first-child:after { + -webkit-transform: rotateZ(0deg); + -ms-transform: rotateZ(0deg); + transform: rotateZ(0deg); +} + +.wrapper { + display: flex; + align-items: center; + display: -webkit-flex; + -webkit-align-items: center; +} + +.wrapper .value { + width: 40px; + background: transparent; + border: 0; + color: #FFFFFF; + margin: auto 5px; +} + +#transform .group .slider { + -webkit-flex: 1; + flex: 1; + width: 100%; +} + +.slider { + position: relative; + margin-left: 20px; + margin-right: 20px; +} + +.slider .track { + border-radius: 4px; + height: 4px; + border: 1px solid #BDC3C7; + background-color: #FFFFFF; + +} + +.slider .thumb { + box-sizing: border-box; + border-radius: 10px; + height: 20px; + width: 20px; + border: 2px solid #BDC3C7; + background-color: #FFFFFF; + position: absolute; + top: -8px; + margin-left: -10px; +} + +.wrapper .icon { + display: block; + font-size: 30px; +} + +.red .icon { + color: #FF0000; +} + +.green .icon { + color: #00FF00; +} + +.blue .icon { + color: #0000FF; +} + +.msg { + position: relative; + margin: auto; + padding: 5px 10px; + font-size: 12px; + font-weight: bold; +} + +.error { + background-color: #D70000; + color: #FFFFFF; +} + +.status { + background-color: #00A200; + color: #FFFFFF; +} + +.wrapper_msg { + position: absolute; + bottom: 5px; + width: 100%; + display: -webkit-flex; + display: flex; + visibility: visible; + opacity: 1; + -webkit-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; +} + +.invisible { + opacity: 0; + visibility: hidden; +} + +.hidden { + display: none !important; +} + +.inputline { + display: -webkit-flex; + -webkit-justify-content: space-between; + -webkit-align-items: center; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 14px; + padding: 6px 10px; +} + +#settings .inputline input { + background: none; + border: 1px solid rgba(100, 100, 100, 0.4); + height: 100%; + color: white; + text-align: right; + font-size: inherit; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; + outline: none; +} + +#settings .inputline input:focus { + color: white; +} + +.line { + width: 100%; + position: relative; + padding: 5px 0; +} + +button { + margin: auto 10px; + border: 1px #A6B4B4 solid; + background: none; + color: #A6B4B4; + padding: 6px 20px; +} + +button:active { + border: 1px #FFFFFF solid; + background-color: transparent; + color: #FFFFFF; + outline: none; +} + +button:focus { + outline: none; +} + +.spinner { + height: 20px; + width: 20px; + display: inline-flex; + display: -webkit-inline-flex; + -webkit-animation: rotation .8s infinite linear; + animation: rotation .8s infinite linear; + border: 6px inset #D7D7D7; + border-radius: 50%; + float: right; + margin-right: 15px; +} + +@-webkit-keyframes rotation { + from { + -ms-transform: rotate(0deg); + } + to { + -ms-transform: rotate(359deg); + } +} + +@-webkit-keyframes rotation { + from { + -webkit-transform: rotate(0deg); + } + to { + -webkit-transform: rotate(359deg); + } +} + +@keyframes rotation { + from { + transform: rotate(0deg); + } + to { + transform: rotate(359deg); + } +} + +li.selected { + background-color: rgba(100, 100, 100, 0.5); +} + +#effects li { + height: 40px; + line-height: 40px; + padding-left: 20px; +} + +.info { + font-size: 16px; + position: absolute; + top: 50%; + width: 100%; + text-align: center; +} + +#color #buttonctrl { + display: -webkit-flex; + -webkit-justify-content: space-between; + -webkit-align-items: flex-end; + display: flex; + justify-content: space-between; + align-items: flex-end; + margin: 10px auto 35px auto; +} + +#color #buttonctrl > .icon { + font-size: 27px; + line-height: 100%; + padding: 0 20px; +} + +#color #buttonctrl > .icon:active { + color: #FFFFFF; +} + +#color .slider { + position: relative; + width: 70%; + margin: 10px auto; + height: 20px; +} + +#color .slider .track { + width: 100%; + height: 100%; + background-image: -webkit-linear-gradient(left, #000000 0%, #FFFFFF 100%); + background-image: linear-gradient(to right, #000000 0%, #FFFFFF 100%); +} + +#color .slider .thumb { + background: transparent; + border: 3px solid rgba(255, 255, 255, 1.0); + width: 14px; + border-radius: 4px; + height: 28px; + position: absolute; + top: -4px; + margin-left: -7px; +} + +#color .value { + outline: none; + border: 1px solid white; + font-family: Monaco, monospace; + text-align: center; + background: transparent; + width: 100px; + color: white; + font-size: 16px; + display: block; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; +} + +.checkbox::before { + display: table-cell; + font-family: "fontello"; + content: '\e803'; + height: 40px; + width: 40px; + font-size: 20px; + vertical-align: middle; + text-align: center; +} + +.selected .checkbox::before { + content: '\e802'; +} \ No newline at end of file diff --git a/assets/webconfig/index.html b/assets/webconfig/index.html new file mode 100644 index 0000000..5f28371 --- /dev/null +++ b/assets/webconfig/index.html @@ -0,0 +1,69 @@ + + + + Hyperion remote control + + + + + + + + +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/assets/webconfig/js/app/api/ChromeLocalStorage.js b/assets/webconfig/js/app/api/ChromeLocalStorage.js new file mode 100644 index 0000000..82467df --- /dev/null +++ b/assets/webconfig/js/app/api/ChromeLocalStorage.js @@ -0,0 +1,37 @@ +/*global define, chrome */ +define(['api/LocalStorage'], function (LocalStorage) { + 'use strict'; + return LocalStorage.subclass(/** @lends ChromeLocalStorage.prototype */{ + + /** + * @class ChromeLocalStorage + * @classdesc Chrome's persistent storage + * @constructs + * @extends LocalStorage + */ + constructor: function () { + }, + + get: function () { + chrome.storage.local.get('data', function (entry) { + if (chrome.runtime.lastError) { + this.emit('error', chrome.runtime.lastError.message); + } else { + this.emit('got', entry.data); + } + }.bind(this)); + }, + + set: function (data) { + var entry = {}; + entry.data = data; + chrome.storage.local.set(entry, function () { + if (chrome.runtime.lastError) { + this.emit('error', chrome.runtime.lastError.message); + } else { + this.emit('set'); + } + }.bind(this)); + } + }); +}); diff --git a/assets/webconfig/js/app/api/ChromeNetwork.js b/assets/webconfig/js/app/api/ChromeNetwork.js new file mode 100644 index 0000000..9915411 --- /dev/null +++ b/assets/webconfig/js/app/api/ChromeNetwork.js @@ -0,0 +1,57 @@ +/*global chrome */ +define(['api/Network'], function (Network) { + 'use strict'; + + return Network.subclass(/** @lends ChromeNetwork.prototype */{ + + /** + * @class ChromeNetwork + * @extends Network + * @classdesc Network functions for chrome apps + * @constructs + */ + constructor: function () { + }, + + /** + * @overrides + * @param onSuccess + * @param onError + */ + getLocalInterfaces: function (onSuccess, onError) { + var ips = []; + + chrome.system.network.getNetworkInterfaces(function (networkInterfaces) { + var i; + + if (chrome.runtime.lastError) { + if (onError) { + onError('Could not get network interfaces'); + } + return; + } + + for (i = 0; i < networkInterfaces.length; i++) { + // check only ipv4 + if (networkInterfaces[i].address.indexOf('.') === -1) { + continue; + } + + ips.push(networkInterfaces[i].address); + } + + if (onSuccess) { + onSuccess(ips); + } + }); + }, + + /** + * @overrides + * @return {boolean} + */ + canDetectLocalAddress: function () { + return true; + } + }, true); +}); diff --git a/assets/webconfig/js/app/api/ChromeTcpSocket.js b/assets/webconfig/js/app/api/ChromeTcpSocket.js new file mode 100644 index 0000000..f626b1d --- /dev/null +++ b/assets/webconfig/js/app/api/ChromeTcpSocket.js @@ -0,0 +1,307 @@ +/*global chrome */ +define(['lib/stapes', 'api/Socket', 'utils/Tools'], function (Stapes, Socket, tools) { + 'use strict'; + return Socket.subclass(/** @lends ChromeTcpSocket.prototype */{ + DEBUG: false, + + /** + * @type {number} + */ + handle: null, + + /** + * @type {function} + */ + currentResponseCallback: null, + + /** + * @type {function} + */ + currentErrorCallback: null, + + /** + * Temporary buffer for incoming data + * @type {Uint8Array} + */ + inputBuffer: null, + inputBufferIndex: 0, + readBufferTimerId: null, + + /** + * @class ChromeTcpSocket + * @extends Socket + * @constructs + */ + constructor: function () { + this.inputBuffer = new Uint8Array(4096); + this.inputBufferIndex = 0; + + chrome.sockets.tcp.onReceive.addListener(this.onDataReceived.bind(this)); + chrome.sockets.tcp.onReceiveError.addListener(this.onError.bind(this)); + }, + + create: function (onSuccess, onError) { + if (this.DEBUG) { + console.log('[DEBUG] Creating socket...'); + } + chrome.sockets.tcp.create({bufferSize: 4096}, function (createInfo) { + if (this.DEBUG) { + console.log('[DEBUG] Socket created: ' + createInfo.socketId); + } + this.handle = createInfo.socketId; + if (onSuccess) { + onSuccess(); + } + }.bind(this)); + }, + + isConnected: function (resultCallback) { + if (this.DEBUG) { + console.log('[DEBUG] Checking if socket is connected...'); + } + + if (!this.handle) { + if (this.DEBUG) { + console.log('[DEBUG] Socket not created'); + } + + if (resultCallback) { + resultCallback(false); + } + return; + } + + chrome.sockets.tcp.getInfo(this.handle, function (socketInfo) { + if (this.DEBUG) { + console.log('[DEBUG] Socket connected: ' + socketInfo.connected); + } + + if (socketInfo.connected) { + if (resultCallback) { + resultCallback(true); + } + } else { + if (resultCallback) { + resultCallback(false); + } + } + }.bind(this)); + }, + + connect: function (server, onSuccess, onError) { + var timeoutHandle; + + if (this.DEBUG) { + console.log('[DEBUG] Connecting to peer ' + server.address + ':' + server.port); + } + + if (!this.handle) { + if (this.DEBUG) { + console.log('[DEBUG] Socket not created'); + } + + if (onError) { + onError('Socket handle is invalid'); + } + return; + } + + // FIXME for some reason chrome blocks if peer is not reachable + timeoutHandle = setTimeout(function () { + chrome.sockets.tcp.getInfo(this.handle, function (socketInfo) { + if (!socketInfo.connected) { + // let the consumer decide if to close or not? + // this.close(); + onError('Could not connect to ' + server.address + ':' + server.port); + } + }.bind(this)); + }.bind(this), 500); + + chrome.sockets.tcp.connect(this.handle, server.address, server.port, function (result) { + if (this.DEBUG) { + console.log('[DEBUG] Connect result: ' + result); + } + clearTimeout(timeoutHandle); + + if (chrome.runtime.lastError) { + if (onError) { + onError('Could not connect to ' + server.address + ':' + server.port); + } + return; + } + + if (result !== 0) { + if (onError) { + onError('Could not connect to ' + server.address + ':' + server.port); + } + } else if (onSuccess) { + onSuccess(); + } + }.bind(this)); + }, + + close: function (onSuccess, onError) { + if (this.DEBUG) { + console.log('[DEBUG] Closing socket...'); + } + + if (this.handle) { + chrome.sockets.tcp.close(this.handle, function () { + this.handle = null; + if (onSuccess) { + onSuccess(); + } + }.bind(this)); + } else { + if (this.DEBUG) { + console.log('[DEBUG] Socket not created'); + } + + if (onError) { + onError('Socket handle is invalid'); + } + } + }, + + write: function (data, onSuccess, onError) { + var dataToSend = null, dataType = typeof (data); + + if (this.DEBUG) { + console.log('[DEBUG] writing to socket...'); + } + + if (!this.handle) { + if (this.DEBUG) { + console.log('[DEBUG] Socket not created'); + } + + if (onError) { + onError('Socket handle is invalid'); + } + return; + } + + this.isConnected(function (connected) { + if (connected) { + if (dataType === 'string') { + if (this.DEBUG) { + console.log('> ' + data); + } + dataToSend = tools.str2ab(data); + } else { + if (this.DEBUG) { + console.log('> ' + tools.ab2hexstr(data)); + } + dataToSend = data; + } + + chrome.sockets.tcp.send(this.handle, tools.a2ab(dataToSend), function (sendInfo) { + if (this.DEBUG) { + console.log('[DEBUG] Socket write result: ' + sendInfo.resultCode); + } + + if (sendInfo.resultCode !== 0) { + onError('Socket write error: ' + sendInfo.resultCode); + } else if (onSuccess) { + onSuccess(); + } + }.bind(this)); + } else { + if (onError) { + onError('No connection to peer'); + } + } + }.bind(this)); + + }, + + read: function (onSuccess, onError) { + if (this.DEBUG) { + console.log('[DEBUG] reading from socket...'); + } + + if (!this.handle) { + if (this.DEBUG) { + console.log('[DEBUG] socket not created'); + } + + if (onError) { + onError('Socket handle is invalid'); + } + return; + } + + this.isConnected(function (connected) { + if (!connected) { + this.currentResponseCallback = null; + this.currentErrorCallback = null; + + if (onError) { + onError('No connection to peer'); + } + } + }.bind(this)); + + if (onSuccess) { + this.currentResponseCallback = onSuccess; + } + + if (onError) { + this.currentErrorCallback = onError; + } + }, + + /** + * Data receiption callback + * @private + * @param info + */ + onDataReceived: function (info) { + if (this.DEBUG) { + console.log('[DEBUG] received data...'); + } + + if (info.socketId === this.handle && info.data) { + if (this.readBufferTimerId) { + clearTimeout(this.readBufferTimerId); + } + if (this.readTimeoutTimerId) { + clearTimeout(this.readTimeoutTimerId); + this.readTimeoutTimerId = null; + } + this.inputBuffer.set(new Uint8Array(info.data), this.inputBufferIndex); + this.inputBufferIndex += info.data.byteLength; + + if (this.DEBUG) { + console.log('< ' + tools.ab2hexstr(info.data)); + } + + if (this.currentResponseCallback) { + this.readBufferTimerId = setTimeout(function () { + this.currentResponseCallback(this.inputBuffer.subarray(0, this.inputBufferIndex)); + this.inputBufferIndex = 0; + this.currentResponseCallback = null; + }.bind(this), 200); + } + } + }, + + /** + * Error callback + * @private + * @param info + */ + onError: function (info) { + if (this.DEBUG) { + console.log('[ERROR]: ' + info.resultCode); + } + + if (info.socketId === this.handle) { + if (this.currentErrorCallback) { + this.currentErrorCallback(info.resultCode); + this.currentErrorCallback = null; + } + } + } + }, true); +}); diff --git a/assets/webconfig/js/app/api/LocalStorage.js b/assets/webconfig/js/app/api/LocalStorage.js new file mode 100644 index 0000000..1b74543 --- /dev/null +++ b/assets/webconfig/js/app/api/LocalStorage.js @@ -0,0 +1,49 @@ +/*global define */ +define(['lib/stapes'], function (Stapes) { + 'use strict'; + return Stapes.subclass(/** @lends LocalStorage.prototype */{ + + /** + * @class LocalStorage + * @classdesc LocalStorage handler using HTML5 localStorage + * @constructs + * + * @fires got + * @fires error + * @fires set + */ + constructor: function () { + }, + + /** + * Gets stored data + */ + get: function () { + var data; + + if (!window.localStorage) { + this.emit('error', 'Local Storage not supported'); + return; + } + + if (localStorage.data) { + data = JSON.parse(localStorage.data); + this.emit('got', data); + } + }, + + /** + * Stores settings + * @param {object} data - Data object to store + */ + set: function (data) { + if (!window.localStorage) { + this.emit('error', 'Local Storage not supported'); + return; + } + + localStorage.data = JSON.stringify(data); + this.emit('set'); + } + }); +}); diff --git a/assets/webconfig/js/app/api/Network.js b/assets/webconfig/js/app/api/Network.js new file mode 100644 index 0000000..2d93ca6 --- /dev/null +++ b/assets/webconfig/js/app/api/Network.js @@ -0,0 +1,57 @@ +/*global define */ +define(['lib/stapes'], function (Stapes) { + 'use strict'; + return Stapes.subclass(/** @lends Network.prototype */{ + detectTimerId: null, + + /** + * @class Network + * @classdesc Empty network functions handler + * @constructs + */ + constructor: function () { + }, + + /** + * Returns the list of known local interfaces (ipv4) + * @param {function(string[])} [onSuccess] - Callback to call on success + * @param {function(error:string)} [onError] - Callback to call on error + */ + getLocalInterfaces: function (onSuccess, onError) { + var ips = [], RTCPeerConnection; + + // https://developer.mozilla.org/de/docs/Web/API/RTCPeerConnection + RTCPeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection || window.msRTCPeerConnection; + + var rtc = new RTCPeerConnection({iceServers: []}); + rtc.onicecandidate = function (event) { + var parts; + + if (this.detectTimerId) { + clearTimeout(this.detectTimerId); + } + + if (event.candidate) { + parts = event.candidate.candidate.split(' '); + if (ips.indexOf(parts[4]) === -1) { + console.log(event.candidate); + ips.push(parts[4]); + } + } + + this.detectTimerId = setTimeout(function () { + if (onSuccess) { + onSuccess(ips); + } + }, 200); + }.bind(this); + + rtc.createDataChannel(''); + rtc.createOffer(rtc.setLocalDescription.bind(rtc), onError); + }, + + canDetectLocalAddress: function () { + return window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection || window.msRTCPeerConnection; + } + }, true); +}); diff --git a/assets/webconfig/js/app/api/Socket.js b/assets/webconfig/js/app/api/Socket.js new file mode 100644 index 0000000..1d6e587 --- /dev/null +++ b/assets/webconfig/js/app/api/Socket.js @@ -0,0 +1,68 @@ +/*global define */ +define(['lib/stapes'], function (Stapes) { + 'use strict'; + return Stapes.subclass(/** @lends Socket.prototype */{ + + /** + * @class Socket + * @abstract + */ + constructor: function () { + }, + + /** + * Create the socket + * @param onSuccess + * @param onError + * @abstract + */ + create: function (onSuccess, onError) { + }, + + /** + * Check if a connection is opened. + * @abstract + */ + isConnected: function () { + return false; + }, + + /** + * Connect to another peer + * @param {Object} peer Port object + * @param {function} [onSuccess] Callback to call on success + * @param {function(error:string)} [onError] Callback to call on error + * @abstract + */ + connect: function (peer, onSuccess, onError) { + }, + + /** + * Close the current connection + * @param {function} [onSuccess] Callback to call on success + * @param {function(error:string)} [onError] Callback to call on error + * @abstract + */ + close: function (onSuccess, onError) { + }, + + /** + * Read data from the socket + * @param {function} [onSuccess] Callback to call on success + * @param {function(error:string)} [onError] Callback to call on error + * @abstract + */ + read: function (onSuccess, onError) { + }, + + /** + * Writes data to the socket + * @param {string | Array} data Data to send. + * @param {function} [onSuccess] Callback to call if data was sent successfully + * @param {function(error:string)} [onError] Callback to call on error + * @abstract + */ + write: function (data, onSuccess, onError) { + } + }, true); +}); diff --git a/assets/webconfig/js/app/api/WebSocket.js b/assets/webconfig/js/app/api/WebSocket.js new file mode 100644 index 0000000..a231f07 --- /dev/null +++ b/assets/webconfig/js/app/api/WebSocket.js @@ -0,0 +1,229 @@ +define(['lib/stapes', 'api/Socket', 'utils/Tools'], function (Stapes, Socket, tools) { + 'use strict'; + return Socket.subclass(/** @lends WebSocket.prototype */{ + DEBUG: false, + + handle: null, + + /** + * @type {function} + */ + currentResponseCallback: null, + + /** + * @type {function} + */ + currentErrorCallback: null, + + /** + * Temporary buffer for incoming data + * @type {Uint8Array} + */ + inputBuffer: null, + inputBufferIndex: 0, + readBufferTimerId: null, + + /** + * @class WebSocket + * @extends Socket + * @constructs + */ + constructor: function () { + this.inputBuffer = new Uint8Array(4096); + this.inputBufferIndex = 0; + }, + + create: function (onSuccess, onError) { + if (this.DEBUG) { + console.log('[DEBUG] Creating socket...'); + } + + if (onSuccess) { + onSuccess(); + } + }, + + isConnected: function (resultCallback) { + if (this.DEBUG) { + console.log('[DEBUG] Checking if socket is connected...'); + } + + if (!this.handle) { + if (this.DEBUG) { + console.log('[DEBUG] Socket not created'); + } + + if (resultCallback) { + resultCallback(false); + } + return; + } + + if (resultCallback) { + if (this.handle.readyState === WebSocket.OPEN) { + resultCallback(true); + } else { + resultCallback(false); + } + } + }, + + connect: function (server, onSuccess, onError) { + if (this.DEBUG) { + console.log('[DEBUG] Connecting to peer ' + server.address + ':' + server.port); + } + + this.currentErrorCallback = onError; + + this.handle = new WebSocket('ws://' + server.address + ':' + server.port); + this.handle.onmessage = this.onDataReceived.bind(this); + this.handle.onclose = function () { + if (this.DEBUG) { + console.log('onClose'); + } + }.bind(this); + this.handle.onerror = function () { + if (this.DEBUG) { + console.log('[ERROR]: '); + } + + if (this.currentErrorCallback) { + this.currentErrorCallback('WebSocket error'); + this.currentErrorCallback = null; + } + }.bind(this); + this.handle.onopen = function () { + if (onSuccess) { + onSuccess(); + } + }; + }, + + close: function (onSuccess, onError) { + if (this.DEBUG) { + console.log('[DEBUG] Closing socket...'); + } + + if (this.handle) { + this.handle.close(); + if (onSuccess) { + onSuccess(); + } + } else { + if (this.DEBUG) { + console.log('[DEBUG] Socket not created'); + } + + if (onError) { + onError('Socket handle is invalid'); + } + } + }, + + write: function (data, onSuccess, onError) { + var dataToSend = null, dataType = typeof (data); + + if (this.DEBUG) { + console.log('[DEBUG] writing to socket...'); + } + + if (!this.handle) { + if (this.DEBUG) { + console.log('[DEBUG] Socket not created'); + } + + if (onError) { + onError('Socket handle is invalid'); + } + return; + } + + this.isConnected(function (connected) { + if (connected) { + if (dataType === 'string') { + if (this.DEBUG) { + console.log('> ' + data); + } + //dataToSend = tools.str2ab(data); + dataToSend = data; + } else { + if (this.DEBUG) { + console.log('> ' + tools.ab2hexstr(data)); + } + dataToSend = data; + } + + this.currentErrorCallback = onError; + this.handle.send(dataToSend); + + if (onSuccess) { + onSuccess(); + } + } else { + if (onError) { + onError('No connection to peer'); + } + } + }.bind(this)); + + }, + + read: function (onSuccess, onError) { + if (this.DEBUG) { + console.log('[DEBUG] reading from socket...'); + } + + if (!this.handle) { + if (this.DEBUG) { + console.log('[DEBUG] socket not created'); + } + + if (onError) { + onError('Socket handle is invalid'); + } + return; + } + + this.isConnected(function (connected) { + if (!connected) { + this.currentResponseCallback = null; + this.currentErrorCallback = null; + + if (onError) { + onError('No connection to peer'); + } + } + }.bind(this)); + + if (onSuccess) { + this.currentResponseCallback = onSuccess; + } + + if (onError) { + this.currentErrorCallback = onError; + } + }, + + /** + * Data receiption callback + * @private + * @param event + */ + onDataReceived: function (event) { + if (this.DEBUG) { + console.log('[DEBUG] received data...'); + } + + if (this.handle && event.data) { + if (this.DEBUG) { + console.log('< ' + event.data); + } + + if (this.currentResponseCallback) { + this.currentResponseCallback(tools.str2ab(event.data)); + this.currentResponseCallback = null; + } + } + } + }, true); +}); diff --git a/assets/webconfig/js/app/controllers/AppController.js b/assets/webconfig/js/app/controllers/AppController.js new file mode 100644 index 0000000..c50e4e6 --- /dev/null +++ b/assets/webconfig/js/app/controllers/AppController.js @@ -0,0 +1,534 @@ +/*global define */ +define([ + 'lib/stapes', 'views/MainView', 'models/Settings', 'views/SettingsView', 'views/EffectsView', 'views/TransformView', 'data/ServerControl', 'api/Socket', 'api/Network' +], function (Stapes, MainView, Settings, SettingsView, EffectsView, TransformView, ServerControl, Socket, Network) { + 'use strict'; + var network = new Network(); + + return Stapes.subclass(/** @lends AppController.prototype */{ + /** + * @type MainView + */ + mainView: null, + /** + * @type SettingsView + */ + settingsView: null, + /** + * @type EffectsView + */ + effectsView: null, + /** + * @type TransformView + */ + transformView: null, + /** + * @type Settings + */ + settings: null, + /** + * @type ServerControl + */ + serverControl: null, + color: { + r: 25, + g: 25, + b: 25 + }, + effects: [], + transform: {}, + selectedServer: null, + + /** + * @class AppController + * @constructs + */ + constructor: function () { + this.mainView = new MainView(); + this.settingsView = new SettingsView(); + this.effectsView = new EffectsView(); + this.transformView = new TransformView(); + + this.settings = new Settings(); + + this.bindEventHandlers(); + this.mainView.setColor(this.color); + + if (!network.canDetectLocalAddress()) { + this.settingsView.enableDetectButton(false); + } + }, + + /** + * Do initialization + */ + init: function () { + this.settings.load(); + }, + + /** + * @private + */ + bindEventHandlers: function () { + this.settings.on({ + 'loaded': function () { + var i; + + for (i = 0; i < this.settings.servers.length; i++) { + if (this.settings.servers[i].selected) { + this.selectedServer = this.settings.servers[i]; + break; + } + } + + this.settingsView.fillServerList(this.settings.servers); + + if (!this.selectedServer) { + this.gotoArea('settings'); + } else { + this.connectToServer(this.selectedServer); + } + }, + 'error': function (message) { + this.showError(message); + }, + 'serverAdded': function (server) { + var i; + for (i = 0; i < this.settings.servers.length; i++) { + if (this.settings.servers[i].selected) { + this.selectedServer = this.settings.servers[i]; + this.connectToServer(server); + break; + } + } + + this.settingsView.fillServerList(this.settings.servers); + }, + 'serverChanged': function (server) { + var i; + for (i = 0; i < this.settings.servers.length; i++) { + if (this.settings.servers[i].selected) { + this.selectedServer = this.settings.servers[i]; + this.connectToServer(server); + break; + } + } + + this.settingsView.fillServerList(this.settings.servers); + this.connectToServer(server); + }, + 'serverRemoved': function () { + var i, removedSelected = true; + this.settingsView.fillServerList(this.settings.servers); + + for (i = 0; i < this.settings.servers.length; i++) { + if (this.settings.servers[i].selected) { + removedSelected = false; + break; + } + } + + if (removedSelected) { + this.selectedServer = null; + if (this.serverControl) { + this.serverControl.disconnect(); + } + this.effectsView.clear(); + this.transformView.clear(); + } + } + }, this); + + this.mainView.on({ + 'barClick': function (id) { + if (id !== 'settings') { + if (!this.selectedServer) { + this.showError('No server selected'); + } else if (!this.serverControl) { + this.connectToServer(this.selectedServer); + } + } + this.gotoArea(id); + }, + 'colorChange': function (color) { + this.color = color; + + if (!this.selectedServer) { + this.showError('No server selected'); + } else if (!this.serverControl) { + this.connectToServer(this.selectedServer, function () { + this.serverControl.setColor(color, this.selectedServer.duration); + }.bind(this)); + } else { + this.serverControl.setColor(color, this.selectedServer.duration); + } + }, + 'clear': function () { + if (!this.selectedServer) { + this.showError('No server selected'); + } else if (!this.serverControl) { + this.connectToServer(this.selectedServer, function () { + this.serverControl.clear(); + }.bind(this)); + } else { + this.serverControl.clear(); + } + }, + 'clearall': function () { + if (!this.selectedServer) { + this.showError('No server selected'); + } else if (!this.serverControl) { + this.connectToServer(this.selectedServer, function () { + this.serverControl.clearall(); + this.mainView.setColor({r: 0, g: 0, b: 0}); + }.bind(this)); + } else { + this.serverControl.clearall(); + this.mainView.setColor({r: 0, g: 0, b: 0}); + } + } + }, this); + + this.settingsView.on({ + 'serverAdded': function (server) { + if (server.address && server.port) { + server.priority = server.priority || 50; + this.settings.addServer(server); + this.lockSettingsView(false); + } else { + this.showError('Invalid server data'); + } + }, + 'serverAddCanceled': function () { + this.lockSettingsView(false); + this.settingsView.fillServerList(this.settings.servers); + }, + 'serverEditCanceled': function () { + this.lockSettingsView(false); + this.settingsView.fillServerList(this.settings.servers); + }, + 'serverSelected': function (index) { + this.lockSettingsView(false); + this.settings.setSelectedServer(index); + }, + 'serverRemoved': function (index) { + this.settings.removeServer(index); + }, + 'serverChanged': function (data) { + if (data.server.address && data.server.port) { + data.server.priority = data.server.priority || 50; + this.settings.updateServer(data.index, data.server); + this.lockSettingsView(false); + } else { + this.showError('Invalid server data'); + } + }, + 'editServer': function (index) { + var server = this.settings.servers[index]; + this.settingsView.editServer({index: index, server: server}); + }, + 'durationChanged': function (value) { + this.settings.duration = value; + this.settings.save(); + }, + 'detect': function () { + this.lockSettingsView(true); + this.settingsView.showWaiting(true); + this.searchForServer(function (server) { + this.settings.addServer(server); + }.bind(this), function () { + this.lockSettingsView(false); + this.settingsView.showWaiting(false); + }.bind(this)); + } + }, this); + + this.effectsView.on({ + 'effectSelected': function (effectId) { + if (!this.serverControl) { + this.connectToServer(this.selectedServer, function () { + this.serverControl.runEffect(this.effects[parseInt(effectId)]); + }.bind(this)); + } else { + this.serverControl.runEffect(this.effects[parseInt(effectId)]); + } + } + }, this); + + this.transformView.on({ + 'gamma': function (data) { + if (data.r) { + this.transform.gamma[0] = data.r; + } else if (data.g) { + this.transform.gamma[1] = data.g; + } else if (data.b) { + this.transform.gamma[2] = data.b; + } + + if (this.serverControl) { + this.serverControl.setTransform(this.transform); + } + }, + 'whitelevel': function (data) { + if (data.r) { + this.transform.whitelevel[0] = data.r; + } else if (data.g) { + this.transform.whitelevel[1] = data.g; + } else if (data.b) { + this.transform.whitelevel[2] = data.b; + } + + if (this.serverControl) { + this.serverControl.setTransform(this.transform); + } + }, + 'blacklevel': function (data) { + if (data.r) { + this.transform.blacklevel[0] = data.r; + } else if (data.g) { + this.transform.blacklevel[1] = data.g; + } else if (data.b) { + this.transform.blacklevel[2] = data.b; + } + + if (this.serverControl) { + this.serverControl.setTransform(this.transform); + } + }, + 'threshold': function (data) { + if (data.r) { + this.transform.threshold[0] = data.r; + } else if (data.g) { + this.transform.threshold[1] = data.g; + } else if (data.b) { + this.transform.threshold[2] = data.b; + } + + if (this.serverControl) { + this.serverControl.setTransform(this.transform); + } + }, + 'hsv': function (data) { + if (data.valueGain) { + this.transform.valueGain = data.valueGain; + } else if (data.saturationGain) { + this.transform.saturationGain = data.saturationGain; + } + + if (this.serverControl) { + this.serverControl.setTransform(this.transform); + } + } + }, this); + }, + + /** + * @private + * @param id + */ + gotoArea: function (id) { + this.mainView.scrollToArea(id); + }, + + /** + * @private + * @param server + */ + connectToServer: function (server, onConnected) { + if (this.serverControl) { + if (this.serverControl.isConnecting()) { + return; + } + this.serverControl.off(); + this.serverControl.disconnect(); + this.transformView.clear(); + this.effectsView.clear(); + } + + this.serverControl = new ServerControl(server, Socket); + this.serverControl.on({ + connected: function () { + this.serverControl.getServerInfo(); + }, + serverInfo: function (info) { + var index; + if (!this.selectedServer.name || this.selectedServer.name.length === 0) { + this.selectedServer.name = info.hostname; + index = this.settings.indexOfServer(this.selectedServer); + this.settings.updateServer(index, this.selectedServer); + this.settingsView.fillServerList(this.settings.servers); + } + this.effects = info.effects; + this.transform = info.transform[0]; + this.updateView(); + this.showStatus('Connected to ' + this.selectedServer.name); + if (onConnected) { + onConnected(); + } + }, + error: function (message) { + this.serverControl = null; + this.showError(message); + } + }, this); + this.serverControl.connect(); + }, + + /** + * @private + */ + updateView: function () { + var i, effects = []; + if (this.effects) { + for (i = 0; i < this.effects.length; i++) { + effects.push({id: i, name: this.effects[i].name}); + } + } + + this.effectsView.clear(); + this.effectsView.fillList(effects); + + this.transformView.clear(); + this.transformView.fillList(this.transform); + }, + + /** + * Shows the error text + * @param {string} error - Error message + */ + showError: function (error) { + this.mainView.showError(error); + }, + + /** + * Shows a message + * @param {string} message - Text to show + */ + showStatus: function (message) { + this.mainView.showStatus(message); + }, + + /** + * @private + * @param lock + */ + lockSettingsView: function (lock) { + if (network.canDetectLocalAddress()) { + this.settingsView.enableDetectButton(!lock); + } + this.settingsView.lockList(lock); + }, + + /** + * @private + * @param onFound + * @param onEnd + */ + searchForServer: function (onFound, onEnd) { + network.getLocalInterfaces(function (ips) { + if (ips.length === 0) { + onEnd(); + return; + } + + function checkInterface (localInterfaceAddress, ciOnFinished) { + var index, ipParts, addr; + + index = 1; + ipParts = localInterfaceAddress.split('.'); + ipParts[3] = index; + addr = ipParts.join('.'); + + function checkAddressRange (startAddress, count, carOnFinished) { + var ipParts, i, addr, cbCounter = 0, last; + + function checkAddress (address, port, caOnFinished) { + var server = new ServerControl({'address': address, 'port': port}, Socket); + server.on({ + 'error': function () { + server.disconnect(); + caOnFinished(); + }, + 'connected': function () { + server.getServerInfo(); + }, + 'serverInfo': function (result) { + var serverInfo = { + 'address': address, + 'port': port, + 'priority': 50 + }; + server.disconnect(); + + if (result.hostname) { + serverInfo.name = result.hostname; + } + + caOnFinished(serverInfo); + } + }); + server.connect(); + } + + function checkAddressDoneCb (serverInfo) { + var ipParts, nextAddr; + + if (serverInfo && onFound) { + onFound(serverInfo); + } + + cbCounter++; + if (cbCounter === count) { + ipParts = startAddress.split('.'); + ipParts[3] = parseInt(ipParts[3]) + count; + nextAddr = ipParts.join('.'); + carOnFinished(nextAddr); + } + } + + ipParts = startAddress.split('.'); + last = parseInt(ipParts[3]); + + for (i = 0; i < count; i++) { + ipParts[3] = last + i; + addr = ipParts.join('.'); + + checkAddress(addr, 19444, checkAddressDoneCb); + } + } + + function checkAddressRangeCb (nextAddr) { + var ipParts, count = 64, lastPart; + + ipParts = nextAddr.split('.'); + lastPart = parseInt(ipParts[3]); + if (lastPart === 255) { + ciOnFinished(); + return; + } else if (lastPart + 64 > 254) { + count = 255 - lastPart; + } + + checkAddressRange(nextAddr, count, checkAddressRangeCb); + } + + // do search in chunks because the dispatcher used in the ios socket plugin can handle only 64 threads + checkAddressRange(addr, 64, checkAddressRangeCb); + } + + function checkInterfaceCb () { + if (ips.length === 0) { + onEnd(); + } else { + checkInterface(ips.pop(), checkInterfaceCb); + } + } + + checkInterface(ips.pop(), checkInterfaceCb); + + }.bind(this), function (error) { + this.showError(error); + }.bind(this)); + } + }); +}); diff --git a/assets/webconfig/js/app/data/ServerControl.js b/assets/webconfig/js/app/data/ServerControl.js new file mode 100644 index 0000000..6eb9f91 --- /dev/null +++ b/assets/webconfig/js/app/data/ServerControl.js @@ -0,0 +1,201 @@ +/*global define */ +define(['lib/stapes', 'utils/Tools'], function (Stapes, tools) { + 'use strict'; + + return Stapes.subclass(/** @lends ServerControl.prototype */{ + /** @type Socket */ + socket: null, + server: null, + connecting: false, + + /** + * @class ServerControl + * @classdesc Interface for the hyperion server control. All commands are sent directly to hyperion's server. + * @constructs + * @param {object} server - Hyperion server parameter + * @param {string} server.address - Server address + * @param {number} server.port - Hyperion server port + * @param {function} Socket - constructor of the socket to use for communication + * + * @fires connected + * @fires error + * @fires serverInfo + * @fires cmdSent + */ + constructor: function (server, Socket) { + this.server = server; + this.socket = new Socket(); + this.connecting = false; + }, + + /** + * Try to connect to the server + */ + connect: function () { + if (!this.server) { + this.emit('error', 'Missing server info'); + } else { + this.connecting = true; + this.socket.create(function () { + this.socket.connect(this.server, function () { + this.emit('connected'); + this.connecting = false; + }.bind(this), function (error) { + this.socket.close(); + this.emit('error', error); + this.connecting = false; + }.bind(this)); + }.bind(this)); + } + }, + + /** + * Disconnect from the server + */ + disconnect: function () { + this.socket.close(); + }, + + /** + * Sends the color command to the server + * @param {object} color - Color to set + * @param {number} color.r - Red value + * @param {number} color.g - Green value + * @param {number} color.b - Blue value + * @param {number} duration - Duration in seconds + */ + setColor: function (color, duration) { + var intColor, cmd; + + intColor = [ + Math.floor(color.r), Math.floor(color.g), Math.floor(color.b) + ]; + cmd = { + command: 'color', + color: intColor, + priority: this.server.priority + }; + + if (duration) { + cmd.duration = duration * 1000; + } + + this.sendCommand(cmd); + }, + + clear: function () { + var cmd = { + command: 'clear', + priority: this.server.priority + }; + this.sendCommand(cmd); + }, + + clearall: function () { + var cmd = { + command: 'clearall' + }; + this.sendCommand(cmd); + }, + + /** + * Sends a command to rund specified effect + * @param {object} effect - Effect object + */ + runEffect: function (effect) { + var cmd; + + if (!effect) { + return; + } + + cmd = { + command: 'effect', + effect: { + name: effect.name, + args: effect.args + }, + priority: this.server.priority + }; + this.sendCommand(cmd); + }, + + /** + * Sends a command for color transformation + * @param {object} transform + */ + setTransform: function (transform) { + var cmd; + + if (!transform) { + return; + } + + cmd = { + 'command': 'transform', + 'transform': transform + }; + + this.sendCommand(cmd); + }, + + /** + * @private + * @param command + */ + sendCommand: function (command) { + var data; + + if (!command) { + return; + } + + if (typeof command === 'string') { + data = command; + } else { + data = JSON.stringify(command); + } + + this.socket.isConnected(function (connected) { + if (connected) { + this.socket.write(data + '\n', function () { + this.emit('cmdSent', command); + }.bind(this), function (error) { + this.emit('error', error); + }.bind(this)); + } else { + this.emit('error', 'No server connection'); + } + }.bind(this)); + }, + + /** + * Get the information about the hyperion server + */ + getServerInfo: function () { + var cmd = {command: 'serverinfo'}; + + this.socket.isConnected(function (connected) { + if (connected) { + this.socket.write(JSON.stringify(cmd) + '\n', function () { + this.socket.read(function (result) { + var dataobj, str = tools.ab2str(result); + dataobj = JSON.parse(str); + this.emit('serverInfo', dataobj.info); + }.bind(this), function (error) { + this.emit('error', error); + }.bind(this)); + }.bind(this), function (error) { + this.emit('error', error); + }.bind(this)); + } else { + this.emit('error', 'No server connection'); + } + }.bind(this)); + }, + + isConnecting: function () { + return this.connecting; + } + }); +}); diff --git a/assets/webconfig/js/app/main.js b/assets/webconfig/js/app/main.js new file mode 100644 index 0000000..dfa2796 --- /dev/null +++ b/assets/webconfig/js/app/main.js @@ -0,0 +1,150 @@ +/*global require, requirejs */ + +requirejs.config({ + baseUrl: 'js/app', + paths: { + 'lib': '../vendor' + }, + map: { + 'controllers/AppController': { + 'api/Socket': 'api/WebSocket' + } + } +}); + +/** + * @param {HTMLElement} dom + * @param {function} handler + */ +window.addPointerDownHandler = function (dom, handler) { + 'use strict'; + dom.addEventListener('touchstart', handler, false); + dom.addEventListener('mousedown', handler, false); +}; + +/** + * @param {HTMLElement} dom + * @param {function} handler + */ +window.removePointerDownHandler = function (dom, handler) { + 'use strict'; + dom.removeEventListener('touchstart', handler, false); + dom.removeEventListener('mousedown', handler, false); +}; + +/** + * @param {HTMLElement} dom + * @param {function} handler + */ +window.addPointerUpHandler = function (dom, handler) { + 'use strict'; + dom.addEventListener('touchend', handler, false); + dom.addEventListener('mouseup', handler, false); +}; + +/** + * @param {HTMLElement} dom + * @param {function} handler + */ +window.removePointerUpHandler = function (dom, handler) { + 'use strict'; + dom.removeEventListener('touchend', handler, false); + dom.removeEventListener('mouseup', handler, false); +}; + +/** + * @param {HTMLElement} dom + * @param {function} handler + */ +window.addPointerMoveHandler = function (dom, handler) { + 'use strict'; + dom.addEventListener('touchmove', handler, false); + dom.addEventListener('mousemove', handler, false); +}; + +/** + * @param {HTMLElement} dom + * @param {function} handler + */ +window.removePointerMoveHandler = function (dom, handler) { + 'use strict'; + dom.removeEventListener('touchmove', handler, false); + dom.removeEventListener('mousemove', handler, false); +}; + +/** + * + * @param {HTMLElement} dom + * @param {function} handler + */ +window.addClickHandler = function (dom, handler) { + 'use strict'; + var toFire = false; + + dom.addEventListener('touchstart', function (event) { + if (event.touches.length > 1) { + return; + } + toFire = true; + }, false); + + dom.addEventListener('touchmove', function () { + toFire = false; + }, false); + + dom.addEventListener('touchend', function (event) { + var focused; + if (toFire) { + handler.apply(this, arguments); + + focused = document.querySelector(':focus'); + + if (focused && event.target !== focused) { + focused.blur(); + } + + if (event.target.tagName !== 'INPUT') { + event.preventDefault(); + } + } + }, false); + + dom.addEventListener('click', function () { + handler.apply(this, arguments); + }, false); +}; + +function checkInstallFirefoxOS() { + 'use strict'; + var manifest_url, installCheck; + + manifest_url = [location.protocol, '//', location.host, location.pathname.replace('index.html',''), 'manifest.webapp'].join(''); + installCheck = navigator.mozApps.checkInstalled(manifest_url); + + installCheck.onerror = function() { + alert('Error calling checkInstalled: ' + installCheck.error.name); + }; + + installCheck.onsuccess = function() { + var installLoc; + if(!installCheck.result) { + if (confirm('Do you want to install hyperion remote contorl on your device?')) { + installLoc = navigator.mozApps.install(manifest_url); + installLoc.onsuccess = function(data) { + }; + installLoc.onerror = function() { + alert(installLoc.error.name); + }; + } + } + }; +} + +require(['controllers/AppController'], function (AppController) { + 'use strict'; + var app = new AppController(); + app.init(); + if (navigator.mozApps && navigator.userAgent.indexOf('Mozilla/5.0 (Mobile;') !== -1) { + checkInstallFirefoxOS(); + } +}); diff --git a/assets/webconfig/js/app/main_chrome.js b/assets/webconfig/js/app/main_chrome.js new file mode 100644 index 0000000..786030f --- /dev/null +++ b/assets/webconfig/js/app/main_chrome.js @@ -0,0 +1,108 @@ +/*global require, requirejs */ + +requirejs.config({ + baseUrl: '../js/app', + paths: { + 'lib': '../vendor' + }, + map: { + 'controllers/AppController': { + 'api/Socket': 'api/ChromeTcpSocket', + 'api/Network': 'api/ChromeNetwork' + }, + 'models/Settings': { + 'api/LocalStorage': 'api/ChromeLocalStorage' + } + } +}); + +/** + * + * @param {HTMLElement} dom + * @param {function} handler + */ +window.addPointerDownHandler = function (dom, handler) { + 'use strict'; + dom.addEventListener('touchstart', function (event) { + handler.apply(this, arguments); + event.preventDefault(); + }, false); + + dom.addEventListener('mousedown', function () { + handler.apply(this, arguments); + }, false); +}; + +/** + * + * @param {HTMLElement} dom + * @param {function} handler + */ +window.addPointerUpHandler = function (dom, handler) { + 'use strict'; + dom.addEventListener('touchend', function (event) { + handler.apply(this, arguments); + event.preventDefault(); + }, false); + + dom.addEventListener('mouseup', function () { + handler.apply(this, arguments); + }, false); +}; + +/** + * + * @param {HTMLElement} dom + * @param {function} handler + */ +window.addPointerMoveHandler = function (dom, handler) { + 'use strict'; + dom.addEventListener('touchmove', function (event) { + handler.apply(this, arguments); + event.preventDefault(); + }, false); + + dom.addEventListener('mousemove', function () { + handler.apply(this, arguments); + }, false); +}; + +/** + * + * @param {HTMLElement} dom + * @param {function} handler + */ +window.addClickHandler = function (dom, handler) { + 'use strict'; + var toFire = false; + + dom.addEventListener('touchstart', function (event) { + if (event.touches.length > 1) { + return; + } + toFire = true; + }, false); + + dom.addEventListener('touchmove', function () { + toFire = false; + }, false); + + dom.addEventListener('touchend', function (event) { + if (toFire) { + handler.apply(this, arguments); + if (event.target.tagName !== 'INPUT') { + event.preventDefault(); + } + } + }, false); + + dom.addEventListener('click', function () { + handler.apply(this, arguments); + }, false); +}; + +require(['controllers/AppController'], function (AppController) { + 'use strict'; + var app = new AppController(); + app.init(); +}); diff --git a/assets/webconfig/js/app/models/Settings.js b/assets/webconfig/js/app/models/Settings.js new file mode 100644 index 0000000..5e224f0 --- /dev/null +++ b/assets/webconfig/js/app/models/Settings.js @@ -0,0 +1,124 @@ +/*global define */ +define(['lib/stapes', 'api/LocalStorage'], function (Stapes, LocalStorage) { + 'use strict'; + + return Stapes.subclass(/** @lends Settings.prototype */{ + storage: null, servers: [], + + /** + * @class Settings + * @classdesc Local application settings + * @constructs + * @fires saved + * @fires loaded + * @fires error + * @fires serverAdded + * @fires serverChanged + * @fires serverRemoved + */ + constructor: function () { + this.storage = new LocalStorage(); + this.storage.on({ + error: function (message) { + this.emit('error', message); + }, got: function (settings) { + if (settings) { + this.servers = settings.servers || []; + } + this.emit('loaded'); + }, set: function () { + this.emit('saved'); + } + }, this); + }, + + /** + * Save current settings + */ + save: function () { + this.storage.set({ + servers: this.servers + }); + }, + + /** + * Loads persistent settings + */ + load: function () { + this.storage.get(); + }, + + /** + * Add a server definition + * @param {object} server - Server information + */ + addServer: function (server) { + if (this.indexOfServer(server) === -1) { + if (this.servers.length === 0) { + server.selected = true; + } + + this.servers.push(server); + this.save(); + this.emit('serverAdded', server); + } + }, + + /** + * Sets a server as a default server + * @param {number} index - Index of the server in the server list to set as default one + */ + setSelectedServer: function (index) { + var i; + for (i = 0; i < this.servers.length; i++) { + delete this.servers[i].selected; + } + this.servers[index].selected = true; + this.save(); + this.emit('serverChanged', this.servers[index]); + }, + + /** + * Remove a server from the list + * @param {number} index - Index of the server in the list to remove + */ + removeServer: function (index) { + this.servers.splice(index, 1); + this.save(); + this.emit('serverRemoved'); + }, + + /** + * Update server information + * @param {number} index - Index of the server to update + * @param {object} server - New server information + */ + updateServer: function (index, server) { + if (index >= 0 && index < this.servers.length) { + this.servers[index] = server; + this.save(); + this.emit('serverChanged', server); + } + }, + + /** + * Find the server in the list. + * @param {object} server - Server to search index for + * @returns {number} - Index of the server in the list. -1 if server not found + */ + indexOfServer: function (server) { + var i, tmp; + + for (i = 0; i < this.servers.length; i++) { + tmp = this.servers[i]; + + if (tmp.port === server.port && tmp.address === server.address) { + return i; + } + } + + return -1; + } + + }); +}); diff --git a/assets/webconfig/js/app/utils/Tools.js b/assets/webconfig/js/app/utils/Tools.js new file mode 100644 index 0000000..2e2194c --- /dev/null +++ b/assets/webconfig/js/app/utils/Tools.js @@ -0,0 +1,56 @@ +define([], function () { + 'use strict'; + + return { + /** + * Convert a string to ArrayBuffer + * @param {string} str String to convert + * @returns {ArrayBuffer} Result + */ + str2ab: function (str) { + var i, buf = new ArrayBuffer(str.length), bufView = new Uint8Array(buf); + for (i = 0; i < str.length; i++) { + bufView[i] = str.charCodeAt(i); + } + return buf; + }, + + /** + * Convert an array to ArrayBuffer + * @param array + * @returns {ArrayBuffer} Result + */ + a2ab: function (array) { + return new Uint8Array(array).buffer; + }, + + /** + * Convert ArrayBuffer to string + * @param {ArrayBuffer} buffer Buffer to convert + * @returns {string} + */ + ab2hexstr: function (buffer) { + var i, str = '', ua = new Uint8Array(buffer); + for (i = 0; i < ua.length; i++) { + str += this.b2hexstr(ua[i]); + } + return str; + }, + + /** + * Convert byte to hexstr. + * @param {number} byte Byte to convert + */ + b2hexstr: function (byte) { + return ('00' + byte.toString(16)).substr(-2); + }, + + /** + * @param {ArrayBuffer} buffer + * @returns {string} + */ + ab2str: function (buffer) { + return String.fromCharCode.apply(null, new Uint8Array(buffer)); + } + }; +}); diff --git a/assets/webconfig/js/app/views/EffectsView.js b/assets/webconfig/js/app/views/EffectsView.js new file mode 100644 index 0000000..4f41d07 --- /dev/null +++ b/assets/webconfig/js/app/views/EffectsView.js @@ -0,0 +1,64 @@ +/** + * hyperion remote + * MIT License + */ + +define(['lib/stapes'], function (Stapes) { + 'use strict'; + + return Stapes.subclass(/** @lends EffectsView.prototype */{ + /** + * @class EffectsView + * @constructs + */ + constructor: function () { + this.bindEventHandlers(); + }, + + /** + * @private + */ + bindEventHandlers: function () { + window.addClickHandler(document.querySelector('#effects ul'), function (event) { + var selected = event.target.parentNode.querySelector('.selected'); + if (selected) { + selected.classList.remove('selected'); + } + event.target.classList.add('selected'); + this.emit('effectSelected', event.target.dataset.id); + }.bind(this)); + }, + + /** + * Clear the list + */ + clear: function () { + document.querySelector('#effects ul').innerHTML = ''; + document.querySelector('#effects .info').classList.add('hidden'); + }, + + /** + * Fill the list + * @param {object} effects - Object containing effect information + */ + fillList: function (effects) { + var dom, el, i; + + dom = document.createDocumentFragment(); + + for (i = 0; i < effects.length; i++) { + el = document.createElement('li'); + el.innerHTML = effects[i].name; + el.dataset.id = effects[i].id; + dom.appendChild(el); + } + + document.querySelector('#effects ul').appendChild(dom); + + if (effects.length === 0) { + document.querySelector('#effects .info').classList.remove('hidden'); + } + } + }); +}); + diff --git a/assets/webconfig/js/app/views/MainView.js b/assets/webconfig/js/app/views/MainView.js new file mode 100644 index 0000000..986af3f --- /dev/null +++ b/assets/webconfig/js/app/views/MainView.js @@ -0,0 +1,396 @@ +define([ + 'lib/stapes', + 'lib/tinycolor', + 'utils/Tools', + 'views/Slider' +], function (Stapes, Tinycolor, tools, Slider) { + 'use strict'; + var timer; + + function showMessageField (text, type) { + var dom, wrapper; + + dom = document.querySelector('.work .msg'); + + if (!dom) { + dom = document.createElement('div'); + dom.classList.add('msg'); + dom.classList.add(type); + wrapper = document.createElement('div'); + wrapper.classList.add('wrapper_msg'); + wrapper.classList.add('invisible'); + wrapper.appendChild(dom); + + document.querySelector('.work').appendChild(wrapper); + setTimeout(function () { + wrapper.classList.remove('invisible'); + }, 0); + } + + if (!dom.classList.contains(type)) { + dom.className = 'msg'; + dom.classList.add(type); + } + dom.innerHTML = text; + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(function () { + var error = document.querySelector('.work .wrapper_msg'); + if (error) { + error.parentNode.removeChild(error); + } + }, 1600); + } + + return Stapes.subclass(/** @lends MainView.prototype*/{ + pointer: null, + colorpicker: null, + slider: null, + sliderinput: null, + cpradius: 0, + cpcenter: 0, + drag: false, + color: null, + brightness: 1.0, + inputbox: null, + + /** + * @class MainView + * @construct + * @fires barClick + * @fires colorChange + */ + constructor: function () { + var ev; + this.pointer = document.querySelector('#colorpicker #pointer'); + this.colorpicker = document.querySelector('#colorpicker #colorwheelbg'); + this.slider = new Slider({ + element: document.getElementById('brightness'), + min: 0, + max: 1, + step: 0.02, + value: 1 + }); + this.inputbox = document.querySelector('#color input.value'); + + this.cpradius = this.colorpicker.offsetWidth / 2; + this.cpcenter = this.colorpicker.offsetLeft + this.cpradius; + + this.bindEventHandlers(); + + ev = document.createEvent('Event'); + ev.initEvent('resize', true, true); + window.dispatchEvent(ev); + }, + + /** + * @private + */ + bindEventHandlers: function () { + var cptouchrect; + + window.addEventListener('resize', function () { + var attrW, attrH, side, w = this.colorpicker.parentNode.clientWidth, h = this.colorpicker.parentNode.clientHeight; + + attrW = this.colorpicker.getAttribute('width'); + attrH = this.colorpicker.getAttribute('height'); + side = attrW === 'auto' ? attrH : attrW; + if (w > h) { + if (attrH !== side) { + this.colorpicker.setAttribute('height', side); + this.colorpicker.setAttribute('width', 'auto'); + } + } else if (attrW !== side) { + this.colorpicker.setAttribute('height', 'auto'); + this.colorpicker.setAttribute('width', side); + } + + this.cpradius = this.colorpicker.offsetWidth / 2; + this.cpcenter = this.colorpicker.offsetLeft + this.cpradius; + if (this.color) { + this.updatePointer(); + } + }.bind(this)); + + window.addClickHandler(document.querySelector('.footer'), function (event) { + this.emit('barClick', event.target.parentNode.dataset.area); + }.bind(this)); + + + this.slider.on('changeValue', function (value) { + this.brightness = value.value; + this.updateInput(); + this.fireColorEvent(); + }, this); + + this.inputbox.addEventListener('input', function (event) { + var bright, rgb = new Tinycolor(event.target.value).toRgb(); + + if (rgb.r === 0 && rgb.g === 0 && rgb.b === 0) { + this.brightness = 0; + this.color = new Tinycolor({ + r: 0xff, + g: 0xff, + b: 0xff + }); + } else { + bright = Math.max(rgb.r, rgb.g, rgb.b) / 256; + rgb.r = Math.round(rgb.r / bright); + rgb.g = Math.round(rgb.g / bright); + rgb.b = Math.round(rgb.b / bright); + this.brightness = bright; + this.color = new Tinycolor(rgb); + } + + this.fireColorEvent(); + this.updatePointer(); + this.updateSlider(); + }.bind(this), false); + + this.inputbox.addEventListener('keydown', function (event) { + switch (event.keyCode) { + case 8: + case 9: + case 16: + case 37: + case 38: + case 39: + case 40: + case 46: + break; + default: + { + if (event.target.value.length >= 6 && (event.target.selectionEnd - event.target.selectionStart) === 0) { + event.preventDefault(); + event.stopPropagation(); + } else if (event.keyCode < 48 || event.keyCode > 71) { + event.preventDefault(); + event.stopPropagation(); + } + } + } + }); + + cptouchrect = document.querySelector('#colorpicker .touchrect'); + window.addPointerDownHandler(cptouchrect, function (event) { + this.leaveInput(); + this.drag = true; + this.handleEvent(event); + }.bind(this)); + + window.addPointerMoveHandler(cptouchrect, function (event) { + if (this.drag) { + this.handleEvent(event); + event.preventDefault(); + } + }.bind(this)); + + window.addPointerUpHandler(cptouchrect, function () { + this.drag = false; + }.bind(this)); + + window.addClickHandler(document.querySelector('#clear_button'), function () { + this.emit('clear'); + }.bind(this)); + + window.addClickHandler(document.querySelector('#clearall_button'), function () { + this.emit('clearall'); + }.bind(this)); + }, + + /** + * @private + * @param event + */ + handleEvent: function (event) { + var point, x, y; + + if (event.touches) { + x = event.touches[0].clientX; + y = event.touches[0].clientY; + } else { + x = event.clientX; + y = event.clientY; + } + point = this.getCirclePoint(x, y); + this.color = this.getColorFromPoint(point); + + this.updatePointer(); + this.updateSlider(); + this.updateInput(); + + this.fireColorEvent(); + }, + + /** + * @private + * @param {number} x + * @param {number} y + * @returns {{x: number, y: number}} + */ + getCirclePoint: function (x, y) { + var p = { + x: x, + y: y + }, c = { + x: this.colorpicker.offsetLeft + this.cpradius, + y: this.colorpicker.offsetTop + this.cpradius + }, n; + + n = Math.sqrt(Math.pow((x - c.x), 2) + Math.pow((y - c.y), 2)); + + if (n > this.cpradius) { + p.x = (c.x) + this.cpradius * ((x - c.x) / n); + p.y = (c.y) + this.cpradius * ((y - c.y) / n); + } + + return p; + }, + + /** + * @private + * @param {{x: number, y: number}} p + * @returns {Tinycolor} + */ + getColorFromPoint: function (p) { + var h, t, s, x, y; + x = p.x - this.colorpicker.offsetLeft - this.cpradius; + y = this.cpradius - p.y + this.colorpicker.offsetTop; + t = Math.atan2(y, x); + h = (t * (180 / Math.PI) + 360) % 360; + s = Math.min(Math.sqrt(x * x + y * y) / this.cpradius, 1); + + return new Tinycolor({ + h: h, + s: s, + v: 1 + }); + }, + + /** + * @private + * @param color + * @returns {{x: number, y: number}} + */ + getPointFromColor: function (color) { + var t, x, y, p = {}; + + t = color.h * (Math.PI / 180); + y = Math.sin(t) * this.cpradius * color.s; + x = Math.cos(t) * this.cpradius * color.s; + + p.x = Math.round(x + this.colorpicker.offsetLeft + this.cpradius); + p.y = Math.round(this.cpradius - y + this.colorpicker.offsetTop); + + return p; + }, + + /** + * @private + */ + fireColorEvent: function () { + var rgb = this.color.toRgb(); + rgb.r = Math.round(rgb.r * this.brightness); + rgb.g = Math.round(rgb.g * this.brightness); + rgb.b = Math.round(rgb.b * this.brightness); + this.emit('colorChange', rgb); + }, + + /** + * + * @param rgb + */ + setColor: function (rgb) { + var bright; + + if (rgb.r === 0 && rgb.g === 0 && rgb.b === 0) { + this.brightness = 0; + this.color = new Tinycolor({ + r: 0xff, + g: 0xff, + b: 0xff + }); + } else { + bright = Math.max(rgb.r, rgb.g, rgb.b) / 256; + rgb.r = Math.round(rgb.r / bright); + rgb.g = Math.round(rgb.g / bright); + rgb.b = Math.round(rgb.b / bright); + this.brightness = bright; + this.color = new Tinycolor(rgb); + } + + this.updatePointer(); + this.updateSlider(); + this.updateInput(); + }, + + /** + * @private + */ + updateSlider: function () { + this.slider.setValue(this.brightness); + this.slider.dom.style.backgroundImage = '-webkit-linear-gradient(left, #000000 0%, ' + this.color.toHexString() + ' 100%)'; + }, + + /** + * @private + */ + updatePointer: function () { + var point = this.getPointFromColor(this.color.toHsv()); + + this.pointer.style.left = (point.x - this.pointer.offsetWidth / 2) + 'px'; + this.pointer.style.top = (point.y - this.pointer.offsetHeight / 2) + 'px'; + this.pointer.style.backgroundColor = this.color.toHexString(); + }, + + /** + * @private + */ + updateInput: function () { + var rgb = this.color.toRgb(); + rgb.r = Math.round(rgb.r * this.brightness); + rgb.g = Math.round(rgb.g * this.brightness); + rgb.b = Math.round(rgb.b * this.brightness); + + this.inputbox.value = tools.b2hexstr(rgb.r) + tools.b2hexstr(rgb.g) + tools.b2hexstr(rgb.b); + }, + + /** + * Scroll to the specific tab content + * @param {string} id - Id of the tab to scroll to + */ + scrollToArea: function (id) { + var area, index; + document.querySelector('.footer .selected').classList.remove('selected'); + document.querySelector('.footer .button[data-area =' + id + ']').classList.add('selected'); + + area = document.getElementById(id); + index = area.offsetLeft / area.clientWidth; + area.parentNode.style.left = (-index * 100) + '%'; + }, + + /** + * Shows a status message + * @param {string} message - Text to show + */ + showStatus: function (message) { + showMessageField(message, 'status'); + }, + + /** + * Shows the error text + * @param {string} error - Error message + */ + showError: function (error) { + showMessageField(error, 'error'); + }, + + /** + * @private + */ + leaveInput: function () { + this.inputbox.blur(); + } + }); +}); diff --git a/assets/webconfig/js/app/views/ServerList.js b/assets/webconfig/js/app/views/ServerList.js new file mode 100644 index 0000000..fb36927 --- /dev/null +++ b/assets/webconfig/js/app/views/ServerList.js @@ -0,0 +1,199 @@ +define(['lib/stapes'], function (Stapes) { + 'use strict'; + + /** + * + * @param params + * @returns {HTMLElement} + */ + function buildDom (params) { + var dom, label, ul, li, i, header; + + dom = document.createElement('div'); + dom.classList.add('grouplist'); + dom.id = params.id; + + header = document.createElement('div'); + header.classList.add('header'); + dom.appendChild(header); + label = document.createElement('div'); + label.innerHTML = params.label; + label.classList.add('title'); + header.appendChild(label); + label = document.createElement('div'); + label.innerHTML = ''; + label.classList.add('callout'); + header.appendChild(label); + + ul = document.createElement('ul'); + dom.appendChild(ul); + if (params.list) { + for (i = 0; i < params.list.length; i++) { + li = createLine(params.list[i]); + ul.appendChild(li); + } + } + + return dom; + } + + function createLine (params) { + var dom, el, horiz, box, touch; + + dom = document.createDocumentFragment(); + + horiz = document.createElement('div'); + horiz.classList.add('horizontal'); + dom.appendChild(horiz); + + touch = document.createElement('div'); + touch.classList.add('horizontal'); + horiz.appendChild(touch); + + el = document.createElement('div'); + el.classList.add('checkbox'); + touch.appendChild(el); + + box = document.createElement('div'); + box.classList.add('titlebox'); + touch.appendChild(box); + + el = document.createElement('label'); + el.classList.add('title'); + el.innerHTML = params.title || ''; + box.appendChild(el); + + el = document.createElement('label'); + el.classList.add('subtitle'); + el.innerHTML = params.subtitle; + box.appendChild(el); + + el = document.createElement('div'); + el.classList.add('touchrect'); + touch.appendChild(el); + + el = document.createElement('div'); + el.classList.add('edit_icon'); + el.innerHTML = ''; + horiz.appendChild(el); + + el = document.createElement('div'); + el.classList.add('delete_icon'); + el.innerHTML = ''; + horiz.appendChild(el); + + return dom; + } + + return Stapes.subclass(/** @lends ServerList.prototype */{ + /** + * @private + * @type {HTMLElement} + */ + dom: null, + + /** + * @class ServerList + * @constructs + * @param {object} params - List parameter + * @param {string} params.id - List id + * @param {string} params.label - List title + * @param {{title: string, subtitle: string, c}[]} [params.list] - List elements + * + * @fires add + * @fires remove + * @fires select + * @fires edit + */ + constructor: function (params) { + this.dom = buildDom(params || {}); + this.bindEventHandlers(); + }, + + /** + * @private + */ + bindEventHandlers: function () { + window.addClickHandler(this.dom.querySelector('.callout'), function () { + this.emit('add'); + }.bind(this)); + + window.addClickHandler(this.dom.querySelector('ul'), function (event) { + if (event.target.classList.contains('delete_icon')) { + this.emit('remove', event.target.parentNode.parentNode.id); + } else if (event.target.classList.contains('edit_icon')) { + this.emit('edit', event.target.parentNode.parentNode.id); + } else if (event.target.classList.contains('touchrect')) { + this.emit('select', event.target.parentNode.parentNode.parentNode.id); + } + }.bind(this)); + }, + + /** + * Returns the DOM of the list + * @returns {HTMLElement} + */ + getDom: function () { + return this.dom; + }, + + /** + * Append a line + * @param id + * @param selected + * @param element + */ + append: function (id, selected, element) { + var li = document.createElement('li'); + li.id = id; + if (selected) { + li.classList.add('selected'); + } + li.appendChild(element); + this.dom.querySelector('ul').appendChild(li); + }, + + /** + * Replace a line + * @param index + * @param element + */ + replace: function (index, element) { + var child = this.dom.querySelector('ul li:nth-child(' + (index + 1) + ')'), li; + if (child) { + li = document.createElement('li'); + li.appendChild(element); + this.dom.querySelector('ul').replaceChild(li, child); + } + }, + + /** + * Add a line + * @param lineParam + */ + addLine: function (lineParam) { + var line = createLine(lineParam); + this.append(lineParam.id, lineParam.selected, line); + }, + + /** + * Clear the list + */ + clear: function () { + this.dom.querySelector('ul').innerHTML = ''; + }, + + /** + * Hide or show the Add button in the header + * @param {boolean} show - True to show, false to hide + */ + showAddButton: function (show) { + if (show) { + this.dom.querySelector('.callout').classList.remove('invisible'); + } else { + this.dom.querySelector('.callout').classList.add('invisible'); + } + } + }); +}); + diff --git a/assets/webconfig/js/app/views/SettingsView.js b/assets/webconfig/js/app/views/SettingsView.js new file mode 100644 index 0000000..38c24f8 --- /dev/null +++ b/assets/webconfig/js/app/views/SettingsView.js @@ -0,0 +1,259 @@ +define(['lib/stapes', 'views/ServerList'], function (Stapes, ServerList) { + 'use strict'; + + function createLabelInputLine (params) { + var dom, el; + + dom = document.createElement('div'); + dom.classList.add('inputline'); + dom.id = params.id; + + el = document.createElement('label'); + el.innerHTML = params.label; + dom.appendChild(el); + + el = document.createElement('input'); + if (typeof params.value === 'number') { + el.type = 'number'; + } + el.value = params.value || ''; + el.autocomplete='off'; + el.autocorrect='off'; + el.autocapitalize='off'; + dom.appendChild(el); + + return dom; + } + + function createButtonLine (params) { + var dom, el; + + dom = document.createElement('div'); + dom.classList.add('inputline'); + + el = document.createElement('button'); + el.innerHTML = params.label; + el.classList.add('OK'); + dom.appendChild(el); + + el = document.createElement('button'); + el.innerHTML = 'Cancel'; + el.classList.add('CANCEL'); + dom.appendChild(el); + + return dom; + } + + function createDetectLine () { + var dom, el; + + dom = document.createElement('div'); + dom.classList.add('line'); + + el = document.createElement('button'); + el.id = 'detect_button'; + el.innerHTML = 'Detect'; + dom.appendChild(el); + + el = document.createElement('div'); + el.classList.add('spinner'); + el.classList.add('hidden'); + dom.appendChild(el); + + return dom; + } + + return Stapes.subclass(/** @lends SettingsView.prototype */{ + dom: null, + serverList: null, + + /** + * @class SettingsView + * @classdesc View for the settings + * @constructs + */ + constructor: function () { + var list = [], el; + + this.dom = document.querySelector('#settings'); + + this.serverList = new ServerList({ + id: 'serverList', + label: 'Server', + list: list + }); + + this.serverList.on({ + add: function () { + var line, box; + + this.enableDetectButton(false); + this.lockList(true); + + box = document.createDocumentFragment(); + + line = createLabelInputLine({id: 'name', label: 'Name:'}); + box.appendChild(line); + line = createLabelInputLine({id: 'address', label: 'Address:'}); + box.appendChild(line); + line = createLabelInputLine({id: 'port', label: 'Port:', value: 19444}); + box.appendChild(line); + line = createLabelInputLine({id: 'priority', label: 'Priority:', value: 50}); + box.appendChild(line); + line = createLabelInputLine({id: 'duration', label: 'Duration (sec):', value: 0}); + box.appendChild(line); + + line = createButtonLine({label: 'Add'}); + + window.addClickHandler(line.firstChild, function (event) { + var server = {}, i, inputs = event.target.parentNode.parentNode.querySelectorAll('input'); + + for (i = 0; i < inputs.length; i++) { + server[inputs[i].parentNode.id] = inputs[i].value; + } + + server.port = parseInt(server.port); + server.priority = parseInt(server.priority); + server.duration = parseInt(server.duration); + + this.emit('serverAdded', server); + }.bind(this)); + + window.addClickHandler(line.lastChild, function () { + this.emit('serverAddCanceled'); + }.bind(this)); + box.appendChild(line); + this.serverList.append(null, false, box); + }, + select: function (id) { + if (!this.dom.classList.contains('locked')) { + this.emit('serverSelected', parseInt(id.replace('server_', ''))); + } + }, + remove: function (id) { + this.emit('serverRemoved', parseInt(id.replace('server_', ''))); + }, + edit: function (id) { + this.emit('editServer', parseInt(id.replace('server_', ''))); + } + }, this); + + this.dom.appendChild(this.serverList.getDom()); + + el = createDetectLine(); + + window.addClickHandler(el.querySelector('button'), function () { + this.emit('detect'); + }.bind(this)); + + this.dom.appendChild(el); + }, + + /** + * Fills the list of known servers + * @param {Array} servers + */ + fillServerList: function (servers) { + var i, server, params; + this.serverList.clear(); + for (i = 0; i < servers.length; i++) { + server = servers[i]; + params = {id: 'server_' + i, title: server.name, subtitle: server.address + ':' + server.port}; + if (server.selected) { + params.selected = true; + } + this.serverList.addLine(params); + } + this.serverList.showAddButton(true); + }, + + /** + * Shows or hides the spinner as progress indicator + * @param {boolean} show True to show, false to hide + */ + showWaiting: function (show) { + if (show) { + this.dom.querySelector('.spinner').classList.remove('hidden'); + } else { + this.dom.querySelector('.spinner').classList.add('hidden'); + } + }, + + /** + * Enables or disables the detect button + * @param {Boolean} enabled True to enable, false to disable + */ + enableDetectButton: function (enabled) { + if (enabled) { + this.dom.querySelector('#detect_button').classList.remove('hidden'); + } else { + this.dom.querySelector('#detect_button').classList.add('hidden'); + } + }, + + /** + * Locks the list for editing/deleting + * @param {Boolean} lock True to lock, false to unlock + */ + lockList: function (lock) { + if (!lock) { + this.dom.classList.remove('locked'); + this.serverList.showAddButton(true); + } else { + this.dom.classList.add('locked'); + this.serverList.showAddButton(false); + } + }, + + editServer: function (serverInfo) { + var line, box; + + this.lockList(true); + this.enableDetectButton(false); + + box = document.createDocumentFragment(); + + line = createLabelInputLine({id: 'name', label: 'Name:', value: serverInfo.server.name}); + box.appendChild(line); + line = createLabelInputLine({id: 'address', label: 'Address:', value: serverInfo.server.address}); + box.appendChild(line); + line = createLabelInputLine({id: 'port', label: 'Port:', value: serverInfo.server.port}); + box.appendChild(line); + line = createLabelInputLine({id: 'priority', label: 'Priority:', value: serverInfo.server.priority}); + box.appendChild(line); + line = createLabelInputLine({id: 'duration', label: 'Duration (sec):', value: serverInfo.server.duration}); + box.appendChild(line); + + line = createButtonLine({label: 'Done'}); + + window.addClickHandler(line.querySelector('button.OK'), function (event) { + var server = {}, i, inputs = event.target.parentNode.parentNode.querySelectorAll('input'); + + for (i = 0; i < inputs.length; i++) { + server[inputs[i].parentNode.id] = inputs[i].value; + } + + server.port = parseInt(server.port); + server.priority = parseInt(server.priority); + server.duration = parseInt(server.duration); + if (serverInfo.server.selected) { + server.selected = true; + } + + this.emit('serverChanged', {index: serverInfo.index, server: server}); + }.bind(this)); + + window.addClickHandler(line.querySelector('button.CANCEL'), function () { + this.emit('serverEditCanceled'); + }.bind(this)); + box.appendChild(line); + + window.addClickHandler(box.querySelector('input'), function (event) { + event.stopPropagation(); + }); + + this.serverList.replace(serverInfo.index, box); + } + }); +}); + diff --git a/assets/webconfig/js/app/views/Slider.js b/assets/webconfig/js/app/views/Slider.js new file mode 100644 index 0000000..84ab6af --- /dev/null +++ b/assets/webconfig/js/app/views/Slider.js @@ -0,0 +1,134 @@ +define(['lib/stapes'], function (Stapes) { + 'use strict'; + + function syncView (slider) { + var left = (slider.value - slider.min) * 100 / (slider.max - slider.min); + slider.dom.lastElementChild.style.left = left + '%'; + } + + function handleEvent(event, slider) { + var x, left, ratio, value, steppedValue; + if (event.touches) { + x = event.touches[0].clientX; + } else { + x = event.clientX; + } + + left = x - slider.dom.getBoundingClientRect().left; + ratio = left / slider.dom.offsetWidth; + value = (slider.max - slider.min) * ratio; + + steppedValue = (value - slider.min) % slider.step; + if (steppedValue <= slider.step / 2) { + value = value - steppedValue; + } else { + value = value + (slider.step - steppedValue); + } + + value = Math.max(value, slider.min); + value = Math.min(value, slider.max); + slider.value = value; + slider.emit('changeValue', { + 'value': value, + 'target': slider.dom + }); + } + + return Stapes.subclass(/** @lends Slider.prototype */{ + /** + * @private + * @type {Element} + */ + dom: null, + + /** + * @private + * @type {Number} + */ + min: 0, + + /** + * @private + * @type {Number} + */ + max: 100, + + /** + * @private + * @type {Number} + */ + value: 0, + + /** + * @private + * @type {Number} + */ + step: 1, + + /** + * @class Slider + * @constructs + * @fires change + */ + constructor: function (params) { + this.dom = params.element; + this.setValue(params.value); + this.setMin(params.min); + this.setMax(params.max); + this.setStep(params.step); + + this.bindEventHandlers(); + }, + + /** + * @private + */ + bindEventHandlers: function () { + var that = this; + + function pointerMoveEventHandler(event) { + if (that.drag) { + handleEvent(event, that); + syncView(that); + event.preventDefault(); + } + } + + function pointerUpEventHandler() { + that.drag = false; + syncView(that); + window.removePointerMoveHandler(document, pointerMoveEventHandler); + window.removePointerUpHandler(document, pointerUpEventHandler); + } + + window.addPointerDownHandler(this.dom, function (event) { + this.drag = true; + handleEvent(event, this); + syncView(this); + window.addPointerMoveHandler(document, pointerMoveEventHandler); + window.addPointerUpHandler(document, pointerUpEventHandler); + }.bind(this)); + }, + + setValue: function(value) { + this.value = value || 0; + syncView(this); + }, + + setMin: function(value) { + this.min = value || 0; + syncView(this); + }, + + setMax: function(value) { + this.max = value || 100; + syncView(this); + }, + + setStep: function(value) { + this.step = value || 1; + syncView(this); + } + }); +}); + diff --git a/assets/webconfig/js/app/views/TransformView.js b/assets/webconfig/js/app/views/TransformView.js new file mode 100644 index 0000000..6fe968a --- /dev/null +++ b/assets/webconfig/js/app/views/TransformView.js @@ -0,0 +1,375 @@ +/** + * hyperion remote + * MIT License + */ + +define([ + 'lib/stapes', + 'views/Slider' +], function (Stapes, Slider) { + 'use strict'; + + function onHeaderClick (event) { + var list = event.target.parentNode.parentNode.querySelector('ul'); + + if (list.clientHeight === 0) { + list.style.maxHeight = list.scrollHeight + 'px'; + event.target.parentNode.parentNode.setAttribute('collapsed', 'false'); + } else { + list.style.maxHeight = 0; + event.target.parentNode.parentNode.setAttribute('collapsed', 'true'); + } + } + + function createLine (id, type, icon, caption, value, min, max) { + var dom, el, el2, label, wrapper; + + dom = document.createElement('li'); + dom.className = type; + + label = document.createElement('label'); + label.innerHTML = caption; + dom.appendChild(label); + + wrapper = document.createElement('div'); + wrapper.classList.add('wrapper'); + wrapper.id = id; + + el = document.createElement('div'); + el.classList.add('icon'); + el.innerHTML = icon; + wrapper.appendChild(el); + + el = document.createElement('div'); + el.classList.add('slider'); + el2 = document.createElement('div'); + el2.classList.add('track'); + el.appendChild(el2); + el2 = document.createElement('div'); + el2.classList.add('thumb'); + el.appendChild(el2); + el.dataset.min = min; + el.dataset.max = max; + el.dataset.value = value; + el.dataset.step = 0.01; + wrapper.appendChild(el); + + el = document.createElement('input'); + el.classList.add('value'); + el.type = 'number'; + el.min = min; + el.max = max; + el.step = 0.01; + el.value = parseFloat(Math.round(value * 100) / 100).toFixed(2); + wrapper.appendChild(el); + + dom.appendChild(wrapper); + + return dom; + } + + function createGroup (groupInfo) { + var group, node, subnode, i, member; + group = document.createElement('div'); + group.classList.add('group'); + if (groupInfo.collapsed) { + group.setAttribute('collapsed', 'true'); + } + group.id = groupInfo.id; + + node = document.createElement('div'); + node.classList.add('header'); + group.appendChild(node); + subnode = document.createElement('label'); + subnode.innerHTML = groupInfo.title; + node.appendChild(subnode); + subnode = document.createElement('label'); + subnode.innerHTML = groupInfo.subtitle; + node.appendChild(subnode); + + node = document.createElement('ul'); + group.appendChild(node); + for (i = 0; i < groupInfo.members.length; i++) { + member = groupInfo.members[i]; + subnode = createLine(member.id, member.type, member.icon, member.label, member.value, member.min, + member.max); + node.appendChild(subnode); + } + + return group; + } + + return Stapes.subclass(/** @lends TransformView.prototype */{ + sliders: {}, + + /** + * @class TransformView + * @constructs + */ + constructor: function () { + }, + + /** + * Clear the list + */ + clear: function () { + document.querySelector('#transform .values').innerHTML = ''; + }, + + /** + * @private + * @param change + */ + onSliderChange: function (event) { + var data = {}, idparts, value; + + idparts = event.target.parentNode.id.split('_'); + value = parseFloat(Math.round(parseFloat(event.value) * 100) / 100); + + event.target.parentNode.querySelector('.value').value = value.toFixed(2); + + data[idparts[1]] = value; + this.emit(idparts[0], data); + }, + + /** + * @private + * @param change + */ + onValueChange: function (event) { + var data = {}, idparts, value; + + idparts = event.target.parentNode.id.split('_'); + value = parseFloat(Math.round(parseFloat(event.target.value) * 100) / 100); + + if (parseFloat(event.target.value) < parseFloat(event.target.min)) { + event.target.value = event.target.min; + } else if (parseFloat(event.target.value) > parseFloat(event.target.max)) { + event.target.value = event.target.max; + } + this.sliders[event.target.parentNode.id].setValue(value); + + data[idparts[1]] = value; + this.emit(idparts[0], data); + }, + + /** + * fill the list + * @param {object} transform - Object containing transform information + */ + fillList: function (transform) { + var dom, group, els, i, slider; + + if (!transform) { + document.querySelector('#transform .info').classList.remove('hidden'); + return; + } + + dom = document.createDocumentFragment(); + + group = createGroup({ + collapsed: true, + id: 'HSV', + title: 'HSV', + subtitle: 'HSV color corrections', + members: [ + { + id: 'hsv_saturationGain', + type: 'saturation', + icon: '', + label: 'Saturation gain', + value: transform.saturationGain, + min: 0, + max: 5 + }, + { + id: 'hsv_valueGain', + type: 'value', + icon: '', + label: 'Value gain', + value: transform.valueGain, + min: 0, + max: 5 + } + ] + }); + dom.appendChild(group); + + group = createGroup({ + collapsed: true, + id: 'Gamma', + title: 'Gamma', + subtitle: 'Gamma correction', + members: [ + { + id: 'gamma_r', + type: 'red', + icon: '', + label: 'Red', + value: transform.gamma[0], + min: 0, + max: 5 + }, + { + id: 'gamma_g', + type: 'green', + icon: '', + label: 'Green', + value: transform.gamma[1], + min: 0, + max: 5 + }, + { + id: 'gamma_b', + type: 'blue', + icon: '', + label: 'Blue', + value: transform.gamma[2], + min: 0, + max: 5 + } + ] + }); + dom.appendChild(group); + + group = createGroup({ + collapsed: true, + id: 'Whitelevel', + title: 'Whitelevel', + subtitle: 'Value when RGB channel is fully on', + members: [ + { + id: 'whitelevel_r', + type: 'red', + icon: '', + label: 'Red', + value: transform.whitelevel[0], + min: 0, + max: 1 + }, + { + id: 'whitelevel_g', + type: 'green', + icon: '', + label: 'Green', + value: transform.whitelevel[1], + min: 0, + max: 1 + }, + { + id: 'whitelevel_b', + type: 'blue', + icon: '', + label: 'Blue', + value: transform.whitelevel[2], + min: 0, + max: 1 + } + ] + }); + dom.appendChild(group); + + group = createGroup({ + collapsed: true, + id: 'Blacklevel', + title: 'Blacklevel', + subtitle: 'Value when RGB channel is fully off', + members: [ + { + id: 'blacklevel_r', + type: 'red', + icon: '', + label: 'Red', + value: transform.blacklevel[0], + min: 0, + max: 1 + }, + { + id: 'blacklevel_g', + type: 'green', + icon: '', + label: 'Green', + value: transform.blacklevel[1], + min: 0, + max: 1 + }, + { + id: 'blacklevel_b', + type: 'blue', + icon: '', + label: 'Blue', + value: transform.blacklevel[2], + min: 0, + max: 1 + } + ] + }); + dom.appendChild(group); + + group = createGroup({ + collapsed: true, + id: 'Threshold', + title: 'Threshold', + subtitle: 'Threshold for a channel', + members: [ + { + id: 'threshold_r', + type: 'red', + icon: '', + label: 'Red', + value: transform.threshold[0], + min: 0, + max: 1 + }, + { + id: 'threshold_g', + type: 'green', + icon: '', + label: 'Green', + value: transform.threshold[1], + min: 0, + max: 1 + }, + { + id: 'threshold_b', + type: 'blue', + icon: '', + label: 'Blue', + value: transform.threshold[2], + min: 0, + max: 1 + } + ] + }); + dom.appendChild(group); + + els = dom.querySelectorAll('.slider'); + for (i = 0; i < els.length; i++) { + slider = new Slider({ + element: els[i], + min: els[i].dataset.min, + max: els[i].dataset.max, + step: els[i].dataset.step, + value: els[i].dataset.value + }); + slider.on('changeValue', this.onSliderChange, this); + this.sliders[els[i].parentNode.id] = slider; + } + + els = dom.querySelectorAll('input'); + for (i = 0; i < els.length; i++) { + els[i].addEventListener('input', this.onValueChange.bind(this), false); + } + + els = dom.querySelectorAll('.header'); + for (i = 0; i < els.length; i++) { + window.addClickHandler(els[i], onHeaderClick); + } + + document.querySelector('#transform .info').classList.add('hidden'); + document.querySelector('#transform .values').appendChild(dom); + } + + }); +}); + diff --git a/assets/webconfig/js/background.js b/assets/webconfig/js/background.js new file mode 100644 index 0000000..0f68ebb --- /dev/null +++ b/assets/webconfig/js/background.js @@ -0,0 +1,17 @@ +/*global chrome */ +chrome.app.runtime.onLaunched.addListener(function () { + 'use strict'; + chrome.app.window.create('index.html', { + 'id': 'fakeIdForSingleton', + 'innerBounds': { + 'width': 320, + 'height': 480, + 'minWidth': 320, + 'minHeight': 480 + }, + resizable: false + }); +}); + + + diff --git a/assets/webconfig/js/vendor/require.js b/assets/webconfig/js/vendor/require.js new file mode 100644 index 0000000..2ce09b5 --- /dev/null +++ b/assets/webconfig/js/vendor/require.js @@ -0,0 +1,2054 @@ +/** vim: et:ts=4:sw=4:sts=4 + * @license RequireJS 2.1.9 Copyright (c) 2010-2012, The Dojo Foundation All Rights Reserved. + * Available via the MIT or new BSD license. + * see: http://github.com/jrburke/requirejs for details + */ +//Not using strict: uneven strict support in browsers, #392, and causes +//problems with requirejs.exec()/transpiler plugins that may not be strict. +/*jslint regexp: true, nomen: true, sloppy: true */ +/*global window, navigator, document, importScripts, setTimeout, opera */ + +var requirejs, require, define; +(function (global) { + var req, s, head, baseElement, dataMain, src, + interactiveScript, currentlyAddingScript, mainScript, subPath, + version = '2.1.9', + commentRegExp = /(\/\*([\s\S]*?)\*\/|([^:]|^)\/\/(.*)$)/mg, + cjsRequireRegExp = /[^.]\s*require\s*\(\s*["']([^'"\s]+)["']\s*\)/g, + jsSuffixRegExp = /\.js$/, + currDirRegExp = /^\.\//, + op = Object.prototype, + ostring = op.toString, + hasOwn = op.hasOwnProperty, + ap = Array.prototype, + apsp = ap.splice, + isBrowser = !!(typeof window !== 'undefined' && typeof navigator !== 'undefined' && window.document), + isWebWorker = !isBrowser && typeof importScripts !== 'undefined', + //PS3 indicates loaded and complete, but need to wait for complete + //specifically. Sequence is 'loading', 'loaded', execution, + // then 'complete'. The UA check is unfortunate, but not sure how + //to feature test w/o causing perf issues. + readyRegExp = isBrowser && navigator.platform === 'PLAYSTATION 3' ? + /^complete$/ : /^(complete|loaded)$/, + defContextName = '_', + //Oh the tragedy, detecting opera. See the usage of isOpera for reason. + isOpera = typeof opera !== 'undefined' && opera.toString() === '[object Opera]', + contexts = {}, + cfg = {}, + globalDefQueue = [], + useInteractive = false; + + function isFunction(it) { + return ostring.call(it) === '[object Function]'; + } + + function isArray(it) { + return ostring.call(it) === '[object Array]'; + } + + /** + * Helper function for iterating over an array. If the func returns + * a true value, it will break out of the loop. + */ + function each(ary, func) { + if (ary) { + var i; + for (i = 0; i < ary.length; i += 1) { + if (ary[i] && func(ary[i], i, ary)) { + break; + } + } + } + } + + /** + * Helper function for iterating over an array backwards. If the func + * returns a true value, it will break out of the loop. + */ + function eachReverse(ary, func) { + if (ary) { + var i; + for (i = ary.length - 1; i > -1; i -= 1) { + if (ary[i] && func(ary[i], i, ary)) { + break; + } + } + } + } + + function hasProp(obj, prop) { + return hasOwn.call(obj, prop); + } + + function getOwn(obj, prop) { + return hasProp(obj, prop) && obj[prop]; + } + + /** + * Cycles over properties in an object and calls a function for each + * property value. If the function returns a truthy value, then the + * iteration is stopped. + */ + function eachProp(obj, func) { + var prop; + for (prop in obj) { + if (hasProp(obj, prop)) { + if (func(obj[prop], prop)) { + break; + } + } + } + } + + /** + * Simple function to mix in properties from source into target, + * but only if target does not already have a property of the same name. + */ + function mixin(target, source, force, deepStringMixin) { + if (source) { + eachProp(source, function (value, prop) { + if (force || !hasProp(target, prop)) { + if (deepStringMixin && typeof value !== 'string') { + if (!target[prop]) { + target[prop] = {}; + } + mixin(target[prop], value, force, deepStringMixin); + } else { + target[prop] = value; + } + } + }); + } + return target; + } + + //Similar to Function.prototype.bind, but the 'this' object is specified + //first, since it is easier to read/figure out what 'this' will be. + function bind(obj, fn) { + return function () { + return fn.apply(obj, arguments); + }; + } + + function scripts() { + return document.getElementsByTagName('script'); + } + + function defaultOnError(err) { + throw err; + } + + //Allow getting a global that expressed in + //dot notation, like 'a.b.c'. + function getGlobal(value) { + if (!value) { + return value; + } + var g = global; + each(value.split('.'), function (part) { + g = g[part]; + }); + return g; + } + + /** + * Constructs an error with a pointer to an URL with more information. + * @param {String} id the error ID that maps to an ID on a web page. + * @param {String} message human readable error. + * @param {Error} [err] the original error, if there is one. + * + * @returns {Error} + */ + function makeError(id, msg, err, requireModules) { + var e = new Error(msg + '\nhttp://requirejs.org/docs/errors.html#' + id); + e.requireType = id; + e.requireModules = requireModules; + if (err) { + e.originalError = err; + } + return e; + } + + if (typeof define !== 'undefined') { + //If a define is already in play via another AMD loader, + //do not overwrite. + return; + } + + if (typeof requirejs !== 'undefined') { + if (isFunction(requirejs)) { + //Do not overwrite and existing requirejs instance. + return; + } + cfg = requirejs; + requirejs = undefined; + } + + //Allow for a require config object + if (typeof require !== 'undefined' && !isFunction(require)) { + //assume it is a config object. + cfg = require; + require = undefined; + } + + function newContext(contextName) { + var inCheckLoaded, Module, context, handlers, + checkLoadedTimeoutId, + config = { + //Defaults. Do not set a default for map + //config to speed up normalize(), which + //will run faster if there is no default. + waitSeconds: 7, + baseUrl: './', + paths: {}, + pkgs: {}, + shim: {}, + config: {} + }, + registry = {}, + //registry of just enabled modules, to speed + //cycle breaking code when lots of modules + //are registered, but not activated. + enabledRegistry = {}, + undefEvents = {}, + defQueue = [], + defined = {}, + urlFetched = {}, + requireCounter = 1, + unnormalizedCounter = 1; + + /** + * Trims the . and .. from an array of path segments. + * It will keep a leading path segment if a .. will become + * the first path segment, to help with module name lookups, + * which act like paths, but can be remapped. But the end result, + * all paths that use this function should look normalized. + * NOTE: this method MODIFIES the input array. + * @param {Array} ary the array of path segments. + */ + function trimDots(ary) { + var i, part; + for (i = 0; ary[i]; i += 1) { + part = ary[i]; + if (part === '.') { + ary.splice(i, 1); + i -= 1; + } else if (part === '..') { + if (i === 1 && (ary[2] === '..' || ary[0] === '..')) { + //End of the line. Keep at least one non-dot + //path segment at the front so it can be mapped + //correctly to disk. Otherwise, there is likely + //no path mapping for a path starting with '..'. + //This can still fail, but catches the most reasonable + //uses of .. + break; + } else if (i > 0) { + ary.splice(i - 1, 2); + i -= 2; + } + } + } + } + + /** + * Given a relative module name, like ./something, normalize it to + * a real name that can be mapped to a path. + * @param {String} name the relative name + * @param {String} baseName a real name that the name arg is relative + * to. + * @param {Boolean} applyMap apply the map config to the value. Should + * only be done if this normalization is for a dependency ID. + * @returns {String} normalized name + */ + function normalize(name, baseName, applyMap) { + var pkgName, pkgConfig, mapValue, nameParts, i, j, nameSegment, + foundMap, foundI, foundStarMap, starI, + baseParts = baseName && baseName.split('/'), + normalizedBaseParts = baseParts, + map = config.map, + starMap = map && map['*']; + + //Adjust any relative paths. + if (name && name.charAt(0) === '.') { + //If have a base name, try to normalize against it, + //otherwise, assume it is a top-level require that will + //be relative to baseUrl in the end. + if (baseName) { + if (getOwn(config.pkgs, baseName)) { + //If the baseName is a package name, then just treat it as one + //name to concat the name with. + normalizedBaseParts = baseParts = [baseName]; + } else { + //Convert baseName to array, and lop off the last part, + //so that . matches that 'directory' and not name of the baseName's + //module. For instance, baseName of 'one/two/three', maps to + //'one/two/three.js', but we want the directory, 'one/two' for + //this normalization. + normalizedBaseParts = baseParts.slice(0, baseParts.length - 1); + } + + name = normalizedBaseParts.concat(name.split('/')); + trimDots(name); + + //Some use of packages may use a . path to reference the + //'main' module name, so normalize for that. + pkgConfig = getOwn(config.pkgs, (pkgName = name[0])); + name = name.join('/'); + if (pkgConfig && name === pkgName + '/' + pkgConfig.main) { + name = pkgName; + } + } else if (name.indexOf('./') === 0) { + // No baseName, so this is ID is resolved relative + // to baseUrl, pull off the leading dot. + name = name.substring(2); + } + } + + //Apply map config if available. + if (applyMap && map && (baseParts || starMap)) { + nameParts = name.split('/'); + + for (i = nameParts.length; i > 0; i -= 1) { + nameSegment = nameParts.slice(0, i).join('/'); + + if (baseParts) { + //Find the longest baseName segment match in the config. + //So, do joins on the biggest to smallest lengths of baseParts. + for (j = baseParts.length; j > 0; j -= 1) { + mapValue = getOwn(map, baseParts.slice(0, j).join('/')); + + //baseName segment has config, find if it has one for + //this name. + if (mapValue) { + mapValue = getOwn(mapValue, nameSegment); + if (mapValue) { + //Match, update name to the new value. + foundMap = mapValue; + foundI = i; + break; + } + } + } + } + + if (foundMap) { + break; + } + + //Check for a star map match, but just hold on to it, + //if there is a shorter segment match later in a matching + //config, then favor over this star map. + if (!foundStarMap && starMap && getOwn(starMap, nameSegment)) { + foundStarMap = getOwn(starMap, nameSegment); + starI = i; + } + } + + if (!foundMap && foundStarMap) { + foundMap = foundStarMap; + foundI = starI; + } + + if (foundMap) { + nameParts.splice(0, foundI, foundMap); + name = nameParts.join('/'); + } + } + + return name; + } + + function removeScript(name) { + if (isBrowser) { + each(scripts(), function (scriptNode) { + if (scriptNode.getAttribute('data-requiremodule') === name && + scriptNode.getAttribute('data-requirecontext') === context.contextName) { + scriptNode.parentNode.removeChild(scriptNode); + return true; + } + }); + } + } + + function hasPathFallback(id) { + var pathConfig = getOwn(config.paths, id); + if (pathConfig && isArray(pathConfig) && pathConfig.length > 1) { + //Pop off the first array value, since it failed, and + //retry + pathConfig.shift(); + context.require.undef(id); + context.require([id]); + return true; + } + } + + //Turns a plugin!resource to [plugin, resource] + //with the plugin being undefined if the name + //did not have a plugin prefix. + function splitPrefix(name) { + var prefix, + index = name ? name.indexOf('!') : -1; + if (index > -1) { + prefix = name.substring(0, index); + name = name.substring(index + 1, name.length); + } + return [prefix, name]; + } + + /** + * Creates a module mapping that includes plugin prefix, module + * name, and path. If parentModuleMap is provided it will + * also normalize the name via require.normalize() + * + * @param {String} name the module name + * @param {String} [parentModuleMap] parent module map + * for the module name, used to resolve relative names. + * @param {Boolean} isNormalized: is the ID already normalized. + * This is true if this call is done for a define() module ID. + * @param {Boolean} applyMap: apply the map config to the ID. + * Should only be true if this map is for a dependency. + * + * @returns {Object} + */ + function makeModuleMap(name, parentModuleMap, isNormalized, applyMap) { + var url, pluginModule, suffix, nameParts, + prefix = null, + parentName = parentModuleMap ? parentModuleMap.name : null, + originalName = name, + isDefine = true, + normalizedName = ''; + + //If no name, then it means it is a require call, generate an + //internal name. + if (!name) { + isDefine = false; + name = '_@r' + (requireCounter += 1); + } + + nameParts = splitPrefix(name); + prefix = nameParts[0]; + name = nameParts[1]; + + if (prefix) { + prefix = normalize(prefix, parentName, applyMap); + pluginModule = getOwn(defined, prefix); + } + + //Account for relative paths if there is a base name. + if (name) { + if (prefix) { + if (pluginModule && pluginModule.normalize) { + //Plugin is loaded, use its normalize method. + normalizedName = pluginModule.normalize(name, function (name) { + return normalize(name, parentName, applyMap); + }); + } else { + normalizedName = normalize(name, parentName, applyMap); + } + } else { + //A regular module. + normalizedName = normalize(name, parentName, applyMap); + + //Normalized name may be a plugin ID due to map config + //application in normalize. The map config values must + //already be normalized, so do not need to redo that part. + nameParts = splitPrefix(normalizedName); + prefix = nameParts[0]; + normalizedName = nameParts[1]; + isNormalized = true; + + url = context.nameToUrl(normalizedName); + } + } + + //If the id is a plugin id that cannot be determined if it needs + //normalization, stamp it with a unique ID so two matching relative + //ids that may conflict can be separate. + suffix = prefix && !pluginModule && !isNormalized ? + '_unnormalized' + (unnormalizedCounter += 1) : + ''; + + return { + prefix: prefix, + name: normalizedName, + parentMap: parentModuleMap, + unnormalized: !!suffix, + url: url, + originalName: originalName, + isDefine: isDefine, + id: (prefix ? + prefix + '!' + normalizedName : + normalizedName) + suffix + }; + } + + function getModule(depMap) { + var id = depMap.id, + mod = getOwn(registry, id); + + if (!mod) { + mod = registry[id] = new context.Module(depMap); + } + + return mod; + } + + function on(depMap, name, fn) { + var id = depMap.id, + mod = getOwn(registry, id); + + if (hasProp(defined, id) && + (!mod || mod.defineEmitComplete)) { + if (name === 'defined') { + fn(defined[id]); + } + } else { + mod = getModule(depMap); + if (mod.error && name === 'error') { + fn(mod.error); + } else { + mod.on(name, fn); + } + } + } + + function onError(err, errback) { + var ids = err.requireModules, + notified = false; + + if (errback) { + errback(err); + } else { + each(ids, function (id) { + var mod = getOwn(registry, id); + if (mod) { + //Set error on module, so it skips timeout checks. + mod.error = err; + if (mod.events.error) { + notified = true; + mod.emit('error', err); + } + } + }); + + if (!notified) { + req.onError(err); + } + } + } + + /** + * Internal method to transfer globalQueue items to this context's + * defQueue. + */ + function takeGlobalQueue() { + //Push all the globalDefQueue items into the context's defQueue + if (globalDefQueue.length) { + //Array splice in the values since the context code has a + //local var ref to defQueue, so cannot just reassign the one + //on context. + apsp.apply(defQueue, + [defQueue.length - 1, 0].concat(globalDefQueue)); + globalDefQueue = []; + } + } + + handlers = { + 'require': function (mod) { + if (mod.require) { + return mod.require; + } else { + return (mod.require = context.makeRequire(mod.map)); + } + }, + 'exports': function (mod) { + mod.usingExports = true; + if (mod.map.isDefine) { + if (mod.exports) { + return mod.exports; + } else { + return (mod.exports = defined[mod.map.id] = {}); + } + } + }, + 'module': function (mod) { + if (mod.module) { + return mod.module; + } else { + return (mod.module = { + id: mod.map.id, + uri: mod.map.url, + config: function () { + var c, + pkg = getOwn(config.pkgs, mod.map.id); + // For packages, only support config targeted + // at the main module. + c = pkg ? getOwn(config.config, mod.map.id + '/' + pkg.main) : + getOwn(config.config, mod.map.id); + return c || {}; + }, + exports: defined[mod.map.id] + }); + } + } + }; + + function cleanRegistry(id) { + //Clean up machinery used for waiting modules. + delete registry[id]; + delete enabledRegistry[id]; + } + + function breakCycle(mod, traced, processed) { + var id = mod.map.id; + + if (mod.error) { + mod.emit('error', mod.error); + } else { + traced[id] = true; + each(mod.depMaps, function (depMap, i) { + var depId = depMap.id, + dep = getOwn(registry, depId); + + //Only force things that have not completed + //being defined, so still in the registry, + //and only if it has not been matched up + //in the module already. + if (dep && !mod.depMatched[i] && !processed[depId]) { + if (getOwn(traced, depId)) { + mod.defineDep(i, defined[depId]); + mod.check(); //pass false? + } else { + breakCycle(dep, traced, processed); + } + } + }); + processed[id] = true; + } + } + + function checkLoaded() { + var map, modId, err, usingPathFallback, + waitInterval = config.waitSeconds * 1000, + //It is possible to disable the wait interval by using waitSeconds of 0. + expired = waitInterval && (context.startTime + waitInterval) < new Date().getTime(), + noLoads = [], + reqCalls = [], + stillLoading = false, + needCycleCheck = true; + + //Do not bother if this call was a result of a cycle break. + if (inCheckLoaded) { + return; + } + + inCheckLoaded = true; + + //Figure out the state of all the modules. + eachProp(enabledRegistry, function (mod) { + map = mod.map; + modId = map.id; + + //Skip things that are not enabled or in error state. + if (!mod.enabled) { + return; + } + + if (!map.isDefine) { + reqCalls.push(mod); + } + + if (!mod.error) { + //If the module should be executed, and it has not + //been inited and time is up, remember it. + if (!mod.inited && expired) { + if (hasPathFallback(modId)) { + usingPathFallback = true; + stillLoading = true; + } else { + noLoads.push(modId); + removeScript(modId); + } + } else if (!mod.inited && mod.fetched && map.isDefine) { + stillLoading = true; + if (!map.prefix) { + //No reason to keep looking for unfinished + //loading. If the only stillLoading is a + //plugin resource though, keep going, + //because it may be that a plugin resource + //is waiting on a non-plugin cycle. + return (needCycleCheck = false); + } + } + } + }); + + if (expired && noLoads.length) { + //If wait time expired, throw error of unloaded modules. + err = makeError('timeout', 'Load timeout for modules: ' + noLoads, null, noLoads); + err.contextName = context.contextName; + return onError(err); + } + + //Not expired, check for a cycle. + if (needCycleCheck) { + each(reqCalls, function (mod) { + breakCycle(mod, {}, {}); + }); + } + + //If still waiting on loads, and the waiting load is something + //other than a plugin resource, or there are still outstanding + //scripts, then just try back later. + if ((!expired || usingPathFallback) && stillLoading) { + //Something is still waiting to load. Wait for it, but only + //if a timeout is not already in effect. + if ((isBrowser || isWebWorker) && !checkLoadedTimeoutId) { + checkLoadedTimeoutId = setTimeout(function () { + checkLoadedTimeoutId = 0; + checkLoaded(); + }, 50); + } + } + + inCheckLoaded = false; + } + + Module = function (map) { + this.events = getOwn(undefEvents, map.id) || {}; + this.map = map; + this.shim = getOwn(config.shim, map.id); + this.depExports = []; + this.depMaps = []; + this.depMatched = []; + this.pluginMaps = {}; + this.depCount = 0; + + /* this.exports this.factory + this.depMaps = [], + this.enabled, this.fetched + */ + }; + + Module.prototype = { + init: function (depMaps, factory, errback, options) { + options = options || {}; + + //Do not do more inits if already done. Can happen if there + //are multiple define calls for the same module. That is not + //a normal, common case, but it is also not unexpected. + if (this.inited) { + return; + } + + this.factory = factory; + + if (errback) { + //Register for errors on this module. + this.on('error', errback); + } else if (this.events.error) { + //If no errback already, but there are error listeners + //on this module, set up an errback to pass to the deps. + errback = bind(this, function (err) { + this.emit('error', err); + }); + } + + //Do a copy of the dependency array, so that + //source inputs are not modified. For example + //"shim" deps are passed in here directly, and + //doing a direct modification of the depMaps array + //would affect that config. + this.depMaps = depMaps && depMaps.slice(0); + + this.errback = errback; + + //Indicate this module has be initialized + this.inited = true; + + this.ignore = options.ignore; + + //Could have option to init this module in enabled mode, + //or could have been previously marked as enabled. However, + //the dependencies are not known until init is called. So + //if enabled previously, now trigger dependencies as enabled. + if (options.enabled || this.enabled) { + //Enable this module and dependencies. + //Will call this.check() + this.enable(); + } else { + this.check(); + } + }, + + defineDep: function (i, depExports) { + //Because of cycles, defined callback for a given + //export can be called more than once. + if (!this.depMatched[i]) { + this.depMatched[i] = true; + this.depCount -= 1; + this.depExports[i] = depExports; + } + }, + + fetch: function () { + if (this.fetched) { + return; + } + this.fetched = true; + + context.startTime = (new Date()).getTime(); + + var map = this.map; + + //If the manager is for a plugin managed resource, + //ask the plugin to load it now. + if (this.shim) { + context.makeRequire(this.map, { + enableBuildCallback: true + })(this.shim.deps || [], bind(this, function () { + return map.prefix ? this.callPlugin() : this.load(); + })); + } else { + //Regular dependency. + return map.prefix ? this.callPlugin() : this.load(); + } + }, + + load: function () { + var url = this.map.url; + + //Regular dependency. + if (!urlFetched[url]) { + urlFetched[url] = true; + context.load(this.map.id, url); + } + }, + + /** + * Checks if the module is ready to define itself, and if so, + * define it. + */ + check: function () { + if (!this.enabled || this.enabling) { + return; + } + + var err, cjsModule, + id = this.map.id, + depExports = this.depExports, + exports = this.exports, + factory = this.factory; + + if (!this.inited) { + this.fetch(); + } else if (this.error) { + this.emit('error', this.error); + } else if (!this.defining) { + //The factory could trigger another require call + //that would result in checking this module to + //define itself again. If already in the process + //of doing that, skip this work. + this.defining = true; + + if (this.depCount < 1 && !this.defined) { + if (isFunction(factory)) { + //If there is an error listener, favor passing + //to that instead of throwing an error. However, + //only do it for define()'d modules. require + //errbacks should not be called for failures in + //their callbacks (#699). However if a global + //onError is set, use that. + if ((this.events.error && this.map.isDefine) || + req.onError !== defaultOnError) { + try { + exports = context.execCb(id, factory, depExports, exports); + } catch (e) { + err = e; + } + } else { + exports = context.execCb(id, factory, depExports, exports); + } + + if (this.map.isDefine) { + //If setting exports via 'module' is in play, + //favor that over return value and exports. After that, + //favor a non-undefined return value over exports use. + cjsModule = this.module; + if (cjsModule && + cjsModule.exports !== undefined && + //Make sure it is not already the exports value + cjsModule.exports !== this.exports) { + exports = cjsModule.exports; + } else if (exports === undefined && this.usingExports) { + //exports already set the defined value. + exports = this.exports; + } + } + + if (err) { + err.requireMap = this.map; + err.requireModules = this.map.isDefine ? [this.map.id] : null; + err.requireType = this.map.isDefine ? 'define' : 'require'; + return onError((this.error = err)); + } + + } else { + //Just a literal value + exports = factory; + } + + this.exports = exports; + + if (this.map.isDefine && !this.ignore) { + defined[id] = exports; + + if (req.onResourceLoad) { + req.onResourceLoad(context, this.map, this.depMaps); + } + } + + //Clean up + cleanRegistry(id); + + this.defined = true; + } + + //Finished the define stage. Allow calling check again + //to allow define notifications below in the case of a + //cycle. + this.defining = false; + + if (this.defined && !this.defineEmitted) { + this.defineEmitted = true; + this.emit('defined', this.exports); + this.defineEmitComplete = true; + } + + } + }, + + callPlugin: function () { + var map = this.map, + id = map.id, + //Map already normalized the prefix. + pluginMap = makeModuleMap(map.prefix); + + //Mark this as a dependency for this plugin, so it + //can be traced for cycles. + this.depMaps.push(pluginMap); + + on(pluginMap, 'defined', bind(this, function (plugin) { + var load, normalizedMap, normalizedMod, + name = this.map.name, + parentName = this.map.parentMap ? this.map.parentMap.name : null, + localRequire = context.makeRequire(map.parentMap, { + enableBuildCallback: true + }); + + //If current map is not normalized, wait for that + //normalized name to load instead of continuing. + if (this.map.unnormalized) { + //Normalize the ID if the plugin allows it. + if (plugin.normalize) { + name = plugin.normalize(name, function (name) { + return normalize(name, parentName, true); + }) || ''; + } + + //prefix and name should already be normalized, no need + //for applying map config again either. + normalizedMap = makeModuleMap(map.prefix + '!' + name, + this.map.parentMap); + on(normalizedMap, + 'defined', bind(this, function (value) { + this.init([], function () { return value; }, null, { + enabled: true, + ignore: true + }); + })); + + normalizedMod = getOwn(registry, normalizedMap.id); + if (normalizedMod) { + //Mark this as a dependency for this plugin, so it + //can be traced for cycles. + this.depMaps.push(normalizedMap); + + if (this.events.error) { + normalizedMod.on('error', bind(this, function (err) { + this.emit('error', err); + })); + } + normalizedMod.enable(); + } + + return; + } + + load = bind(this, function (value) { + this.init([], function () { return value; }, null, { + enabled: true + }); + }); + + load.error = bind(this, function (err) { + this.inited = true; + this.error = err; + err.requireModules = [id]; + + //Remove temp unnormalized modules for this module, + //since they will never be resolved otherwise now. + eachProp(registry, function (mod) { + if (mod.map.id.indexOf(id + '_unnormalized') === 0) { + cleanRegistry(mod.map.id); + } + }); + + onError(err); + }); + + //Allow plugins to load other code without having to know the + //context or how to 'complete' the load. + load.fromText = bind(this, function (text, textAlt) { + /*jslint evil: true */ + var moduleName = map.name, + moduleMap = makeModuleMap(moduleName), + hasInteractive = useInteractive; + + //As of 2.1.0, support just passing the text, to reinforce + //fromText only being called once per resource. Still + //support old style of passing moduleName but discard + //that moduleName in favor of the internal ref. + if (textAlt) { + text = textAlt; + } + + //Turn off interactive script matching for IE for any define + //calls in the text, then turn it back on at the end. + if (hasInteractive) { + useInteractive = false; + } + + //Prime the system by creating a module instance for + //it. + getModule(moduleMap); + + //Transfer any config to this other module. + if (hasProp(config.config, id)) { + config.config[moduleName] = config.config[id]; + } + + try { + req.exec(text); + } catch (e) { + return onError(makeError('fromtexteval', + 'fromText eval for ' + id + + ' failed: ' + e, + e, + [id])); + } + + if (hasInteractive) { + useInteractive = true; + } + + //Mark this as a dependency for the plugin + //resource + this.depMaps.push(moduleMap); + + //Support anonymous modules. + context.completeLoad(moduleName); + + //Bind the value of that module to the value for this + //resource ID. + localRequire([moduleName], load); + }); + + //Use parentName here since the plugin's name is not reliable, + //could be some weird string with no path that actually wants to + //reference the parentName's path. + plugin.load(map.name, localRequire, load, config); + })); + + context.enable(pluginMap, this); + this.pluginMaps[pluginMap.id] = pluginMap; + }, + + enable: function () { + enabledRegistry[this.map.id] = this; + this.enabled = true; + + //Set flag mentioning that the module is enabling, + //so that immediate calls to the defined callbacks + //for dependencies do not trigger inadvertent load + //with the depCount still being zero. + this.enabling = true; + + //Enable each dependency + each(this.depMaps, bind(this, function (depMap, i) { + var id, mod, handler; + + if (typeof depMap === 'string') { + //Dependency needs to be converted to a depMap + //and wired up to this module. + depMap = makeModuleMap(depMap, + (this.map.isDefine ? this.map : this.map.parentMap), + false, + !this.skipMap); + this.depMaps[i] = depMap; + + handler = getOwn(handlers, depMap.id); + + if (handler) { + this.depExports[i] = handler(this); + return; + } + + this.depCount += 1; + + on(depMap, 'defined', bind(this, function (depExports) { + this.defineDep(i, depExports); + this.check(); + })); + + if (this.errback) { + on(depMap, 'error', bind(this, this.errback)); + } + } + + id = depMap.id; + mod = registry[id]; + + //Skip special modules like 'require', 'exports', 'module' + //Also, don't call enable if it is already enabled, + //important in circular dependency cases. + if (!hasProp(handlers, id) && mod && !mod.enabled) { + context.enable(depMap, this); + } + })); + + //Enable each plugin that is used in + //a dependency + eachProp(this.pluginMaps, bind(this, function (pluginMap) { + var mod = getOwn(registry, pluginMap.id); + if (mod && !mod.enabled) { + context.enable(pluginMap, this); + } + })); + + this.enabling = false; + + this.check(); + }, + + on: function (name, cb) { + var cbs = this.events[name]; + if (!cbs) { + cbs = this.events[name] = []; + } + cbs.push(cb); + }, + + emit: function (name, evt) { + each(this.events[name], function (cb) { + cb(evt); + }); + if (name === 'error') { + //Now that the error handler was triggered, remove + //the listeners, since this broken Module instance + //can stay around for a while in the registry. + delete this.events[name]; + } + } + }; + + function callGetModule(args) { + //Skip modules already defined. + if (!hasProp(defined, args[0])) { + getModule(makeModuleMap(args[0], null, true)).init(args[1], args[2]); + } + } + + function removeListener(node, func, name, ieName) { + //Favor detachEvent because of IE9 + //issue, see attachEvent/addEventListener comment elsewhere + //in this file. + if (node.detachEvent && !isOpera) { + //Probably IE. If not it will throw an error, which will be + //useful to know. + if (ieName) { + node.detachEvent(ieName, func); + } + } else { + node.removeEventListener(name, func, false); + } + } + + /** + * Given an event from a script node, get the requirejs info from it, + * and then removes the event listeners on the node. + * @param {Event} evt + * @returns {Object} + */ + function getScriptData(evt) { + //Using currentTarget instead of target for Firefox 2.0's sake. Not + //all old browsers will be supported, but this one was easy enough + //to support and still makes sense. + var node = evt.currentTarget || evt.srcElement; + + //Remove the listeners once here. + removeListener(node, context.onScriptLoad, 'load', 'onreadystatechange'); + removeListener(node, context.onScriptError, 'error'); + + return { + node: node, + id: node && node.getAttribute('data-requiremodule') + }; + } + + function intakeDefines() { + var args; + + //Any defined modules in the global queue, intake them now. + takeGlobalQueue(); + + //Make sure any remaining defQueue items get properly processed. + while (defQueue.length) { + args = defQueue.shift(); + if (args[0] === null) { + return onError(makeError('mismatch', 'Mismatched anonymous define() module: ' + args[args.length - 1])); + } else { + //args are id, deps, factory. Should be normalized by the + //define() function. + callGetModule(args); + } + } + } + + context = { + config: config, + contextName: contextName, + registry: registry, + defined: defined, + urlFetched: urlFetched, + defQueue: defQueue, + Module: Module, + makeModuleMap: makeModuleMap, + nextTick: req.nextTick, + onError: onError, + + /** + * Set a configuration for the context. + * @param {Object} cfg config object to integrate. + */ + configure: function (cfg) { + //Make sure the baseUrl ends in a slash. + if (cfg.baseUrl) { + if (cfg.baseUrl.charAt(cfg.baseUrl.length - 1) !== '/') { + cfg.baseUrl += '/'; + } + } + + //Save off the paths and packages since they require special processing, + //they are additive. + var pkgs = config.pkgs, + shim = config.shim, + objs = { + paths: true, + config: true, + map: true + }; + + eachProp(cfg, function (value, prop) { + if (objs[prop]) { + if (prop === 'map') { + if (!config.map) { + config.map = {}; + } + mixin(config[prop], value, true, true); + } else { + mixin(config[prop], value, true); + } + } else { + config[prop] = value; + } + }); + + //Merge shim + if (cfg.shim) { + eachProp(cfg.shim, function (value, id) { + //Normalize the structure + if (isArray(value)) { + value = { + deps: value + }; + } + if ((value.exports || value.init) && !value.exportsFn) { + value.exportsFn = context.makeShimExports(value); + } + shim[id] = value; + }); + config.shim = shim; + } + + //Adjust packages if necessary. + if (cfg.packages) { + each(cfg.packages, function (pkgObj) { + var location; + + pkgObj = typeof pkgObj === 'string' ? { name: pkgObj } : pkgObj; + location = pkgObj.location; + + //Create a brand new object on pkgs, since currentPackages can + //be passed in again, and config.pkgs is the internal transformed + //state for all package configs. + pkgs[pkgObj.name] = { + name: pkgObj.name, + location: location || pkgObj.name, + //Remove leading dot in main, so main paths are normalized, + //and remove any trailing .js, since different package + //envs have different conventions: some use a module name, + //some use a file name. + main: (pkgObj.main || 'main') + .replace(currDirRegExp, '') + .replace(jsSuffixRegExp, '') + }; + }); + + //Done with modifications, assing packages back to context config + config.pkgs = pkgs; + } + + //If there are any "waiting to execute" modules in the registry, + //update the maps for them, since their info, like URLs to load, + //may have changed. + eachProp(registry, function (mod, id) { + //If module already has init called, since it is too + //late to modify them, and ignore unnormalized ones + //since they are transient. + if (!mod.inited && !mod.map.unnormalized) { + mod.map = makeModuleMap(id); + } + }); + + //If a deps array or a config callback is specified, then call + //require with those args. This is useful when require is defined as a + //config object before require.js is loaded. + if (cfg.deps || cfg.callback) { + context.require(cfg.deps || [], cfg.callback); + } + }, + + makeShimExports: function (value) { + function fn() { + var ret; + if (value.init) { + ret = value.init.apply(global, arguments); + } + return ret || (value.exports && getGlobal(value.exports)); + } + return fn; + }, + + makeRequire: function (relMap, options) { + options = options || {}; + + function localRequire(deps, callback, errback) { + var id, map, requireMod; + + if (options.enableBuildCallback && callback && isFunction(callback)) { + callback.__requireJsBuild = true; + } + + if (typeof deps === 'string') { + if (isFunction(callback)) { + //Invalid call + return onError(makeError('requireargs', 'Invalid require call'), errback); + } + + //If require|exports|module are requested, get the + //value for them from the special handlers. Caveat: + //this only works while module is being defined. + if (relMap && hasProp(handlers, deps)) { + return handlers[deps](registry[relMap.id]); + } + + //Synchronous access to one module. If require.get is + //available (as in the Node adapter), prefer that. + if (req.get) { + return req.get(context, deps, relMap, localRequire); + } + + //Normalize module name, if it contains . or .. + map = makeModuleMap(deps, relMap, false, true); + id = map.id; + + if (!hasProp(defined, id)) { + return onError(makeError('notloaded', 'Module name "' + + id + + '" has not been loaded yet for context: ' + + contextName + + (relMap ? '' : '. Use require([])'))); + } + return defined[id]; + } + + //Grab defines waiting in the global queue. + intakeDefines(); + + //Mark all the dependencies as needing to be loaded. + context.nextTick(function () { + //Some defines could have been added since the + //require call, collect them. + intakeDefines(); + + requireMod = getModule(makeModuleMap(null, relMap)); + + //Store if map config should be applied to this require + //call for dependencies. + requireMod.skipMap = options.skipMap; + + requireMod.init(deps, callback, errback, { + enabled: true + }); + + checkLoaded(); + }); + + return localRequire; + } + + mixin(localRequire, { + isBrowser: isBrowser, + + /** + * Converts a module name + .extension into an URL path. + * *Requires* the use of a module name. It does not support using + * plain URLs like nameToUrl. + */ + toUrl: function (moduleNamePlusExt) { + var ext, + index = moduleNamePlusExt.lastIndexOf('.'), + segment = moduleNamePlusExt.split('/')[0], + isRelative = segment === '.' || segment === '..'; + + //Have a file extension alias, and it is not the + //dots from a relative path. + if (index !== -1 && (!isRelative || index > 1)) { + ext = moduleNamePlusExt.substring(index, moduleNamePlusExt.length); + moduleNamePlusExt = moduleNamePlusExt.substring(0, index); + } + + return context.nameToUrl(normalize(moduleNamePlusExt, + relMap && relMap.id, true), ext, true); + }, + + defined: function (id) { + return hasProp(defined, makeModuleMap(id, relMap, false, true).id); + }, + + specified: function (id) { + id = makeModuleMap(id, relMap, false, true).id; + return hasProp(defined, id) || hasProp(registry, id); + } + }); + + //Only allow undef on top level require calls + if (!relMap) { + localRequire.undef = function (id) { + //Bind any waiting define() calls to this context, + //fix for #408 + takeGlobalQueue(); + + var map = makeModuleMap(id, relMap, true), + mod = getOwn(registry, id); + + removeScript(id); + + delete defined[id]; + delete urlFetched[map.url]; + delete undefEvents[id]; + + if (mod) { + //Hold on to listeners in case the + //module will be attempted to be reloaded + //using a different config. + if (mod.events.defined) { + undefEvents[id] = mod.events; + } + + cleanRegistry(id); + } + }; + } + + return localRequire; + }, + + /** + * Called to enable a module if it is still in the registry + * awaiting enablement. A second arg, parent, the parent module, + * is passed in for context, when this method is overriden by + * the optimizer. Not shown here to keep code compact. + */ + enable: function (depMap) { + var mod = getOwn(registry, depMap.id); + if (mod) { + getModule(depMap).enable(); + } + }, + + /** + * Internal method used by environment adapters to complete a load event. + * A load event could be a script load or just a load pass from a synchronous + * load call. + * @param {String} moduleName the name of the module to potentially complete. + */ + completeLoad: function (moduleName) { + var found, args, mod, + shim = getOwn(config.shim, moduleName) || {}, + shExports = shim.exports; + + takeGlobalQueue(); + + while (defQueue.length) { + args = defQueue.shift(); + if (args[0] === null) { + args[0] = moduleName; + //If already found an anonymous module and bound it + //to this name, then this is some other anon module + //waiting for its completeLoad to fire. + if (found) { + break; + } + found = true; + } else if (args[0] === moduleName) { + //Found matching define call for this script! + found = true; + } + + callGetModule(args); + } + + //Do this after the cycle of callGetModule in case the result + //of those calls/init calls changes the registry. + mod = getOwn(registry, moduleName); + + if (!found && !hasProp(defined, moduleName) && mod && !mod.inited) { + if (config.enforceDefine && (!shExports || !getGlobal(shExports))) { + if (hasPathFallback(moduleName)) { + return; + } else { + return onError(makeError('nodefine', + 'No define call for ' + moduleName, + null, + [moduleName])); + } + } else { + //A script that does not call define(), so just simulate + //the call for it. + callGetModule([moduleName, (shim.deps || []), shim.exportsFn]); + } + } + + checkLoaded(); + }, + + /** + * Converts a module name to a file path. Supports cases where + * moduleName may actually be just an URL. + * Note that it **does not** call normalize on the moduleName, + * it is assumed to have already been normalized. This is an + * internal API, not a public one. Use toUrl for the public API. + */ + nameToUrl: function (moduleName, ext, skipExt) { + var paths, pkgs, pkg, pkgPath, syms, i, parentModule, url, + parentPath; + + //If a colon is in the URL, it indicates a protocol is used and it is just + //an URL to a file, or if it starts with a slash, contains a query arg (i.e. ?) + //or ends with .js, then assume the user meant to use an url and not a module id. + //The slash is important for protocol-less URLs as well as full paths. + if (req.jsExtRegExp.test(moduleName)) { + //Just a plain path, not module name lookup, so just return it. + //Add extension if it is included. This is a bit wonky, only non-.js things pass + //an extension, this method probably needs to be reworked. + url = moduleName + (ext || ''); + } else { + //A module that needs to be converted to a path. + paths = config.paths; + pkgs = config.pkgs; + + syms = moduleName.split('/'); + //For each module name segment, see if there is a path + //registered for it. Start with most specific name + //and work up from it. + for (i = syms.length; i > 0; i -= 1) { + parentModule = syms.slice(0, i).join('/'); + pkg = getOwn(pkgs, parentModule); + parentPath = getOwn(paths, parentModule); + if (parentPath) { + //If an array, it means there are a few choices, + //Choose the one that is desired + if (isArray(parentPath)) { + parentPath = parentPath[0]; + } + syms.splice(0, i, parentPath); + break; + } else if (pkg) { + //If module name is just the package name, then looking + //for the main module. + if (moduleName === pkg.name) { + pkgPath = pkg.location + '/' + pkg.main; + } else { + pkgPath = pkg.location; + } + syms.splice(0, i, pkgPath); + break; + } + } + + //Join the path parts together, then figure out if baseUrl is needed. + url = syms.join('/'); + url += (ext || (/^data\:|\?/.test(url) || skipExt ? '' : '.js')); + url = (url.charAt(0) === '/' || url.match(/^[\w\+\.\-]+:/) ? '' : config.baseUrl) + url; + } + + return config.urlArgs ? url + + ((url.indexOf('?') === -1 ? '?' : '&') + + config.urlArgs) : url; + }, + + //Delegates to req.load. Broken out as a separate function to + //allow overriding in the optimizer. + load: function (id, url) { + req.load(context, id, url); + }, + + /** + * Executes a module callback function. Broken out as a separate function + * solely to allow the build system to sequence the files in the built + * layer in the right sequence. + * + * @private + */ + execCb: function (name, callback, args, exports) { + return callback.apply(exports, args); + }, + + /** + * callback for script loads, used to check status of loading. + * + * @param {Event} evt the event from the browser for the script + * that was loaded. + */ + onScriptLoad: function (evt) { + //Using currentTarget instead of target for Firefox 2.0's sake. Not + //all old browsers will be supported, but this one was easy enough + //to support and still makes sense. + if (evt.type === 'load' || + (readyRegExp.test((evt.currentTarget || evt.srcElement).readyState))) { + //Reset interactive script so a script node is not held onto for + //to long. + interactiveScript = null; + + //Pull out the name of the module and the context. + var data = getScriptData(evt); + context.completeLoad(data.id); + } + }, + + /** + * Callback for script errors. + */ + onScriptError: function (evt) { + var data = getScriptData(evt); + if (!hasPathFallback(data.id)) { + return onError(makeError('scripterror', 'Script error for: ' + data.id, evt, [data.id])); + } + } + }; + + context.require = context.makeRequire(); + return context; + } + + /** + * Main entry point. + * + * If the only argument to require is a string, then the module that + * is represented by that string is fetched for the appropriate context. + * + * If the first argument is an array, then it will be treated as an array + * of dependency string names to fetch. An optional function callback can + * be specified to execute when all of those dependencies are available. + * + * Make a local req variable to help Caja compliance (it assumes things + * on a require that are not standardized), and to give a short + * name for minification/local scope use. + */ + req = requirejs = function (deps, callback, errback, optional) { + + //Find the right context, use default + var context, config, + contextName = defContextName; + + // Determine if have config object in the call. + if (!isArray(deps) && typeof deps !== 'string') { + // deps is a config object + config = deps; + if (isArray(callback)) { + // Adjust args if there are dependencies + deps = callback; + callback = errback; + errback = optional; + } else { + deps = []; + } + } + + if (config && config.context) { + contextName = config.context; + } + + context = getOwn(contexts, contextName); + if (!context) { + context = contexts[contextName] = req.s.newContext(contextName); + } + + if (config) { + context.configure(config); + } + + return context.require(deps, callback, errback); + }; + + /** + * Support require.config() to make it easier to cooperate with other + * AMD loaders on globally agreed names. + */ + req.config = function (config) { + return req(config); + }; + + /** + * Execute something after the current tick + * of the event loop. Override for other envs + * that have a better solution than setTimeout. + * @param {Function} fn function to execute later. + */ + req.nextTick = typeof setTimeout !== 'undefined' ? function (fn) { + setTimeout(fn, 4); + } : function (fn) { fn(); }; + + /** + * Export require as a global, but only if it does not already exist. + */ + if (!require) { + require = req; + } + + req.version = version; + + //Used to filter out dependencies that are already paths. + req.jsExtRegExp = /^\/|:|\?|\.js$/; + req.isBrowser = isBrowser; + s = req.s = { + contexts: contexts, + newContext: newContext + }; + + //Create default context. + req({}); + + //Exports some context-sensitive methods on global require. + each([ + 'toUrl', + 'undef', + 'defined', + 'specified' + ], function (prop) { + //Reference from contexts instead of early binding to default context, + //so that during builds, the latest instance of the default context + //with its config gets used. + req[prop] = function () { + var ctx = contexts[defContextName]; + return ctx.require[prop].apply(ctx, arguments); + }; + }); + + if (isBrowser) { + head = s.head = document.getElementsByTagName('head')[0]; + //If BASE tag is in play, using appendChild is a problem for IE6. + //When that browser dies, this can be removed. Details in this jQuery bug: + //http://dev.jquery.com/ticket/2709 + baseElement = document.getElementsByTagName('base')[0]; + if (baseElement) { + head = s.head = baseElement.parentNode; + } + } + + /** + * Any errors that require explicitly generates will be passed to this + * function. Intercept/override it if you want custom error handling. + * @param {Error} err the error object. + */ + req.onError = defaultOnError; + + /** + * Creates the node for the load command. Only used in browser envs. + */ + req.createNode = function (config, moduleName, url) { + var node = config.xhtml ? + document.createElementNS('http://www.w3.org/1999/xhtml', 'html:script') : + document.createElement('script'); + node.type = config.scriptType || 'text/javascript'; + node.charset = 'utf-8'; + node.async = true; + return node; + }; + + /** + * Does the request to load a module for the browser case. + * Make this a separate function to allow other environments + * to override it. + * + * @param {Object} context the require context to find state. + * @param {String} moduleName the name of the module. + * @param {Object} url the URL to the module. + */ + req.load = function (context, moduleName, url) { + var config = (context && context.config) || {}, + node; + if (isBrowser) { + //In the browser so use a script tag + node = req.createNode(config, moduleName, url); + + node.setAttribute('data-requirecontext', context.contextName); + node.setAttribute('data-requiremodule', moduleName); + + //Set up load listener. Test attachEvent first because IE9 has + //a subtle issue in its addEventListener and script onload firings + //that do not match the behavior of all other browsers with + //addEventListener support, which fire the onload event for a + //script right after the script execution. See: + //https://connect.microsoft.com/IE/feedback/details/648057/script-onload-event-is-not-fired-immediately-after-script-execution + //UNFORTUNATELY Opera implements attachEvent but does not follow the script + //script execution mode. + if (node.attachEvent && + //Check if node.attachEvent is artificially added by custom script or + //natively supported by browser + //read https://github.com/jrburke/requirejs/issues/187 + //if we can NOT find [native code] then it must NOT natively supported. + //in IE8, node.attachEvent does not have toString() + //Note the test for "[native code" with no closing brace, see: + //https://github.com/jrburke/requirejs/issues/273 + !(node.attachEvent.toString && node.attachEvent.toString().indexOf('[native code') < 0) && + !isOpera) { + //Probably IE. IE (at least 6-8) do not fire + //script onload right after executing the script, so + //we cannot tie the anonymous define call to a name. + //However, IE reports the script as being in 'interactive' + //readyState at the time of the define call. + useInteractive = true; + + node.attachEvent('onreadystatechange', context.onScriptLoad); + //It would be great to add an error handler here to catch + //404s in IE9+. However, onreadystatechange will fire before + //the error handler, so that does not help. If addEventListener + //is used, then IE will fire error before load, but we cannot + //use that pathway given the connect.microsoft.com issue + //mentioned above about not doing the 'script execute, + //then fire the script load event listener before execute + //next script' that other browsers do. + //Best hope: IE10 fixes the issues, + //and then destroys all installs of IE 6-9. + //node.attachEvent('onerror', context.onScriptError); + } else { + node.addEventListener('load', context.onScriptLoad, false); + node.addEventListener('error', context.onScriptError, false); + } + node.src = url; + + //For some cache cases in IE 6-8, the script executes before the end + //of the appendChild execution, so to tie an anonymous define + //call to the module name (which is stored on the node), hold on + //to a reference to this node, but clear after the DOM insertion. + currentlyAddingScript = node; + if (baseElement) { + head.insertBefore(node, baseElement); + } else { + head.appendChild(node); + } + currentlyAddingScript = null; + + return node; + } else if (isWebWorker) { + try { + //In a web worker, use importScripts. This is not a very + //efficient use of importScripts, importScripts will block until + //its script is downloaded and evaluated. However, if web workers + //are in play, the expectation that a build has been done so that + //only one script needs to be loaded anyway. This may need to be + //reevaluated if other use cases become common. + importScripts(url); + + //Account for anonymous modules + context.completeLoad(moduleName); + } catch (e) { + context.onError(makeError('importscripts', + 'importScripts failed for ' + + moduleName + ' at ' + url, + e, + [moduleName])); + } + } + }; + + function getInteractiveScript() { + if (interactiveScript && interactiveScript.readyState === 'interactive') { + return interactiveScript; + } + + eachReverse(scripts(), function (script) { + if (script.readyState === 'interactive') { + return (interactiveScript = script); + } + }); + return interactiveScript; + } + + //Look for a data-main script attribute, which could also adjust the baseUrl. + if (isBrowser && !cfg.skipDataMain) { + //Figure out baseUrl. Get it from the script tag with require.js in it. + eachReverse(scripts(), function (script) { + //Set the 'head' where we can append children by + //using the script's parent. + if (!head) { + head = script.parentNode; + } + + //Look for a data-main attribute to set main script for the page + //to load. If it is there, the path to data main becomes the + //baseUrl, if it is not already set. + dataMain = script.getAttribute('data-main'); + if (dataMain) { + //Preserve dataMain in case it is a path (i.e. contains '?') + mainScript = dataMain; + + //Set final baseUrl if there is not already an explicit one. + if (!cfg.baseUrl) { + //Pull off the directory of data-main for use as the + //baseUrl. + src = mainScript.split('/'); + mainScript = src.pop(); + subPath = src.length ? src.join('/') + '/' : './'; + + cfg.baseUrl = subPath; + } + + //Strip off any trailing .js since mainScript is now + //like a module name. + mainScript = mainScript.replace(jsSuffixRegExp, ''); + + //If mainScript is still a path, fall back to dataMain + if (req.jsExtRegExp.test(mainScript)) { + mainScript = dataMain; + } + + //Put the data-main script in the files to load. + cfg.deps = cfg.deps ? cfg.deps.concat(mainScript) : [mainScript]; + + return true; + } + }); + } + + /** + * The function that handles definitions of modules. Differs from + * require() in that a string for the module should be the first argument, + * and the function to execute after dependencies are loaded should + * return a value to define the module corresponding to the first argument's + * name. + */ + define = function (name, deps, callback) { + var node, context; + + //Allow for anonymous modules + if (typeof name !== 'string') { + //Adjust args appropriately + callback = deps; + deps = name; + name = null; + } + + //This module may not have dependencies + if (!isArray(deps)) { + callback = deps; + deps = null; + } + + //If no name, and callback is a function, then figure out if it a + //CommonJS thing with dependencies. + if (!deps && isFunction(callback)) { + deps = []; + //Remove comments from the callback string, + //look for require calls, and pull them into the dependencies, + //but only if there are function args. + if (callback.length) { + callback + .toString() + .replace(commentRegExp, '') + .replace(cjsRequireRegExp, function (match, dep) { + deps.push(dep); + }); + + //May be a CommonJS thing even without require calls, but still + //could use exports, and module. Avoid doing exports and module + //work though if it just needs require. + //REQUIRES the function to expect the CommonJS variables in the + //order listed below. + deps = (callback.length === 1 ? ['require'] : ['require', 'exports', 'module']).concat(deps); + } + } + + //If in IE 6-8 and hit an anonymous define() call, do the interactive + //work. + if (useInteractive) { + node = currentlyAddingScript || getInteractiveScript(); + if (node) { + if (!name) { + name = node.getAttribute('data-requiremodule'); + } + context = contexts[node.getAttribute('data-requirecontext')]; + } + } + + //Always save off evaluating the def call until the script onload handler. + //This allows multiple modules to be in a file without prematurely + //tracing dependencies, and allows for anonymous module support, + //where the module name is not known until the script onload event + //occurs. If no context, use the global queue, and get it processed + //in the onscript load callback. + (context ? context.defQueue : globalDefQueue).push([name, deps, callback]); + }; + + define.amd = { + jQuery: true + }; + + + /** + * Executes the text. Normally just uses eval, but can be modified + * to use a better, environment-specific call. Only used for transpiling + * loader plugins, not for plain JS modules. + * @param {String} text the text to execute/evaluate. + */ + req.exec = function (text) { + /*jslint evil: true */ + return eval(text); + }; + + //Set up with config info. + req(cfg); +}(this)); diff --git a/assets/webconfig/js/vendor/stapes.js b/assets/webconfig/js/vendor/stapes.js new file mode 100644 index 0000000..106af17 --- /dev/null +++ b/assets/webconfig/js/vendor/stapes.js @@ -0,0 +1,594 @@ +// +// ____ _ _ +// / ___|| |_ __ _ _ __ ___ ___ (_)___ (*) +// \___ \| __/ _` | '_ \ / _ \/ __| | / __| +// ___) | || (_| | |_) | __/\__ \_ | \__ \ +// |____/ \__\__,_| .__/ \___||___(_)/ |___/ +// |_| |__/ +// +// (*) a (really) tiny Javascript MVC microframework +// +// (c) Hay Kranen < hay@bykr.org > +// Released under the terms of the MIT license +// < http://en.wikipedia.org/wiki/MIT_License > +// +// Stapes.js : http://hay.github.com/stapes +(function() { + 'use strict'; + + var VERSION = "0.8.0"; + + // Global counter for all events in all modules (including mixed in objects) + var guid = 1; + + // Makes _.create() faster + if (!Object.create) { + var CachedFunction = function(){}; + } + + // So we can use slice.call for arguments later on + var slice = Array.prototype.slice; + + // Private attributes and helper functions, stored in an object so they + // are overwritable by plugins + var _ = { + // Properties + attributes : {}, + + eventHandlers : { + "-1" : {} // '-1' is used for the global event handling + }, + + guid : -1, + + // Methods + addEvent : function(event) { + // If we don't have any handlers for this type of event, add a new + // array we can use to push new handlers + if (!_.eventHandlers[event.guid][event.type]) { + _.eventHandlers[event.guid][event.type] = []; + } + + // Push an event object + _.eventHandlers[event.guid][event.type].push({ + "guid" : event.guid, + "handler" : event.handler, + "scope" : event.scope, + "type" : event.type + }); + }, + + addEventHandler : function(argTypeOrMap, argHandlerOrScope, argScope) { + var eventMap = {}, + scope; + + if (typeof argTypeOrMap === "string") { + scope = argScope || false; + eventMap[ argTypeOrMap ] = argHandlerOrScope; + } else { + scope = argHandlerOrScope || false; + eventMap = argTypeOrMap; + } + + for (var eventString in eventMap) { + var handler = eventMap[eventString]; + var events = eventString.split(" "); + + for (var i = 0, l = events.length; i < l; i++) { + var eventType = events[i]; + _.addEvent.call(this, { + "guid" : this._guid || this._.guid, + "handler" : handler, + "scope" : scope, + "type" : eventType + }); + } + } + }, + + addGuid : function(object, forceGuid) { + if (object._guid && !forceGuid) return; + + object._guid = guid++; + + _.attributes[object._guid] = {}; + _.eventHandlers[object._guid] = {}; + }, + + // This is a really small utility function to save typing and produce + // better optimized code + attr : function(guid) { + return _.attributes[guid]; + }, + + clone : function(obj) { + var type = _.typeOf(obj); + + if (type === 'object') { + return _.extend({}, obj); + } + + if (type === 'array') { + return obj.slice(0); + } + }, + + create : function(proto) { + if (Object.create) { + return Object.create(proto); + } else { + CachedFunction.prototype = proto; + return new CachedFunction(); + } + }, + + createSubclass : function(props, includeEvents) { + props = props || {}; + includeEvents = includeEvents || false; + + var superclass = props.superclass.prototype; + + // Objects always have a constructor, so we need to be sure this is + // a property instead of something from the prototype + var realConstructor = props.hasOwnProperty('constructor') ? props.constructor : function(){}; + + function constructor() { + // Be kind to people forgetting new + if (!(this instanceof constructor)) { + throw new Error("Please use 'new' when initializing Stapes classes"); + } + + // If this class has events add a GUID as well + if (this.on) { + _.addGuid( this, true ); + } + + realConstructor.apply(this, arguments); + } + + if (includeEvents) { + _.extend(superclass, Events); + } + + constructor.prototype = _.create(superclass); + constructor.prototype.constructor = constructor; + + _.extend(constructor, { + extend : function() { + return _.extendThis.apply(this, arguments); + }, + + // We can't call this 'super' because that's a reserved keyword + // and fails in IE8 + 'parent' : superclass, + + proto : function() { + return _.extendThis.apply(this.prototype, arguments); + }, + + subclass : function(obj) { + obj = obj || {}; + obj.superclass = this; + return _.createSubclass(obj); + } + }); + + // Copy all props given in the definition to the prototype + for (var key in props) { + if (key !== 'constructor' && key !== 'superclass') { + constructor.prototype[key] = props[key]; + } + } + + return constructor; + }, + + emitEvents : function(type, data, explicitType, explicitGuid) { + explicitType = explicitType || false; + explicitGuid = explicitGuid || this._guid; + + // #30: make a local copy of handlers to prevent problems with + // unbinding the event while unwinding the loop + var handlers = slice.call(_.eventHandlers[explicitGuid][type]); + + for (var i = 0, l = handlers.length; i < l; i++) { + // Clone the event to prevent issue #19 + var event = _.extend({}, handlers[i]); + var scope = (event.scope) ? event.scope : this; + + if (explicitType) { + event.type = explicitType; + } + + event.scope = scope; + event.handler.call(event.scope, data, event); + } + }, + + // Extend an object with more objects + extend : function() { + var args = slice.call(arguments); + var object = args.shift(); + + for (var i = 0, l = args.length; i < l; i++) { + var props = args[i]; + for (var key in props) { + object[key] = props[key]; + } + } + + return object; + }, + + // The same as extend, but uses the this value as the scope + extendThis : function() { + var args = slice.call(arguments); + args.unshift(this); + return _.extend.apply(this, args); + }, + + // from http://stackoverflow.com/a/2117523/152809 + makeUuid : function() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); + return v.toString(16); + }); + }, + + removeAttribute : function(keys, silent) { + silent = silent || false; + + // Split the key, maybe we want to remove more than one item + var attributes = _.trim(keys).split(" "); + + // Actually delete the item + for (var i = 0, l = attributes.length; i < l; i++) { + var key = _.trim(attributes[i]); + + if (key) { + delete _.attr(this._guid)[key]; + + // If 'silent' is set, do not throw any events + if (!silent) { + this.emit('change', key); + this.emit('change:' + key); + this.emit('remove', key); + this.emit('remove:' + key); + } + } + } + }, + + removeEventHandler : function(type, handler) { + var handlers = _.eventHandlers[this._guid]; + + if (type && handler) { + // Remove a specific handler + handlers = handlers[type]; + if (!handlers) return; + + for (var i = 0, l = handlers.length, h; i < l; i++) { + h = handlers[i].handler; + if (h && h === handler) { + handlers.splice(i--, 1); + l--; + } + } + } else if (type) { + // Remove all handlers for a specific type + delete handlers[type]; + } else { + // Remove all handlers for this module + _.eventHandlers[this._guid] = {}; + } + }, + + setAttribute : function(key, value, silent) { + silent = silent || false; + + // We need to do this before we actually add the item :) + var itemExists = this.has(key); + var oldValue = _.attr(this._guid)[key]; + + // Is the value different than the oldValue? If not, ignore this call + if (value === oldValue) { + return; + } + + // Actually add the item to the attributes + _.attr(this._guid)[key] = value; + + // If 'silent' flag is set, do not throw any events + if (silent) { + return; + } + + // Throw a generic event + this.emit('change', key); + + // And a namespaced event as well, NOTE that we pass value instead of + // key here! + this.emit('change:' + key, value); + + // Throw namespaced and non-namespaced 'mutate' events as well with + // the old value data as well and some extra metadata such as the key + var mutateData = { + "key" : key, + "newValue" : value, + "oldValue" : oldValue || null + }; + + this.emit('mutate', mutateData); + this.emit('mutate:' + key, mutateData); + + // Also throw a specific event for this type of set + var specificEvent = itemExists ? 'update' : 'create'; + + this.emit(specificEvent, key); + + // And a namespaced event as well, NOTE that we pass value instead of key + this.emit(specificEvent + ':' + key, value); + }, + + trim : function(str) { + return str.replace(/^\s\s*/, '').replace(/\s\s*$/, ''); + }, + + typeOf : function(val) { + if (val === null || typeof val === "undefined") { + // This is a special exception for IE, in other browsers the + // method below works all the time + return String(val); + } else { + return Object.prototype.toString.call(val).replace(/\[object |\]/g, '').toLowerCase(); + } + }, + + updateAttribute : function(key, fn, silent) { + var item = this.get(key); + + // In previous versions of Stapes we didn't have the check for object, + // but still this worked. In 0.7.0 it suddenly doesn't work anymore and + // we need the check. Why? I have no clue. + var type = _.typeOf(item); + + if (type === 'object' || type === 'array') { + item = _.clone(item); + } + + var newValue = fn.call(this, item, key); + _.setAttribute.call(this, key, newValue, silent || false); + } + }; + + // Can be mixed in later using Stapes.mixinEvents(object); + var Events = { + emit : function(types, data) { + data = (typeof data === "undefined") ? null : data; + + var splittedTypes = types.split(" "); + + for (var i = 0, l = splittedTypes.length; i < l; i++) { + var type = splittedTypes[i]; + + // First 'all' type events: is there an 'all' handler in the + // global stack? + if (_.eventHandlers[-1].all) { + _.emitEvents.call(this, "all", data, type, -1); + } + + // Catch all events for this type? + if (_.eventHandlers[-1][type]) { + _.emitEvents.call(this, type, data, type, -1); + } + + if (typeof this._guid === 'number') { + // 'all' event for this specific module? + if (_.eventHandlers[this._guid].all) { + _.emitEvents.call(this, "all", data, type); + } + + // Finally, normal events :) + if (_.eventHandlers[this._guid][type]) { + _.emitEvents.call(this, type, data); + } + } + } + }, + + off : function() { + _.removeEventHandler.apply(this, arguments); + }, + + on : function() { + _.addEventHandler.apply(this, arguments); + } + }; + + _.Module = function() { + + }; + + _.Module.prototype = { + each : function(fn, ctx) { + var attr = _.attr(this._guid); + for (var key in attr) { + var value = attr[key]; + fn.call(ctx || this, value, key); + } + }, + + extend : function() { + return _.extendThis.apply(this, arguments); + }, + + filter : function(fn) { + var filtered = []; + var attributes = _.attr(this._guid); + + for (var key in attributes) { + if ( fn.call(this, attributes[key], key)) { + filtered.push( attributes[key] ); + } + } + + return filtered; + }, + + get : function(input) { + if (typeof input === "string") { + return this.has(input) ? _.attr(this._guid)[input] : null; + } else if (typeof input === "function") { + var items = this.filter(input); + return (items.length) ? items[0] : null; + } + }, + + getAll : function() { + return _.clone( _.attr(this._guid) ); + }, + + getAllAsArray : function() { + var arr = []; + var attributes = _.attr(this._guid); + + for (var key in attributes) { + var value = attributes[key]; + + if (_.typeOf(value) === "object" && !value.id) { + value.id = key; + } + + arr.push(value); + } + + return arr; + }, + + has : function(key) { + return (typeof _.attr(this._guid)[key] !== "undefined"); + }, + + map : function(fn, ctx) { + var mapped = []; + this.each(function(value, key) { + mapped.push( fn.call(ctx || this, value, key) ); + }, ctx || this); + return mapped; + }, + + // Akin to set(), but makes a unique id + push : function(input, silent) { + if (_.typeOf(input) === "array") { + for (var i = 0, l = input.length; i < l; i++) { + _.setAttribute.call(this, _.makeUuid(), input[i], silent || false); + } + } else { + _.setAttribute.call(this, _.makeUuid(), input, silent || false); + } + + return this; + }, + + remove : function(input, silent) { + if (typeof input === 'undefined') { + // With no arguments, remove deletes all attributes + _.attributes[this._guid] = {}; + this.emit('change remove'); + } else if (typeof input === "function") { + this.each(function(item, key) { + if (input(item)) { + _.removeAttribute.call(this, key, silent); + } + }); + } else { + // nb: checking for exists happens in removeAttribute + _.removeAttribute.call(this, input, silent || false); + } + + return this; + }, + + set : function(objOrKey, valueOrSilent, silent) { + if (typeof objOrKey === "object") { + for (var key in objOrKey) { + _.setAttribute.call(this, key, objOrKey[key], valueOrSilent || false); + } + } else { + _.setAttribute.call(this, objOrKey, valueOrSilent, silent || false); + } + + return this; + }, + + size : function() { + var size = 0; + var attr = _.attr(this._guid); + + for (var key in attr) { + size++; + } + + return size; + }, + + update : function(keyOrFn, fn, silent) { + if (typeof keyOrFn === "string") { + _.updateAttribute.call(this, keyOrFn, fn, silent || false); + } else if (typeof keyOrFn === "function") { + this.each(function(value, key) { + _.updateAttribute.call(this, key, keyOrFn); + }); + } + + return this; + } + }; + + var Stapes = { + "_" : _, // private helper functions and properties + + "extend" : function() { + return _.extendThis.apply(_.Module.prototype, arguments); + }, + + "mixinEvents" : function(obj) { + obj = obj || {}; + + _.addGuid(obj); + + return _.extend(obj, Events); + }, + + "on" : function() { + _.addEventHandler.apply(this, arguments); + }, + + "subclass" : function(obj, classOnly) { + classOnly = classOnly || false; + obj = obj || {}; + obj.superclass = classOnly ? function(){} : _.Module; + return _.createSubclass(obj, !classOnly); + }, + + "version" : VERSION + }; + + // This library can be used as an AMD module, a Node.js module, or an + // old fashioned global + if (typeof exports !== "undefined") { + // Server + if (typeof module !== "undefined" && module.exports) { + exports = module.exports = Stapes; + } + exports.Stapes = Stapes; + } else if (typeof define === "function" && define.amd) { + // AMD + define(function() { + return Stapes; + }); + } else { + // Global scope + window.Stapes = Stapes; + } +})(); diff --git a/assets/webconfig/js/vendor/tinycolor.js b/assets/webconfig/js/vendor/tinycolor.js new file mode 100644 index 0000000..4c6f8d9 --- /dev/null +++ b/assets/webconfig/js/vendor/tinycolor.js @@ -0,0 +1,1107 @@ +// TinyColor v1.0.0 +// https://github.com/bgrins/TinyColor +// Brian Grinstead, MIT License + +(function() { + + var trimLeft = /^[\s,#]+/, + trimRight = /\s+$/, + tinyCounter = 0, + math = Math, + mathRound = math.round, + mathMin = math.min, + mathMax = math.max, + mathRandom = math.random; + + var tinycolor = function tinycolor (color, opts) { + + color = (color) ? color : ''; + opts = opts || { }; + + // If input is already a tinycolor, return itself + if (color instanceof tinycolor) { + return color; + } + // If we are called as a function, call using new instead + if (!(this instanceof tinycolor)) { + return new tinycolor(color, opts); + } + + var rgb = inputToRGB(color); + this._r = rgb.r, + this._g = rgb.g, + this._b = rgb.b, + this._a = rgb.a, + this._roundA = mathRound(100*this._a) / 100, + this._format = opts.format || rgb.format; + this._gradientType = opts.gradientType; + + // Don't let the range of [0,255] come back in [0,1]. + // Potentially lose a little bit of precision here, but will fix issues where + // .5 gets interpreted as half of the total, instead of half of 1 + // If it was supposed to be 128, this was already taken care of by `inputToRgb` + if (this._r < 1) { this._r = mathRound(this._r); } + if (this._g < 1) { this._g = mathRound(this._g); } + if (this._b < 1) { this._b = mathRound(this._b); } + + this._ok = rgb.ok; + this._tc_id = tinyCounter++; + }; + + tinycolor.prototype = { + isDark: function() { + return this.getBrightness() < 128; + }, + isLight: function() { + return !this.isDark(); + }, + isValid: function() { + return this._ok; + }, + getFormat: function() { + return this._format; + }, + getAlpha: function() { + return this._a; + }, + getBrightness: function() { + var rgb = this.toRgb(); + return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000; + }, + setAlpha: function(value) { + this._a = boundAlpha(value); + this._roundA = mathRound(100*this._a) / 100; + return this; + }, + toHsv: function() { + var hsv = rgbToHsv(this._r, this._g, this._b); + return { h: hsv.h * 360, s: hsv.s, v: hsv.v, a: this._a }; + }, + toHsvString: function() { + var hsv = rgbToHsv(this._r, this._g, this._b); + var h = mathRound(hsv.h * 360), s = mathRound(hsv.s * 100), v = mathRound(hsv.v * 100); + return (this._a == 1) ? + "hsv(" + h + ", " + s + "%, " + v + "%)" : + "hsva(" + h + ", " + s + "%, " + v + "%, "+ this._roundA + ")"; + }, + toHsl: function() { + var hsl = rgbToHsl(this._r, this._g, this._b); + return { h: hsl.h * 360, s: hsl.s, l: hsl.l, a: this._a }; + }, + toHslString: function() { + var hsl = rgbToHsl(this._r, this._g, this._b); + var h = mathRound(hsl.h * 360), s = mathRound(hsl.s * 100), l = mathRound(hsl.l * 100); + return (this._a == 1) ? + "hsl(" + h + ", " + s + "%, " + l + "%)" : + "hsla(" + h + ", " + s + "%, " + l + "%, "+ this._roundA + ")"; + }, + toHex: function(allow3Char) { + return rgbToHex(this._r, this._g, this._b, allow3Char); + }, + toHexString: function(allow3Char) { + return '#' + this.toHex(allow3Char); + }, + toHex8: function() { + return rgbaToHex(this._r, this._g, this._b, this._a); + }, + toHex8String: function() { + return '#' + this.toHex8(); + }, + toRgb: function() { + return { r: mathRound(this._r), g: mathRound(this._g), b: mathRound(this._b), a: this._a }; + }, + toRgbString: function() { + return (this._a == 1) ? + "rgb(" + mathRound(this._r) + ", " + mathRound(this._g) + ", " + mathRound(this._b) + ")" : + "rgba(" + mathRound(this._r) + ", " + mathRound(this._g) + ", " + mathRound(this._b) + ", " + this._roundA + ")"; + }, + toPercentageRgb: function() { + return { r: mathRound(bound01(this._r, 255) * 100) + "%", g: mathRound(bound01(this._g, 255) * 100) + "%", b: mathRound(bound01(this._b, 255) * 100) + "%", a: this._a }; + }, + toPercentageRgbString: function() { + return (this._a == 1) ? + "rgb(" + mathRound(bound01(this._r, 255) * 100) + "%, " + mathRound(bound01(this._g, 255) * 100) + "%, " + mathRound(bound01(this._b, 255) * 100) + "%)" : + "rgba(" + mathRound(bound01(this._r, 255) * 100) + "%, " + mathRound(bound01(this._g, 255) * 100) + "%, " + mathRound(bound01(this._b, 255) * 100) + "%, " + this._roundA + ")"; + }, + toName: function() { + if (this._a === 0) { + return "transparent"; + } + + if (this._a < 1) { + return false; + } + + return hexNames[rgbToHex(this._r, this._g, this._b, true)] || false; + }, + toFilter: function(secondColor) { + var hex8String = '#' + rgbaToHex(this._r, this._g, this._b, this._a); + var secondHex8String = hex8String; + var gradientType = this._gradientType ? "GradientType = 1, " : ""; + + if (secondColor) { + var s = tinycolor(secondColor); + secondHex8String = s.toHex8String(); + } + + return "progid:DXImageTransform.Microsoft.gradient("+gradientType+"startColorstr="+hex8String+",endColorstr="+secondHex8String+")"; + }, + toString: function(format) { + var formatSet = !!format; + format = format || this._format; + + var formattedString = false; + var hasAlpha = this._a < 1 && this._a >= 0; + var needsAlphaFormat = !formatSet && hasAlpha && (format === "hex" || format === "hex6" || format === "hex3" || format === "name"); + + if (needsAlphaFormat) { + // Special case for "transparent", all other non-alpha formats + // will return rgba when there is transparency. + if (format === "name" && this._a === 0) { + return this.toName(); + } + return this.toRgbString(); + } + if (format === "rgb") { + formattedString = this.toRgbString(); + } + if (format === "prgb") { + formattedString = this.toPercentageRgbString(); + } + if (format === "hex" || format === "hex6") { + formattedString = this.toHexString(); + } + if (format === "hex3") { + formattedString = this.toHexString(true); + } + if (format === "hex8") { + formattedString = this.toHex8String(); + } + if (format === "name") { + formattedString = this.toName(); + } + if (format === "hsl") { + formattedString = this.toHslString(); + } + if (format === "hsv") { + formattedString = this.toHsvString(); + } + + return formattedString || this.toHexString(); + }, + + _applyModification: function(fn, args) { + var color = fn.apply(null, [this].concat([].slice.call(args))); + this._r = color._r; + this._g = color._g; + this._b = color._b; + this.setAlpha(color._a); + return this; + }, + lighten: function() { + return this._applyModification(lighten, arguments); + }, + brighten: function() { + return this._applyModification(brighten, arguments); + }, + darken: function() { + return this._applyModification(darken, arguments); + }, + desaturate: function() { + return this._applyModification(desaturate, arguments); + }, + saturate: function() { + return this._applyModification(saturate, arguments); + }, + greyscale: function() { + return this._applyModification(greyscale, arguments); + }, + spin: function() { + return this._applyModification(spin, arguments); + }, + + _applyCombination: function(fn, args) { + return fn.apply(null, [this].concat([].slice.call(args))); + }, + analogous: function() { + return this._applyCombination(analogous, arguments); + }, + complement: function() { + return this._applyCombination(complement, arguments); + }, + monochromatic: function() { + return this._applyCombination(monochromatic, arguments); + }, + splitcomplement: function() { + return this._applyCombination(splitcomplement, arguments); + }, + triad: function() { + return this._applyCombination(triad, arguments); + }, + tetrad: function() { + return this._applyCombination(tetrad, arguments); + } + }; + + // If input is an object, force 1 into "1.0" to handle ratios properly + // String input requires "1.0" as input, so 1 will be treated as 1 + tinycolor.fromRatio = function(color, opts) { + if (typeof color == "object") { + var newColor = {}; + for (var i in color) { + if (color.hasOwnProperty(i)) { + if (i === "a") { + newColor[i] = color[i]; + } + else { + newColor[i] = convertToPercentage(color[i]); + } + } + } + color = newColor; + } + + return tinycolor(color, opts); + }; + + // Given a string or object, convert that input to RGB + // Possible string inputs: + // + // "red" + // "#f00" or "f00" + // "#ff0000" or "ff0000" + // "#ff000000" or "ff000000" + // "rgb 255 0 0" or "rgb (255, 0, 0)" + // "rgb 1.0 0 0" or "rgb (1, 0, 0)" + // "rgba (255, 0, 0, 1)" or "rgba 255, 0, 0, 1" + // "rgba (1.0, 0, 0, 1)" or "rgba 1.0, 0, 0, 1" + // "hsl(0, 100%, 50%)" or "hsl 0 100% 50%" + // "hsla(0, 100%, 50%, 1)" or "hsla 0 100% 50%, 1" + // "hsv(0, 100%, 100%)" or "hsv 0 100% 100%" + // + function inputToRGB(color) { + + var rgb = { r: 0, g: 0, b: 0 }; + var a = 1; + var ok = false; + var format = false; + + if (typeof color == "string") { + color = stringInputToObject(color); + } + + if (typeof color == "object") { + if (color.hasOwnProperty("r") && color.hasOwnProperty("g") && color.hasOwnProperty("b")) { + rgb = rgbToRgb(color.r, color.g, color.b); + ok = true; + format = String(color.r).substr(-1) === "%" ? "prgb" : "rgb"; + } + else if (color.hasOwnProperty("h") && color.hasOwnProperty("s") && color.hasOwnProperty("v")) { + color.s = convertToPercentage(color.s); + color.v = convertToPercentage(color.v); + rgb = hsvToRgb(color.h, color.s, color.v); + ok = true; + format = "hsv"; + } + else if (color.hasOwnProperty("h") && color.hasOwnProperty("s") && color.hasOwnProperty("l")) { + color.s = convertToPercentage(color.s); + color.l = convertToPercentage(color.l); + rgb = hslToRgb(color.h, color.s, color.l); + ok = true; + format = "hsl"; + } + + if (color.hasOwnProperty("a")) { + a = color.a; + } + } + + a = boundAlpha(a); + + return { + ok: ok, + format: color.format || format, + r: mathMin(255, mathMax(rgb.r, 0)), + g: mathMin(255, mathMax(rgb.g, 0)), + b: mathMin(255, mathMax(rgb.b, 0)), + a: a + }; + } + + + // Conversion Functions + // -------------------- + + // `rgbToHsl`, `rgbToHsv`, `hslToRgb`, `hsvToRgb` modified from: + // + + // `rgbToRgb` + // Handle bounds / percentage checking to conform to CSS color spec + // + // *Assumes:* r, g, b in [0, 255] or [0, 1] + // *Returns:* { r, g, b } in [0, 255] + function rgbToRgb(r, g, b){ + return { + r: bound01(r, 255) * 255, + g: bound01(g, 255) * 255, + b: bound01(b, 255) * 255 + }; + } + + // `rgbToHsl` + // Converts an RGB color value to HSL. + // *Assumes:* r, g, and b are contained in [0, 255] or [0, 1] + // *Returns:* { h, s, l } in [0,1] + function rgbToHsl(r, g, b) { + + r = bound01(r, 255); + g = bound01(g, 255); + b = bound01(b, 255); + + var max = mathMax(r, g, b), min = mathMin(r, g, b); + var h, s, l = (max + min) / 2; + + if(max == min) { + h = s = 0; // achromatic + } + else { + var d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch(max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + + h /= 6; + } + + return { h: h, s: s, l: l }; + } + + // `hslToRgb` + // Converts an HSL color value to RGB. + // *Assumes:* h is contained in [0, 1] or [0, 360] and s and l are contained [0, 1] or [0, 100] + // *Returns:* { r, g, b } in the set [0, 255] + function hslToRgb(h, s, l) { + var r, g, b; + + h = bound01(h, 360); + s = bound01(s, 100); + l = bound01(l, 100); + + function hue2rgb(p, q, t) { + if(t < 0) t += 1; + if(t > 1) t -= 1; + if(t < 1/6) return p + (q - p) * 6 * t; + if(t < 1/2) return q; + if(t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + } + + if(s === 0) { + r = g = b = l; // achromatic + } + else { + var q = l < 0.5 ? l * (1 + s) : l + s - l * s; + var p = 2 * l - q; + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + + return { r: r * 255, g: g * 255, b: b * 255 }; + } + + // `rgbToHsv` + // Converts an RGB color value to HSV + // *Assumes:* r, g, and b are contained in the set [0, 255] or [0, 1] + // *Returns:* { h, s, v } in [0,1] + function rgbToHsv(r, g, b) { + + r = bound01(r, 255); + g = bound01(g, 255); + b = bound01(b, 255); + + var max = mathMax(r, g, b), min = mathMin(r, g, b); + var h, s, v = max; + + var d = max - min; + s = max === 0 ? 0 : d / max; + + if(max == min) { + h = 0; // achromatic + } + else { + switch(max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + } + return { h: h, s: s, v: v }; + } + + // `hsvToRgb` + // Converts an HSV color value to RGB. + // *Assumes:* h is contained in [0, 1] or [0, 360] and s and v are contained in [0, 1] or [0, 100] + // *Returns:* { r, g, b } in the set [0, 255] + function hsvToRgb(h, s, v) { + + h = bound01(h, 360) * 6; + s = bound01(s, 100); + v = bound01(v, 100); + + var i = math.floor(h), + f = h - i, + p = v * (1 - s), + q = v * (1 - f * s), + t = v * (1 - (1 - f) * s), + mod = i % 6, + r = [v, q, p, p, t, v][mod], + g = [t, v, v, q, p, p][mod], + b = [p, p, t, v, v, q][mod]; + + return { r: r * 255, g: g * 255, b: b * 255 }; + } + + // `rgbToHex` + // Converts an RGB color to hex + // Assumes r, g, and b are contained in the set [0, 255] + // Returns a 3 or 6 character hex + function rgbToHex(r, g, b, allow3Char) { + + var hex = [ + pad2(mathRound(r).toString(16)), + pad2(mathRound(g).toString(16)), + pad2(mathRound(b).toString(16)) + ]; + + // Return a 3 character hex if possible + if (allow3Char && hex[0].charAt(0) == hex[0].charAt(1) && hex[1].charAt(0) == hex[1].charAt(1) && hex[2].charAt(0) == hex[2].charAt(1)) { + return hex[0].charAt(0) + hex[1].charAt(0) + hex[2].charAt(0); + } + + return hex.join(""); + } + // `rgbaToHex` + // Converts an RGBA color plus alpha transparency to hex + // Assumes r, g, b and a are contained in the set [0, 255] + // Returns an 8 character hex + function rgbaToHex(r, g, b, a) { + + var hex = [ + pad2(convertDecimalToHex(a)), + pad2(mathRound(r).toString(16)), + pad2(mathRound(g).toString(16)), + pad2(mathRound(b).toString(16)) + ]; + + return hex.join(""); + } + + // `equals` + // Can be called with any tinycolor input + tinycolor.equals = function (color1, color2) { + if (!color1 || !color2) { return false; } + return tinycolor(color1).toRgbString() == tinycolor(color2).toRgbString(); + }; + tinycolor.random = function() { + return tinycolor.fromRatio({ + r: mathRandom(), + g: mathRandom(), + b: mathRandom() + }); + }; + + + // Modification Functions + // ---------------------- + // Thanks to less.js for some of the basics here + // + + function desaturate(color, amount) { + amount = (amount === 0) ? 0 : (amount || 10); + var hsl = tinycolor(color).toHsl(); + hsl.s -= amount / 100; + hsl.s = clamp01(hsl.s); + return tinycolor(hsl); + } + + function saturate(color, amount) { + amount = (amount === 0) ? 0 : (amount || 10); + var hsl = tinycolor(color).toHsl(); + hsl.s += amount / 100; + hsl.s = clamp01(hsl.s); + return tinycolor(hsl); + } + + function greyscale(color) { + return tinycolor(color).desaturate(100); + } + + function lighten (color, amount) { + amount = (amount === 0) ? 0 : (amount || 10); + var hsl = tinycolor(color).toHsl(); + hsl.l += amount / 100; + hsl.l = clamp01(hsl.l); + return tinycolor(hsl); + } + + function brighten(color, amount) { + amount = (amount === 0) ? 0 : (amount || 10); + var rgb = tinycolor(color).toRgb(); + rgb.r = mathMax(0, mathMin(255, rgb.r - mathRound(255 * - (amount / 100)))); + rgb.g = mathMax(0, mathMin(255, rgb.g - mathRound(255 * - (amount / 100)))); + rgb.b = mathMax(0, mathMin(255, rgb.b - mathRound(255 * - (amount / 100)))); + return tinycolor(rgb); + } + + function darken (color, amount) { + amount = (amount === 0) ? 0 : (amount || 10); + var hsl = tinycolor(color).toHsl(); + hsl.l -= amount / 100; + hsl.l = clamp01(hsl.l); + return tinycolor(hsl); + } + + // Spin takes a positive or negative amount within [-360, 360] indicating the change of hue. + // Values outside of this range will be wrapped into this range. + function spin(color, amount) { + var hsl = tinycolor(color).toHsl(); + var hue = (mathRound(hsl.h) + amount) % 360; + hsl.h = hue < 0 ? 360 + hue : hue; + return tinycolor(hsl); + } + + // Combination Functions + // --------------------- + // Thanks to jQuery xColor for some of the ideas behind these + // + + function complement(color) { + var hsl = tinycolor(color).toHsl(); + hsl.h = (hsl.h + 180) % 360; + return tinycolor(hsl); + } + + function triad(color) { + var hsl = tinycolor(color).toHsl(); + var h = hsl.h; + return [ + tinycolor(color), + tinycolor({ h: (h + 120) % 360, s: hsl.s, l: hsl.l }), + tinycolor({ h: (h + 240) % 360, s: hsl.s, l: hsl.l }) + ]; + } + + function tetrad(color) { + var hsl = tinycolor(color).toHsl(); + var h = hsl.h; + return [ + tinycolor(color), + tinycolor({ h: (h + 90) % 360, s: hsl.s, l: hsl.l }), + tinycolor({ h: (h + 180) % 360, s: hsl.s, l: hsl.l }), + tinycolor({ h: (h + 270) % 360, s: hsl.s, l: hsl.l }) + ]; + } + + function splitcomplement(color) { + var hsl = tinycolor(color).toHsl(); + var h = hsl.h; + return [ + tinycolor(color), + tinycolor({ h: (h + 72) % 360, s: hsl.s, l: hsl.l}), + tinycolor({ h: (h + 216) % 360, s: hsl.s, l: hsl.l}) + ]; + } + + function analogous(color, results, slices) { + results = results || 6; + slices = slices || 30; + + var hsl = tinycolor(color).toHsl(); + var part = 360 / slices; + var ret = [tinycolor(color)]; + + for (hsl.h = ((hsl.h - (part * results >> 1)) + 720) % 360; --results; ) { + hsl.h = (hsl.h + part) % 360; + ret.push(tinycolor(hsl)); + } + return ret; + } + + function monochromatic(color, results) { + results = results || 6; + var hsv = tinycolor(color).toHsv(); + var h = hsv.h, s = hsv.s, v = hsv.v; + var ret = []; + var modification = 1 / results; + + while (results--) { + ret.push(tinycolor({ h: h, s: s, v: v})); + v = (v + modification) % 1; + } + + return ret; + } + + // Utility Functions + // --------------------- + + tinycolor.mix = function(color1, color2, amount) { + amount = (amount === 0) ? 0 : (amount || 50); + + var rgb1 = tinycolor(color1).toRgb(); + var rgb2 = tinycolor(color2).toRgb(); + + var p = amount / 100; + var w = p * 2 - 1; + var a = rgb2.a - rgb1.a; + + var w1; + + if (w * a == -1) { + w1 = w; + } else { + w1 = (w + a) / (1 + w * a); + } + + w1 = (w1 + 1) / 2; + + var w2 = 1 - w1; + + var rgba = { + r: rgb2.r * w1 + rgb1.r * w2, + g: rgb2.g * w1 + rgb1.g * w2, + b: rgb2.b * w1 + rgb1.b * w2, + a: rgb2.a * p + rgb1.a * (1 - p) + }; + + return tinycolor(rgba); + }; + + + // Readability Functions + // --------------------- + // + + // `readability` + // Analyze the 2 colors and returns an object with the following properties: + // `brightness`: difference in brightness between the two colors + // `color`: difference in color/hue between the two colors + tinycolor.readability = function(color1, color2) { + var c1 = tinycolor(color1); + var c2 = tinycolor(color2); + var rgb1 = c1.toRgb(); + var rgb2 = c2.toRgb(); + var brightnessA = c1.getBrightness(); + var brightnessB = c2.getBrightness(); + var colorDiff = ( + Math.max(rgb1.r, rgb2.r) - Math.min(rgb1.r, rgb2.r) + + Math.max(rgb1.g, rgb2.g) - Math.min(rgb1.g, rgb2.g) + + Math.max(rgb1.b, rgb2.b) - Math.min(rgb1.b, rgb2.b) + ); + + return { + brightness: Math.abs(brightnessA - brightnessB), + color: colorDiff + }; + }; + + // `readable` + // http://www.w3.org/TR/AERT#color-contrast + // Ensure that foreground and background color combinations provide sufficient contrast. + // *Example* + // tinycolor.isReadable("#000", "#111") => false + tinycolor.isReadable = function(color1, color2) { + var readability = tinycolor.readability(color1, color2); + return readability.brightness > 125 && readability.color > 500; + }; + + // `mostReadable` + // Given a base color and a list of possible foreground or background + // colors for that base, returns the most readable color. + // *Example* + // tinycolor.mostReadable("#123", ["#fff", "#000"]) => "#000" + tinycolor.mostReadable = function(baseColor, colorList) { + var bestColor = null; + var bestScore = 0; + var bestIsReadable = false; + for (var i=0; i < colorList.length; i++) { + + // We normalize both around the "acceptable" breaking point, + // but rank brightness constrast higher than hue. + + var readability = tinycolor.readability(baseColor, colorList[i]); + var readable = readability.brightness > 125 && readability.color > 500; + var score = 3 * (readability.brightness / 125) + (readability.color / 500); + + if ((readable && ! bestIsReadable) || + (readable && bestIsReadable && score > bestScore) || + ((! readable) && (! bestIsReadable) && score > bestScore)) { + bestIsReadable = readable; + bestScore = score; + bestColor = tinycolor(colorList[i]); + } + } + return bestColor; + }; + + + // Big List of Colors + // ------------------ + // + var names = tinycolor.names = { + aliceblue: "f0f8ff", + antiquewhite: "faebd7", + aqua: "0ff", + aquamarine: "7fffd4", + azure: "f0ffff", + beige: "f5f5dc", + bisque: "ffe4c4", + black: "000", + blanchedalmond: "ffebcd", + blue: "00f", + blueviolet: "8a2be2", + brown: "a52a2a", + burlywood: "deb887", + burntsienna: "ea7e5d", + cadetblue: "5f9ea0", + chartreuse: "7fff00", + chocolate: "d2691e", + coral: "ff7f50", + cornflowerblue: "6495ed", + cornsilk: "fff8dc", + crimson: "dc143c", + cyan: "0ff", + darkblue: "00008b", + darkcyan: "008b8b", + darkgoldenrod: "b8860b", + darkgray: "a9a9a9", + darkgreen: "006400", + darkgrey: "a9a9a9", + darkkhaki: "bdb76b", + darkmagenta: "8b008b", + darkolivegreen: "556b2f", + darkorange: "ff8c00", + darkorchid: "9932cc", + darkred: "8b0000", + darksalmon: "e9967a", + darkseagreen: "8fbc8f", + darkslateblue: "483d8b", + darkslategray: "2f4f4f", + darkslategrey: "2f4f4f", + darkturquoise: "00ced1", + darkviolet: "9400d3", + deeppink: "ff1493", + deepskyblue: "00bfff", + dimgray: "696969", + dimgrey: "696969", + dodgerblue: "1e90ff", + firebrick: "b22222", + floralwhite: "fffaf0", + forestgreen: "228b22", + fuchsia: "f0f", + gainsboro: "dcdcdc", + ghostwhite: "f8f8ff", + gold: "ffd700", + goldenrod: "daa520", + gray: "808080", + green: "008000", + greenyellow: "adff2f", + grey: "808080", + honeydew: "f0fff0", + hotpink: "ff69b4", + indianred: "cd5c5c", + indigo: "4b0082", + ivory: "fffff0", + khaki: "f0e68c", + lavender: "e6e6fa", + lavenderblush: "fff0f5", + lawngreen: "7cfc00", + lemonchiffon: "fffacd", + lightblue: "add8e6", + lightcoral: "f08080", + lightcyan: "e0ffff", + lightgoldenrodyellow: "fafad2", + lightgray: "d3d3d3", + lightgreen: "90ee90", + lightgrey: "d3d3d3", + lightpink: "ffb6c1", + lightsalmon: "ffa07a", + lightseagreen: "20b2aa", + lightskyblue: "87cefa", + lightslategray: "789", + lightslategrey: "789", + lightsteelblue: "b0c4de", + lightyellow: "ffffe0", + lime: "0f0", + limegreen: "32cd32", + linen: "faf0e6", + magenta: "f0f", + maroon: "800000", + mediumaquamarine: "66cdaa", + mediumblue: "0000cd", + mediumorchid: "ba55d3", + mediumpurple: "9370db", + mediumseagreen: "3cb371", + mediumslateblue: "7b68ee", + mediumspringgreen: "00fa9a", + mediumturquoise: "48d1cc", + mediumvioletred: "c71585", + midnightblue: "191970", + mintcream: "f5fffa", + mistyrose: "ffe4e1", + moccasin: "ffe4b5", + navajowhite: "ffdead", + navy: "000080", + oldlace: "fdf5e6", + olive: "808000", + olivedrab: "6b8e23", + orange: "ffa500", + orangered: "ff4500", + orchid: "da70d6", + palegoldenrod: "eee8aa", + palegreen: "98fb98", + paleturquoise: "afeeee", + palevioletred: "db7093", + papayawhip: "ffefd5", + peachpuff: "ffdab9", + peru: "cd853f", + pink: "ffc0cb", + plum: "dda0dd", + powderblue: "b0e0e6", + purple: "800080", + red: "f00", + rosybrown: "bc8f8f", + royalblue: "4169e1", + saddlebrown: "8b4513", + salmon: "fa8072", + sandybrown: "f4a460", + seagreen: "2e8b57", + seashell: "fff5ee", + sienna: "a0522d", + silver: "c0c0c0", + skyblue: "87ceeb", + slateblue: "6a5acd", + slategray: "708090", + slategrey: "708090", + snow: "fffafa", + springgreen: "00ff7f", + steelblue: "4682b4", + tan: "d2b48c", + teal: "008080", + thistle: "d8bfd8", + tomato: "ff6347", + turquoise: "40e0d0", + violet: "ee82ee", + wheat: "f5deb3", + white: "fff", + whitesmoke: "f5f5f5", + yellow: "ff0", + yellowgreen: "9acd32" + }; + + // Make it easy to access colors via `hexNames[hex]` + var hexNames = tinycolor.hexNames = flip(names); + + + // Utilities + // --------- + + // `{ 'name1': 'val1' }` becomes `{ 'val1': 'name1' }` + function flip(o) { + var flipped = { }; + for (var i in o) { + if (o.hasOwnProperty(i)) { + flipped[o[i]] = i; + } + } + return flipped; + } + + // Return a valid alpha value [0,1] with all invalid values being set to 1 + function boundAlpha(a) { + a = parseFloat(a); + + if (isNaN(a) || a < 0 || a > 1) { + a = 1; + } + + return a; + } + + // Take input from [0, n] and return it as [0, 1] + function bound01(n, max) { + if (isOnePointZero(n)) { n = "100%"; } + + var processPercent = isPercentage(n); + n = mathMin(max, mathMax(0, parseFloat(n))); + + // Automatically convert percentage into number + if (processPercent) { + n = parseInt(n * max, 10) / 100; + } + + // Handle floating point rounding errors + if ((math.abs(n - max) < 0.000001)) { + return 1; + } + + // Convert into [0, 1] range if it isn't already + return (n % max) / parseFloat(max); + } + + // Force a number between 0 and 1 + function clamp01(val) { + return mathMin(1, mathMax(0, val)); + } + + // Parse a base-16 hex value into a base-10 integer + function parseIntFromHex(val) { + return parseInt(val, 16); + } + + // Need to handle 1.0 as 100%, since once it is a number, there is no difference between it and 1 + // + function isOnePointZero(n) { + return typeof n == "string" && n.indexOf('.') != -1 && parseFloat(n) === 1; + } + + // Check to see if string passed in is a percentage + function isPercentage(n) { + return typeof n === "string" && n.indexOf('%') != -1; + } + + // Force a hex value to have 2 characters + function pad2(c) { + return c.length == 1 ? '0' + c : '' + c; + } + + // Replace a decimal with it's percentage value + function convertToPercentage(n) { + if (n <= 1) { + n = (n * 100) + "%"; + } + + return n; + } + + // Converts a decimal to a hex value + function convertDecimalToHex(d) { + return Math.round(parseFloat(d) * 255).toString(16); + } + // Converts a hex value to a decimal + function convertHexToDecimal(h) { + return (parseIntFromHex(h) / 255); + } + + var matchers = (function() { + + // + var CSS_INTEGER = "[-\\+]?\\d+%?"; + + // + var CSS_NUMBER = "[-\\+]?\\d*\\.\\d+%?"; + + // Allow positive/negative integer/number. Don't capture the either/or, just the entire outcome. + var CSS_UNIT = "(?:" + CSS_NUMBER + ")|(?:" + CSS_INTEGER + ")"; + + // Actual matching. + // Parentheses and commas are optional, but not required. + // Whitespace can take the place of commas or opening paren + var PERMISSIVE_MATCH3 = "[\\s|\\(]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")\\s*\\)?"; + var PERMISSIVE_MATCH4 = "[\\s|\\(]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")\\s*\\)?"; + + return { + rgb: new RegExp("rgb" + PERMISSIVE_MATCH3), + rgba: new RegExp("rgba" + PERMISSIVE_MATCH4), + hsl: new RegExp("hsl" + PERMISSIVE_MATCH3), + hsla: new RegExp("hsla" + PERMISSIVE_MATCH4), + hsv: new RegExp("hsv" + PERMISSIVE_MATCH3), + hex3: /^([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/, + hex6: /^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/, + hex8: /^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/ + }; + })(); + + // `stringInputToObject` + // Permissive string parsing. Take in a number of formats, and output an object + // based on detected format. Returns `{ r, g, b }` or `{ h, s, l }` or `{ h, s, v}` + function stringInputToObject(color) { + + color = color.replace(trimLeft,'').replace(trimRight, '').toLowerCase(); + var named = false; + if (names[color]) { + color = names[color]; + named = true; + } + else if (color == 'transparent') { + return { r: 0, g: 0, b: 0, a: 0, format: "name" }; + } + + // Try to match string input using regular expressions. + // Keep most of the number bounding out of this function - don't worry about [0,1] or [0,100] or [0,360] + // Just return an object and let the conversion functions handle that. + // This way the result will be the same whether the tinycolor is initialized with string or object. + var match; + if ((match = matchers.rgb.exec(color))) { + return { r: match[1], g: match[2], b: match[3] }; + } + if ((match = matchers.rgba.exec(color))) { + return { r: match[1], g: match[2], b: match[3], a: match[4] }; + } + if ((match = matchers.hsl.exec(color))) { + return { h: match[1], s: match[2], l: match[3] }; + } + if ((match = matchers.hsla.exec(color))) { + return { h: match[1], s: match[2], l: match[3], a: match[4] }; + } + if ((match = matchers.hsv.exec(color))) { + return { h: match[1], s: match[2], v: match[3] }; + } + if ((match = matchers.hex8.exec(color))) { + return { + a: convertHexToDecimal(match[1]), + r: parseIntFromHex(match[2]), + g: parseIntFromHex(match[3]), + b: parseIntFromHex(match[4]), + format: named ? "name" : "hex8" + }; + } + if ((match = matchers.hex6.exec(color))) { + return { + r: parseIntFromHex(match[1]), + g: parseIntFromHex(match[2]), + b: parseIntFromHex(match[3]), + format: named ? "name" : "hex" + }; + } + if ((match = matchers.hex3.exec(color))) { + return { + r: parseIntFromHex(match[1] + '' + match[1]), + g: parseIntFromHex(match[2] + '' + match[2]), + b: parseIntFromHex(match[3] + '' + match[3]), + format: named ? "name" : "hex" + }; + } + + return false; + } + + // Node: Export function + if (typeof module !== "undefined" && module.exports) { + module.exports = tinycolor; + } + // AMD/requirejs: Define the module + else if (typeof define === 'function' && define.amd) { + define(function () {return tinycolor;}); + } + // Browser: Expose to window + else { + window.tinycolor = tinycolor; + } + +})(); \ No newline at end of file diff --git a/assets/webconfig/manifest.json b/assets/webconfig/manifest.json new file mode 100644 index 0000000..c15d41a --- /dev/null +++ b/assets/webconfig/manifest.json @@ -0,0 +1,32 @@ +{ + "name": "hyperion remote control", + "short_name": "hyperion remote", + "description": "Client side app for controlling the hyperion server over the local network.", + "author": { + "name": "Daniel Wiese", + "email": "gamadril.dev@gmail.com" + }, + "manifest_version": 2, + "version": "0.6.0", + "minimum_chrome_version": "36", + "permissions": [ + "storage", "system.network" + ], + "app": { + "background": { + "scripts": ["js/background.js"] + } + }, + "sockets": { + "tcp": { + "connect": "*" + }, + "udp": { + "send": "*", + "bind": "*" + } + }, + "icons": { + "128": "res/icon_128.png" + } +} diff --git a/assets/webconfig/manifest.webapp b/assets/webconfig/manifest.webapp new file mode 100644 index 0000000..8acc29e --- /dev/null +++ b/assets/webconfig/manifest.webapp @@ -0,0 +1,14 @@ +{ + "name": "hyperion remote control", + "description": "Client side app for controlling the hyperion server over the local network.", + "launch_path": "/hyperion-remote/index.html", + "icons": { + "128": "/hyperion-remote/res/icon_128.png" + }, + "developer": { + "name": "Daniel Wiese", + "email": "gamadril.dev@gmail.com", + "url": "https://github.com/Gamadril" + }, + "default_locale": "en" +} \ No newline at end of file diff --git a/assets/webconfig/res/colorwheel.png b/assets/webconfig/res/colorwheel.png new file mode 100644 index 0000000..1db8ad5 Binary files /dev/null and b/assets/webconfig/res/colorwheel.png differ diff --git a/assets/webconfig/res/fontello.ttf b/assets/webconfig/res/fontello.ttf new file mode 100644 index 0000000..00330dc Binary files /dev/null and b/assets/webconfig/res/fontello.ttf differ diff --git a/assets/webconfig/res/fontello.woff b/assets/webconfig/res/fontello.woff new file mode 100644 index 0000000..f5cf5dd Binary files /dev/null and b/assets/webconfig/res/fontello.woff differ diff --git a/assets/webconfig/res/icon_128.png b/assets/webconfig/res/icon_128.png new file mode 100644 index 0000000..a8caaf4 Binary files /dev/null and b/assets/webconfig/res/icon_128.png differ diff --git a/cmake/debian/postinst b/cmake/debian/postinst index 8413632..3723458 100644 --- a/cmake/debian/postinst +++ b/cmake/debian/postinst @@ -20,7 +20,7 @@ install_file() echo "--- hyperion ambilight postinstall ---" echo "- install configuration template" mkdir -p /etc/hyperion -install_file /usr/share/hyperion/config/hyperion.config.json /etc/hyperion/hyperion.config.json +install_file /usr/share/hyperion/config/hyperion.config.json.example /etc/hyperion/hyperion.config.json HYPERION_RUNNING=false diff --git a/config/hyperion.config.json.example b/config/hyperion.config.json.example index de046db..c9ce0e9 100644 --- a/config/hyperion.config.json.example +++ b/config/hyperion.config.json.example @@ -42,7 +42,7 @@ /// tuning parameters: /// - 'saturationGain' The gain adjustement of the saturation /// - 'luminanceGain' The gain adjustement of the luminance - /// - 'luminanceMinimum' The minimum luminance (backlight) + /// - 'luminanceMinimum' The minimum luminance (backlight) /// * 'red'/'green'/'blue' : The manipulation in the Red-Green-Blue color domain with the /// following tuning parameters for each channel: /// - 'threshold' The minimum required input value for the channel to be on @@ -65,21 +65,21 @@ "leds" : "*", "pureRed" : { - "redChannel" : 255, - "greenChannel" : 0, - "blueChannel" : 0 + "redChannel" : 255, + "greenChannel" : 0, + "blueChannel" : 0 }, "pureGreen" : { - "redChannel" : 0, - "greenChannel" : 255, - "blueChannel" : 0 + "redChannel" : 0, + "greenChannel" : 255, + "blueChannel" : 0 }, "pureBlue" : { - "redChannel" : 0, - "greenChannel" : 0, - "blueChannel" : 255 + "redChannel" : 0, + "greenChannel" : 0, + "blueChannel" : 255 } } ], @@ -90,9 +90,9 @@ "leds" : "*", "temperatureValues" : { - "red" : 255, - "green" : 255, - "blue" : 255 + "red" : 255, + "green" : 255, + "blue" : 255 } } ], @@ -103,24 +103,24 @@ "leds" : "*", "hsl" : { - "saturationGain" : 1.0000, - "luminanceGain" : 1.0000, - "luminanceMinimum" : 0.0000 + "saturationGain" : 1.0000, + "luminanceGain" : 1.0000, + "luminanceMinimum" : 0.0000 }, "red" : { - "threshold" : 0.0000, - "gamma" : 2.5000 + "threshold" : 0.0000, + "gamma" : 2.5000 }, "green" : { - "threshold" : 0.0000, - "gamma" : 2.5000 + "threshold" : 0.0000, + "gamma" : 2.5000 }, "blue" : { - "threshold" : 0.0000, - "gamma" : 2.5000 + "threshold" : 0.0000, + "gamma" : 2.5000 } } ], @@ -135,14 +135,14 @@ }, /// The black border configuration, contains the following items: - /// * enable : true if the detector should be activated - /// * threshold : Value below which a pixel is regarded as black (value between 0.0 and 1.0) - /// * unknownFrameCnt : Number of frames without any detection before the border is set to 0 (default 600) - /// * borderFrameCnt : Number of frames before a consistent detected border gets set (default 50) - /// * maxInconsistentCnt : Number of inconsistent frames that are ignored before a new border gets a chance to proof consistency - /// * blurRemoveCnt : Number of pixels that get removed from the detected border to cut away blur (default 1) - /// * mode : Border detection mode (values=default,classic,osd) - "blackborderdetector" : + /// * enable : true if the detector should be activated + /// * threshold : Value below which a pixel is regarded as black (value between 0.0 and 1.0) + /// * unknownFrameCnt : Number of frames without any detection before the border is set to 0 (default 600) + /// * borderFrameCnt : Number of frames before a consistent detected border gets set (default 50) + /// * maxInconsistentCnt : Number of inconsistent frames that are ignored before a new border gets a chance to proof consistency + /// * blurRemoveCnt : Number of pixels that get removed from the detected border to cut away blur (default 1) + /// * mode : Border detection mode (values=default,classic,osd) + "blackborderdetector" : { "enable" : true, "threshold" : 0.0, @@ -154,19 +154,23 @@ }, /// The configuration of the effect engine, contains the following items: - /// * paths : An array with absolute location(s) of directories with effects - /// * color : Set static color after boot -> set effect to "" (empty) and input the values [R,G,B] and set duration_ms NOT to 0 (use 1) instead - /// * effect : The effect selected as 'boot sequence' - /// * duration_ms : The duration of the selected effect (0=endless) - /// * priority : The priority of the selected effect/static color (default=990) HINT: lower value result in HIGHER priority! + /// * paths : An array with absolute/relative location(s) of directories with effects "effects" : { - "paths" : + "paths" : [ - "/opt/hyperion/effects" + "/usr/share/hyperion/effects" ] }, + /// Boot sequence configuration. Start effect / set color at startup of hyperion + /// HINT inital background color is not shown, when any other grabber is active + /// * color : Set initial background color on startup -> set effect to "" (empty) and input the values [R,G,B] and set duration_ms NOT to 0 (use 1) instead + /// * effect : The effect is shown when hyperion starts + /// * duration_ms : The duration of the selected effect (0=endless) + /// * priority : The priority of the selected effect/initial background color (default=990, if duration is 0) + /// when duration > 0 => priority is set to 0, otherwise priority is set to configured value + /// HINT: lower value result in HIGHER priority! "bootsequence" : { "color" : [0,0,0], @@ -175,12 +179,23 @@ "priority" : 990 }, + /// Configuration of webserver integrated in hyperion. + /// * enable : enable the server or not + /// * document_root : path to hyperion webapp files + /// * port : the port where hyperion webapp is accasible + "webConfig" : + { + "enable" : true, + "document_root" : "/usr/share/hyperion/webconfig", + "port" : 8080 + }, + /// The configuration of the Json/Proto forwarder. Forward messages to multiple instances of Hyperion on same and/or other hosts /// 'proto' is mostly used for video streams and 'json' for effects - /// * proto : Proto server adress and port of your target. Syntax:[IP:PORT] -> ["127.0.0.1:19447"] or more instances to forward ["127.0.0.1:19447","192.168.0.24:19449"] - /// * json : Json server adress and port of your target. Syntax:[IP:PORT] -> ["127.0.0.1:19446"] or more instances to forward ["127.0.0.1:19446","192.168.0.24:19448"] - /// HINT: If you redirect to "127.0.0.1" (localhost) you could start a second hyperion with another device/led config! - /// Be sure your client(s) is/are listening on the configured ports. The second Hyperion (if used) also needs to be configured! (HyperCon -> External -> Json Server/Proto Server) + /// * proto : Proto server adress and port of your target. Syntax:[IP:PORT] -> ["127.0.0.1:19447"] or more instances to forward ["127.0.0.1:19447","192.168.0.24:19449"] + /// * json : Json server adress and port of your target. Syntax:[IP:PORT] -> ["127.0.0.1:19446"] or more instances to forward ["127.0.0.1:19446","192.168.0.24:19448"] + /// HINT:If you redirect to "127.0.0.1" (localhost) you could start a second hyperion with another device/led config! + /// Be sure your client(s) is/are listening on the configured ports. The second Hyperion (if used) also needs to be configured! (HyperCon -> External -> Json Server/Proto Server) "forwarder" : { "proto" : ["127.0.0.1:19447"], @@ -226,31 +241,31 @@ /// The configuration of the Json server which enables the json remote interface /// * port : Port at which the json server is started - "jsonServer" : + "jsonServer" : { "port" : 19444 }, /// The configuration of the Proto server which enables the protobuffer remote interface /// * port : Port at which the protobuffer server is started - "protoServer" : + "protoServer" : { "port" : 19445 }, /// The configuration of the boblight server which enables the boblight remote interface - /// * port : Port at which the boblight server is started - /// * priority: Priority of the boblight server (Default=900) HINT: lower value result in HIGHER priority! - "boblightServer" : + /// * port : Port at which the boblight server is started + /// * priority : Priority of the boblight server (Default=900) HINT: lower value result in HIGHER priority! + "boblightServer" : { "port" : 19333, "priority" : 900 }, /// Configuration for the embedded V4L2 grabber - /// * device : V4L2 Device to use [default="/dev/video0"] - /// * input : V4L2 input to use [default=0] - /// * standard : Video standard (no-change/PAL/NTSC) [default="no-change"] + /// * device : V4L2 Device to use [default="/dev/video0"] + /// * input : V4L2 input to use [default=0] + /// * standard : Video standard (no-change/PAL/NTSC) [default="no-change"] /// * width : V4L2 width to set [default=-1] /// * height : V4L2 height to set [default=-1] /// * frameDecimation : Frame decimation factor [default=2] @@ -264,25 +279,25 @@ /// * redSignalThreshold : Signal threshold for the red channel between 0.0 and 1.0 [default=0.0] /// * greenSignalThreshold : Signal threshold for the green channel between 0.0 and 1.0 [default=0.0] /// * blueSignalThreshold : Signal threshold for the blue channel between 0.0 and 1.0 [default=0.0] - "grabber-v4l2" : - { - "device" : "/dev/video0", - "input" : 0, - "standard" : "no-change", - "width" : -1, - "height" : -1, - "frameDecimation" : 2, - "sizeDecimation" : 8, - "priority" : 900, - "mode" : "2D", - "cropLeft" : 0, - "cropRight" : 0, - "cropTop" : 0, - "cropBottom" : 0, - "redSignalThreshold" : 0.0, - "greenSignalThreshold" : 0.0, - "blueSignalThreshold" : 0.0 - }, + "grabber-v4l2" : + { + "device" : "/dev/video0", + "input" : 0, + "standard" : "no-change", + "width" : -1, + "height" : -1, + "frameDecimation" : 2, + "sizeDecimation" : 8, + "priority" : 900, + "mode" : "2D", + "cropLeft" : 0, + "cropRight" : 0, + "cropTop" : 0, + "cropBottom" : 0, + "redSignalThreshold" : 0.0, + "greenSignalThreshold" : 0.0, + "blueSignalThreshold" : 0.0 + }, /// The configuration for each individual led. This contains the specification of the area /// averaged of an input image for each led to determine its color. Each item in the list @@ -293,7 +308,7 @@ /// (minimum and maximum inclusive) /// * vscan: The fractional part of the image along the vertical used for the averaging /// (minimum and maximum inclusive) - "leds" : + "leds" : [ { "index" : 0, diff --git a/include/webconfig/WebConfig.h b/include/webconfig/WebConfig.h new file mode 100644 index 0000000..eba244c --- /dev/null +++ b/include/webconfig/WebConfig.h @@ -0,0 +1,27 @@ +#ifndef WEBCONFIG_H +#define WEBCONFIG_H + +#include +#include + +class StaticFileServing; + +class WebConfig : public QObject { + Q_OBJECT + +public: + explicit WebConfig (std::string baseUrl, quint16 port, QObject * parent = NULL); + virtual ~WebConfig (void); + + void start(); + void stop(); + +private: + QObject * _parent; + QString _baseUrl; + quint16 _port; + StaticFileServing * _server; +}; + +#endif // WEBCONFIG_H + diff --git a/libsrc/CMakeLists.txt b/libsrc/CMakeLists.txt index d6b1f04..cf9a383 100644 --- a/libsrc/CMakeLists.txt +++ b/libsrc/CMakeLists.txt @@ -19,3 +19,7 @@ add_subdirectory(utils) add_subdirectory(xbmcvideochecker) add_subdirectory(effectengine) add_subdirectory(grabber) + +if(ENABLE_QT5) + add_subdirectory(webconfig) +endif() diff --git a/libsrc/boblightserver/BoblightClientConnection.cpp b/libsrc/boblightserver/BoblightClientConnection.cpp index a1c09c8..d272513 100644 --- a/libsrc/boblightserver/BoblightClientConnection.cpp +++ b/libsrc/boblightserver/BoblightClientConnection.cpp @@ -17,6 +17,7 @@ #include "hyperion/ImageProcessorFactory.h" #include "hyperion/ImageProcessor.h" #include "utils/ColorRgb.h" +#include "HyperionConfig.h" // project includes #include "BoblightClientConnection.h" diff --git a/libsrc/effectengine/EffectEngine.cpp b/libsrc/effectengine/EffectEngine.cpp index c5a554c..7e1ca68 100644 --- a/libsrc/effectengine/EffectEngine.cpp +++ b/libsrc/effectengine/EffectEngine.cpp @@ -16,6 +16,7 @@ // effect engine includes #include #include "Effect.h" +#include "HyperionConfig.h" EffectEngine::EffectEngine(Hyperion * hyperion, const Json::Value & jsonEffectConfig) : _hyperion(hyperion), diff --git a/libsrc/webconfig/CMakeLists.txt b/libsrc/webconfig/CMakeLists.txt new file mode 100644 index 0000000..25abbbb --- /dev/null +++ b/libsrc/webconfig/CMakeLists.txt @@ -0,0 +1,53 @@ + +# Define the current source locations +set(CURRENT_HEADER_DIR ${CMAKE_SOURCE_DIR}/include/webconfig) +set(CURRENT_SOURCE_DIR ${CMAKE_SOURCE_DIR}/libsrc/webconfig) + +# Group the headers that go through the MOC compiler +set(WebConfig_QT_HEADERS + ${CURRENT_SOURCE_DIR}/QtHttpClientWrapper.h + ${CURRENT_SOURCE_DIR}/QtHttpHeader.h + ${CURRENT_SOURCE_DIR}/QtHttpReply.h + ${CURRENT_SOURCE_DIR}/QtHttpRequest.h + ${CURRENT_SOURCE_DIR}/QtHttpServer.h + ${CURRENT_SOURCE_DIR}/StaticFileServing.h + ${CURRENT_HEADER_DIR}/WebConfig.h +) + +set(WebConfig_HEADERS +) + +set(WebConfig_SOURCES + ${CURRENT_SOURCE_DIR}/QtHttpClientWrapper.cpp + ${CURRENT_SOURCE_DIR}/QtHttpHeader.cpp + ${CURRENT_SOURCE_DIR}/QtHttpReply.cpp + ${CURRENT_SOURCE_DIR}/QtHttpRequest.cpp + ${CURRENT_SOURCE_DIR}/QtHttpServer.cpp + ${CURRENT_SOURCE_DIR}/StaticFileServing.cpp + ${CURRENT_SOURCE_DIR}/WebConfig.cpp +) + +if(ENABLE_QT5) + qt5_wrap_cpp(WebConfig_HEADERS_MOC ${WebConfig_QT_HEADERS}) +else() + qt4_wrap_cpp(WebConfigr_HEADERS_MOC ${WebConfig_QT_HEADERS}) +endif() + +add_library(webconfig + ${WebConfig_HEADERS} + ${WebConfig_QT_HEADERS} + ${WebConfig_SOURCES} + ${WebConfig_HEADERS_MOC} +) + +if(ENABLE_QT5) + qt5_use_modules(webconfig Widgets Network) +endif() + +target_link_libraries(webconfig + hyperion + hyperion-utils + ${QT_LIBRARIES} +) + + diff --git a/libsrc/webconfig/QtHttpClientWrapper.cpp b/libsrc/webconfig/QtHttpClientWrapper.cpp new file mode 100644 index 0000000..6e88fb4 --- /dev/null +++ b/libsrc/webconfig/QtHttpClientWrapper.cpp @@ -0,0 +1,221 @@ + +#include "QtHttpClientWrapper.h" +#include "QtHttpRequest.h" +#include "QtHttpReply.h" +#include "QtHttpServer.h" +#include "QtHttpHeader.h" + +#include +#include +#include +#include +#include + +const QByteArray & QtHttpClientWrapper::CRLF = QByteArrayLiteral ("\r\n"); + +QtHttpClientWrapper::QtHttpClientWrapper (QTcpSocket * sock, QtHttpServer * parent) + : QObject (parent) + , m_guid ("") + , m_parsingStatus (AwaitingRequest) + , m_sockClient (sock) + , m_currentRequest (Q_NULLPTR) + , m_serverHandle (parent) +{ + connect (m_sockClient, &QTcpSocket::readyRead, this, &QtHttpClientWrapper::onClientDataReceived); +} + +QString QtHttpClientWrapper::getGuid (void) { + if (m_guid.isEmpty ()) { + m_guid = QString::fromLocal8Bit ( + QCryptographicHash::hash ( + QByteArray::number ((quint64) (this)), + QCryptographicHash::Md5 + ).toHex () + ); + } + return m_guid; +} + +void QtHttpClientWrapper::onClientDataReceived (void) { + if (m_sockClient != Q_NULLPTR) { + while (m_sockClient->bytesAvailable ()) { + QByteArray line = m_sockClient->readLine (); + switch (m_parsingStatus) { // handle parsing steps + case AwaitingRequest: { // "command url version" × 1 + QString str = QString::fromUtf8 (line).trimmed (); + QStringList parts = str.split (SPACE, QString::SkipEmptyParts); + if (parts.size () == 3) { + QString command = parts.at (0); + QString url = parts.at (1); + QString version = parts.at (2); + if (version == QtHttpServer::HTTP_VERSION) { + //qDebug () << "Debug : HTTP" + // << "command :" << command + // << "url :" << url + // << "version :" << version; + m_currentRequest = new QtHttpRequest (m_serverHandle); + m_currentRequest->setUrl (QUrl (url)); + m_currentRequest->setCommand (command); + m_parsingStatus = AwaitingHeaders; + } + else { + m_parsingStatus = ParsingError; + //qWarning () << "Error : unhandled HTTP version :" << version; + } + } + else { + m_parsingStatus = ParsingError; + //qWarning () << "Error : incorrect HTTP command line :" << line; + } + break; + } + case AwaitingHeaders: { // "header: value" × N (until empty line) + QByteArray raw = line.trimmed (); + if (!raw.isEmpty ()) { // parse headers + int pos = raw.indexOf (COLON); + if (pos > 0) { + QByteArray header = raw.left (pos).trimmed (); + QByteArray value = raw.mid (pos +1).trimmed (); + //qDebug () << "Debug : HTTP" + // << "header :" << header + // << "value :" << value; + m_currentRequest->addHeader (header, value); + if (header == QtHttpHeader::ContentLength) { + int len = -1; + bool ok = false; + len = value.toInt (&ok, 10); + if (ok) { + m_currentRequest->addHeader (QtHttpHeader::ContentLength, QByteArray::number (len)); + } + } + } + else { + m_parsingStatus = ParsingError; + qWarning () << "Error : incorrect HTTP headers line :" << line; + } + } + else { // end of headers + //qDebug () << "Debug : HTTP end of headers"; + if (m_currentRequest->getHeader (QtHttpHeader::ContentLength).toInt () > 0) { + m_parsingStatus = AwaitingContent; + } + else { + m_parsingStatus = RequestParsed; + } + } + break; + } + case AwaitingContent: { // raw data × N (until EOF ??) + m_currentRequest->appendRawData (line); + //qDebug () << "Debug : HTTP" + // << "content :" << m_currentRequest->getRawData ().toHex () + // << "size :" << m_currentRequest->getRawData ().size (); + if (m_currentRequest->getRawDataSize () == m_currentRequest->getHeader (QtHttpHeader::ContentLength).toInt ()) { + //qDebug () << "Debug : HTTP end of content"; + m_parsingStatus = RequestParsed; + } + break; + } + default: { break; } + } + switch (m_parsingStatus) { // handle parsing status end/error + case RequestParsed: { // a valid request has ben fully parsed + QtHttpReply reply (m_serverHandle); + connect (&reply, &QtHttpReply::requestSendHeaders, + this, &QtHttpClientWrapper::onReplySendHeadersRequested); + connect (&reply, &QtHttpReply::requestSendData, + this, &QtHttpClientWrapper::onReplySendDataRequested); + emit m_serverHandle->requestNeedsReply (m_currentRequest, &reply); // allow app to handle request + m_parsingStatus = sendReplyToClient (&reply); + break; + } + case ParsingError: { // there was an error durin one of parsing steps + m_sockClient->readAll (); // clear remaining buffer to ignore content + QtHttpReply reply (m_serverHandle); + reply.setStatusCode (QtHttpReply::BadRequest); + reply.appendRawData (QByteArrayLiteral ("

    Bad Request (HTTP parsing error) !

    ")); + reply.appendRawData (CRLF); + m_parsingStatus = sendReplyToClient (&reply); + break; + } + default: { break; } + } + } + } +} + +void QtHttpClientWrapper::onReplySendHeadersRequested (void) { + QtHttpReply * reply = qobject_cast (sender ()); + if (reply != Q_NULLPTR) { + QByteArray data; + // HTTP Version + Status Code + Status Msg + data.append (QtHttpServer::HTTP_VERSION); + data.append (SPACE); + data.append (QByteArray::number (reply->getStatusCode ())); + data.append (SPACE); + data.append (QtHttpReply::getStatusTextForCode (reply->getStatusCode ())); + data.append (CRLF); + // Header name: header value + if (reply->useChunked ()) { + static const QByteArray & CHUNKED = QByteArrayLiteral ("chunked"); + reply->addHeader (QtHttpHeader::TransferEncoding, CHUNKED); + } + else { + reply->addHeader (QtHttpHeader::ContentLength, QByteArray::number (reply->getRawDataSize ())); + } + const QList & headersList = reply->getHeadersList (); + foreach (const QByteArray & header, headersList) { + data.append (header); + data.append (COLON); + data.append (SPACE); + data.append (reply->getHeader (header)); + data.append (CRLF); + } + // empty line + data.append (CRLF); + m_sockClient->write (data); + m_sockClient->flush (); + } +} + +void QtHttpClientWrapper::onReplySendDataRequested (void) { + QtHttpReply * reply = qobject_cast (sender ()); + if (reply != Q_NULLPTR) { + // content raw data + QByteArray data = reply->getRawData (); + if (reply->useChunked ()) { + data.prepend (QByteArray::number (data.size (), 16) % CRLF); + data.append (CRLF); + reply->resetRawData (); + } + // write to socket + m_sockClient->write (data); + m_sockClient->flush (); + } +} + +QtHttpClientWrapper::ParsingStatus QtHttpClientWrapper::sendReplyToClient (QtHttpReply * reply) { + if (reply != Q_NULLPTR) { + if (!reply->useChunked ()) { + reply->appendRawData (CRLF); + // send all headers and all data in one shot + reply->requestSendHeaders (); + reply->requestSendData (); + } + else { + // last chunk + m_sockClient->write ("0" % CRLF % CRLF); + m_sockClient->flush (); + } + if (m_currentRequest != Q_NULLPTR) { + static const QByteArray & CLOSE = QByteArrayLiteral ("close"); + if (m_currentRequest->getHeader (QtHttpHeader::Connection).toLower () == CLOSE) { + // must close connection after this request + m_sockClient->close (); + } + m_currentRequest->deleteLater (); + m_currentRequest = Q_NULLPTR; + } + } + return AwaitingRequest; +} diff --git a/libsrc/webconfig/QtHttpClientWrapper.h b/libsrc/webconfig/QtHttpClientWrapper.h new file mode 100644 index 0000000..ae68a32 --- /dev/null +++ b/libsrc/webconfig/QtHttpClientWrapper.h @@ -0,0 +1,51 @@ +#ifndef QTHTTPCLIENTWRAPPER_H +#define QTHTTPCLIENTWRAPPER_H + +#include +#include + +class QTcpSocket; + +class QtHttpRequest; +class QtHttpReply; +class QtHttpServer; + +class QtHttpClientWrapper : public QObject { + Q_OBJECT + +public: + explicit QtHttpClientWrapper (QTcpSocket * sock, QtHttpServer * parent); + + static const char SPACE = ' '; + static const char COLON = ':'; + static const QByteArray & CRLF; + + enum ParsingStatus { + ParsingError = -1, + AwaitingRequest = 0, + AwaitingHeaders = 1, + AwaitingContent = 2, + RequestParsed = 3 + }; + + QString getGuid (void); + +private slots: + void onClientDataReceived (void); + +protected: + ParsingStatus sendReplyToClient (QtHttpReply * reply); + +protected slots: + void onReplySendHeadersRequested (void); + void onReplySendDataRequested (void); + +private: + QString m_guid; + ParsingStatus m_parsingStatus; + QTcpSocket * m_sockClient; + QtHttpRequest * m_currentRequest; + QtHttpServer * m_serverHandle; +}; + +#endif // QTHTTPCLIENTWRAPPER_H diff --git a/libsrc/webconfig/QtHttpHeader.cpp b/libsrc/webconfig/QtHttpHeader.cpp new file mode 100644 index 0000000..c3f4eb5 --- /dev/null +++ b/libsrc/webconfig/QtHttpHeader.cpp @@ -0,0 +1,32 @@ + +#include "QtHttpHeader.h" + +#include + +const QByteArray & QtHttpHeader::Server = QByteArrayLiteral ("Server"); +const QByteArray & QtHttpHeader::Date = QByteArrayLiteral ("Date"); +const QByteArray & QtHttpHeader::Host = QByteArrayLiteral ("Host"); +const QByteArray & QtHttpHeader::Accept = QByteArrayLiteral ("Accept"); +const QByteArray & QtHttpHeader::Cookie = QByteArrayLiteral ("Cookie"); +const QByteArray & QtHttpHeader::ContentType = QByteArrayLiteral ("Content-Type"); +const QByteArray & QtHttpHeader::ContentLength = QByteArrayLiteral ("Content-Length"); +const QByteArray & QtHttpHeader::Connection = QByteArrayLiteral ("Connection"); +const QByteArray & QtHttpHeader::UserAgent = QByteArrayLiteral ("User-Agent"); +const QByteArray & QtHttpHeader::AcceptCharset = QByteArrayLiteral ("Accept-Charset"); +const QByteArray & QtHttpHeader::AcceptEncoding = QByteArrayLiteral ("Accept-Encoding"); +const QByteArray & QtHttpHeader::AcceptLanguage = QByteArrayLiteral ("Accept-Language"); +const QByteArray & QtHttpHeader::Authorization = QByteArrayLiteral ("Authorization"); +const QByteArray & QtHttpHeader::CacheControl = QByteArrayLiteral ("Cache-Control"); +const QByteArray & QtHttpHeader::ContentMD5 = QByteArrayLiteral ("Content-MD5"); +const QByteArray & QtHttpHeader::ProxyAuthorization = QByteArrayLiteral ("Proxy-Authorization"); +const QByteArray & QtHttpHeader::Range = QByteArrayLiteral ("Range"); +const QByteArray & QtHttpHeader::ContentEncoding = QByteArrayLiteral ("Content-Encoding"); +const QByteArray & QtHttpHeader::ContentLanguage = QByteArrayLiteral ("Content-Language"); +const QByteArray & QtHttpHeader::ContentLocation = QByteArrayLiteral ("Content-Location"); +const QByteArray & QtHttpHeader::ContentRange = QByteArrayLiteral ("Content-Range"); +const QByteArray & QtHttpHeader::Expires = QByteArrayLiteral ("Expires"); +const QByteArray & QtHttpHeader::LastModified = QByteArrayLiteral ("Last-Modified"); +const QByteArray & QtHttpHeader::Location = QByteArrayLiteral ("Location"); +const QByteArray & QtHttpHeader::SetCookie = QByteArrayLiteral ("Set-Cookie"); +const QByteArray & QtHttpHeader::TransferEncoding = QByteArrayLiteral ("Transfer-Encoding"); +const QByteArray & QtHttpHeader::ContentDisposition = QByteArrayLiteral ("Content-Disposition"); diff --git a/libsrc/webconfig/QtHttpHeader.h b/libsrc/webconfig/QtHttpHeader.h new file mode 100644 index 0000000..9728414 --- /dev/null +++ b/libsrc/webconfig/QtHttpHeader.h @@ -0,0 +1,37 @@ +#ifndef QTHTTPHEADER_H +#define QTHTTPHEADER_H + +class QByteArray; + +class QtHttpHeader { +public: + static const QByteArray & Server; + static const QByteArray & Date; + static const QByteArray & Host; + static const QByteArray & Accept; + static const QByteArray & ContentType; + static const QByteArray & ContentLength; + static const QByteArray & Connection; + static const QByteArray & Cookie; + static const QByteArray & UserAgent; + static const QByteArray & AcceptCharset; + static const QByteArray & AcceptEncoding; + static const QByteArray & AcceptLanguage; + static const QByteArray & Authorization; + static const QByteArray & CacheControl; + static const QByteArray & ContentMD5; + static const QByteArray & ProxyAuthorization; + static const QByteArray & Range; + static const QByteArray & ContentEncoding; + static const QByteArray & ContentLanguage; + static const QByteArray & ContentLocation; + static const QByteArray & ContentRange; + static const QByteArray & Expires; + static const QByteArray & LastModified; + static const QByteArray & Location; + static const QByteArray & SetCookie; + static const QByteArray & TransferEncoding; + static const QByteArray & ContentDisposition; +}; + +#endif // QTHTTPHEADER_H diff --git a/libsrc/webconfig/QtHttpReply.cpp b/libsrc/webconfig/QtHttpReply.cpp new file mode 100644 index 0000000..f33e398 --- /dev/null +++ b/libsrc/webconfig/QtHttpReply.cpp @@ -0,0 +1,75 @@ + +#include "QtHttpReply.h" +#include "QtHttpHeader.h" +#include "QtHttpServer.h" + +#include + +QtHttpReply::QtHttpReply (QtHttpServer * parent) + : QObject (parent) + , m_useChunked (false) + , m_statusCode (Ok) + , m_data (QByteArray ()) + , m_serverHandle (parent) +{ + // set some additional headers + addHeader (QtHttpHeader::Date, QDateTime::currentDateTimeUtc ().toString ("ddd, dd MMM yyyy hh:mm:ss t").toUtf8 ()); + addHeader (QtHttpHeader::Server, m_serverHandle->getServerName ().toUtf8 ()); +} + +int QtHttpReply::getRawDataSize (void) const { + return m_data.size (); +} + +bool QtHttpReply::useChunked (void) const { + return m_useChunked; +} + +QtHttpReply::StatusCode QtHttpReply::getStatusCode (void) const { + return m_statusCode; +} + +QByteArray QtHttpReply::getRawData (void) const { + return m_data; +} + +QList QtHttpReply::getHeadersList (void) const { + return m_headersHash.keys (); +} + +QByteArray QtHttpReply::getHeader (const QByteArray & header) const { + return m_headersHash.value (header, QByteArray ()); +} + +const QByteArray QtHttpReply::getStatusTextForCode (QtHttpReply::StatusCode statusCode) { + switch (statusCode) { + case Ok: return QByteArrayLiteral ("OK."); + case BadRequest: return QByteArrayLiteral ("Bad request !"); + case Forbidden: return QByteArrayLiteral ("Forbidden !"); + case NotFound: return QByteArrayLiteral ("Not found !"); + default: return QByteArrayLiteral (""); + } +} + +void QtHttpReply::setUseChunked (bool chunked){ + m_useChunked = chunked; +} + +void QtHttpReply::setStatusCode (QtHttpReply::StatusCode statusCode) { + m_statusCode = statusCode; +} + +void QtHttpReply::appendRawData (const QByteArray & data) { + m_data.append (data); +} + +void QtHttpReply::addHeader (const QByteArray & header, const QByteArray & value) { + QByteArray key = header.trimmed (); + if (!key.isEmpty ()) { + m_headersHash.insert (key, value); + } +} + +void QtHttpReply::resetRawData (void) { + m_data.clear (); +} diff --git a/libsrc/webconfig/QtHttpReply.h b/libsrc/webconfig/QtHttpReply.h new file mode 100644 index 0000000..92b57da --- /dev/null +++ b/libsrc/webconfig/QtHttpReply.h @@ -0,0 +1,55 @@ +#ifndef QTHTTPREPLY_H +#define QTHTTPREPLY_H + +#include +#include +#include +#include + +class QtHttpServer; + +class QtHttpReply : public QObject { + Q_OBJECT + Q_ENUMS (StatusCode) + +public: + explicit QtHttpReply (QtHttpServer * parent); + + enum StatusCode { + Ok = 200, + BadRequest = 400, + Forbidden = 403, + NotFound = 404, + InternalError = 502, + }; + + int getRawDataSize (void) const; + bool useChunked (void) const; + StatusCode getStatusCode (void) const; + QByteArray getRawData (void) const; + QList getHeadersList (void) const; + + QByteArray getHeader (const QByteArray & header) const; + + static const QByteArray getStatusTextForCode (StatusCode statusCode); + +public slots: + void setUseChunked (bool chunked = false); + void setStatusCode (StatusCode statusCode); + void appendRawData (const QByteArray & data); + void addHeader (const QByteArray & header, const QByteArray & value); + void resetRawData (void); + +signals: + void requestSendHeaders (void); + void requestSendData (void); + +private: + bool m_useChunked; + StatusCode m_statusCode; + QByteArray m_data; + QtHttpServer * m_serverHandle; + QHash m_headersHash; +}; + +#endif // QTHTTPREPLY_H diff --git a/libsrc/webconfig/QtHttpRequest.cpp b/libsrc/webconfig/QtHttpRequest.cpp new file mode 100644 index 0000000..0571c5f --- /dev/null +++ b/libsrc/webconfig/QtHttpRequest.cpp @@ -0,0 +1,60 @@ + +#include "QtHttpRequest.h" +#include "QtHttpHeader.h" +#include "QtHttpServer.h" + +QtHttpRequest::QtHttpRequest (QtHttpServer * parent) + : QObject (parent) + , m_url (QUrl ()) + , m_command (QString ()) + , m_data (QByteArray ()) + , m_serverHandle (parent) +{ + // set some additional headers + addHeader (QtHttpHeader::ContentLength, QByteArrayLiteral ("0")); + addHeader (QtHttpHeader::Connection, QByteArrayLiteral ("Keep-Alive")); +} + +QUrl QtHttpRequest::getUrl (void) const { + return m_url; +} + +QString QtHttpRequest::getCommand (void) const { + return m_command; +} + +int QtHttpRequest::getRawDataSize (void) const { + return m_data.size (); +} + + +QByteArray QtHttpRequest::getRawData (void) const { + return m_data; +} + +QList QtHttpRequest::getHeadersList (void) const { + return m_headersHash.keys (); +} + +QByteArray QtHttpRequest::getHeader (const QByteArray & header) const { + return m_headersHash.value (header, QByteArray ()); +} + +void QtHttpRequest::setUrl (const QUrl & url) { + m_url = url; +} + +void QtHttpRequest::setCommand (const QString & command) { + m_command = command; +} + +void QtHttpRequest::addHeader (const QByteArray & header, const QByteArray & value) { + QByteArray key = header.trimmed (); + if (!key.isEmpty ()) { + m_headersHash.insert (key, value); + } +} + +void QtHttpRequest::appendRawData (const QByteArray & data) { + m_data.append (data); +} diff --git a/libsrc/webconfig/QtHttpRequest.h b/libsrc/webconfig/QtHttpRequest.h new file mode 100644 index 0000000..9224f1e --- /dev/null +++ b/libsrc/webconfig/QtHttpRequest.h @@ -0,0 +1,40 @@ +#ifndef QTHTTPREQUEST_H +#define QTHTTPREQUEST_H + +#include +#include +#include +#include +#include + +class QtHttpServer; + +class QtHttpRequest : public QObject { + Q_OBJECT + +public: + explicit QtHttpRequest (QtHttpServer * parent); + + int getRawDataSize (void) const; + QUrl getUrl (void) const; + QString getCommand (void) const; + QByteArray getRawData (void) const; + QList getHeadersList (void) const; + + QByteArray getHeader (const QByteArray & header) const; + +public slots: + void setUrl (const QUrl & url); + void setCommand (const QString & command); + void addHeader (const QByteArray & header, const QByteArray & value); + void appendRawData (const QByteArray & data); + +private: + QUrl m_url; + QString m_command; + QByteArray m_data; + QtHttpServer * m_serverHandle; + QHash m_headersHash; +}; + +#endif // QTHTTPREQUEST_H diff --git a/libsrc/webconfig/QtHttpServer.cpp b/libsrc/webconfig/QtHttpServer.cpp new file mode 100644 index 0000000..cf0ff33 --- /dev/null +++ b/libsrc/webconfig/QtHttpServer.cpp @@ -0,0 +1,66 @@ + +#include "QtHttpServer.h" +#include "QtHttpRequest.h" +#include "QtHttpReply.h" +#include "QtHttpClientWrapper.h" + +#include +#include +#include +#include + +const QString & QtHttpServer::HTTP_VERSION = QStringLiteral ("HTTP/1.1"); + +QtHttpServer::QtHttpServer (QObject * parent) + : QObject (parent) + , m_serverName (QStringLiteral ("The Qt5 HTTP Server")) +{ + m_sockServer = new QTcpServer (this); + connect (m_sockServer, &QTcpServer::newConnection, this, &QtHttpServer::onClientConnected); +} + +const QString QtHttpServer::getServerName (void) const { + return m_serverName; +} + +void QtHttpServer::start (quint16 port) { + if (m_sockServer->listen (QHostAddress::Any, port)) { + emit started (m_sockServer->serverPort ()); + } + else { + emit error (m_sockServer->errorString ()); + } +} + +void QtHttpServer::stop (void) { + if (m_sockServer->isListening ()) { + m_sockServer->close (); + emit stopped (); + } +} + +void QtHttpServer::setServerName (const QString & serverName) { + m_serverName = serverName; +} + +void QtHttpServer::onClientConnected (void) { + while (m_sockServer->hasPendingConnections ()) { + QTcpSocket * sockClient = m_sockServer->nextPendingConnection (); + QtHttpClientWrapper * wrapper = new QtHttpClientWrapper (sockClient, this); + connect (sockClient, &QTcpSocket::disconnected, this, &QtHttpServer::onClientDisconnected); + m_socksClientsHash.insert (sockClient, wrapper); + emit clientConnected (wrapper->getGuid ()); + } +} + +void QtHttpServer::onClientDisconnected (void) { + QTcpSocket * sockClient = qobject_cast (sender ()); + if (sockClient) { + QtHttpClientWrapper * wrapper = m_socksClientsHash.value (sockClient, Q_NULLPTR); + if (wrapper) { + emit clientDisconnected (wrapper->getGuid ()); + wrapper->deleteLater (); + m_socksClientsHash.remove (sockClient); + } + } +} diff --git a/libsrc/webconfig/QtHttpServer.h b/libsrc/webconfig/QtHttpServer.h new file mode 100644 index 0000000..71a6261 --- /dev/null +++ b/libsrc/webconfig/QtHttpServer.h @@ -0,0 +1,48 @@ +#ifndef QTHTTPSERVER_H +#define QTHTTPSERVER_H + +#include +#include +#include + +class QTcpSocket; +class QTcpServer; + +class QtHttpRequest; +class QtHttpReply; +class QtHttpClientWrapper; + +class QtHttpServer : public QObject { + Q_OBJECT + +public: + explicit QtHttpServer (QObject * parent = Q_NULLPTR); + + static const QString & HTTP_VERSION; + + const QString getServerName (void) const; + +public slots: + void start (quint16 port = 0); + void stop (void); + void setServerName (const QString & serverName); + +signals: + void started (quint16 port); + void stopped (void); + void error (const QString & msg); + void clientConnected (const QString & guid); + void clientDisconnected (const QString & guid); + void requestNeedsReply (QtHttpRequest * request, QtHttpReply * reply); + +private slots: + void onClientConnected (void); + void onClientDisconnected (void); + +private: + QString m_serverName; + QTcpServer * m_sockServer; + QHash m_socksClientsHash; +}; + +#endif // QTHTTPSERVER_H diff --git a/libsrc/webconfig/StaticFileServing.cpp b/libsrc/webconfig/StaticFileServing.cpp new file mode 100644 index 0000000..9a1c91b --- /dev/null +++ b/libsrc/webconfig/StaticFileServing.cpp @@ -0,0 +1,87 @@ + +#include "StaticFileServing.h" + +#include +#include +#include +#include +#include +#include + +StaticFileServing::StaticFileServing (QString baseUrl, quint16 port, QObject * parent) + : QObject (parent) + , m_baseUrl (baseUrl) +{ + m_mimeDb = new QMimeDatabase; + + m_server = new QtHttpServer (this); + m_server->setServerName (QStringLiteral ("Qt Static HTTP File Server")); + + connect (m_server, &QtHttpServer::started, this, &StaticFileServing::onServerStarted); + connect (m_server, &QtHttpServer::stopped, this, &StaticFileServing::onServerStopped); + connect (m_server, &QtHttpServer::error, this, &StaticFileServing::onServerError); + connect (m_server, &QtHttpServer::requestNeedsReply, this, &StaticFileServing::onRequestNeedsReply); + + m_server->start (port); +} + +StaticFileServing::~StaticFileServing () +{ + m_server->stop (); +} + +void StaticFileServing::onServerStarted (quint16 port) +{ + qDebug () << "QtHttpServer started on port" << port << m_server->getServerName (); +} + +void StaticFileServing::onServerStopped () { + qDebug () << "QtHttpServer stopped" << m_server->getServerName (); +} + +void StaticFileServing::onServerError (QString msg) +{ + qDebug () << "QtHttpServer error :" << msg; +} + +static inline void printErrorToReply (QtHttpReply * reply, QString errorMessage) +{ + reply->addHeader ("Content-Type", QByteArrayLiteral ("text/plain")); + reply->appendRawData (errorMessage.toLocal8Bit ()); +} + +void StaticFileServing::onRequestNeedsReply (QtHttpRequest * request, QtHttpReply * reply) +{ + QString command = request->getCommand (); + if (command == QStringLiteral ("GET")) + { + QString path = request->getUrl ().path (); + if ( path == "/" || path.isEmpty() || ! QFile::exists(m_baseUrl % "/" % path) ) + path = "index.html"; + + QFile file (m_baseUrl % "/" % path); + if (file.exists ()) + { + QMimeType mime = m_mimeDb->mimeTypeForFile (file.fileName ()); + if (file.open (QFile::ReadOnly)) { + QByteArray data = file.readAll (); + reply->addHeader ("Content-Type", mime.name ().toLocal8Bit ()); + reply->appendRawData (data); + file.close (); + } + else + { + printErrorToReply (reply, "Requested file " % m_baseUrl % "/" % path % " couldn't be open for reading !"); + } + } + else + { + printErrorToReply (reply, "Requested file " % path % " couldn't be found !"); + } + } + else + { + printErrorToReply (reply, "Unhandled HTTP/1.1 method " % command % " on static file server !"); + } +} + diff --git a/libsrc/webconfig/StaticFileServing.h b/libsrc/webconfig/StaticFileServing.h new file mode 100644 index 0000000..a6a7201 --- /dev/null +++ b/libsrc/webconfig/StaticFileServing.h @@ -0,0 +1,31 @@ +#ifndef STATICFILESERVING_H +#define STATICFILESERVING_H + +#include +#include + +#include "QtHttpServer.h" +#include "QtHttpRequest.h" +#include "QtHttpReply.h" +#include "QtHttpHeader.h" + +class StaticFileServing : public QObject { + Q_OBJECT + +public: + explicit StaticFileServing (QString baseUrl, quint16 port, QObject * parent = NULL); + virtual ~StaticFileServing (void); + +public slots: + void onServerStopped (void); + void onServerStarted (quint16 port); + void onServerError (QString msg); + void onRequestNeedsReply (QtHttpRequest * request, QtHttpReply * reply); + +private: + QString m_baseUrl; + QtHttpServer * m_server; + QMimeDatabase * m_mimeDb; +}; + +#endif // STATICFILESERVING_H diff --git a/libsrc/webconfig/WebConfig.cpp b/libsrc/webconfig/WebConfig.cpp new file mode 100644 index 0000000..89dbf21 --- /dev/null +++ b/libsrc/webconfig/WebConfig.cpp @@ -0,0 +1,33 @@ +#include "webconfig/webconfig.h" +#include "StaticFileServing.h" + +WebConfig::WebConfig(std::string baseUrl, quint16 port, QObject * parent) : + _parent(parent), + _baseUrl(QString::fromStdString(baseUrl)), + _port(port), + _server(nullptr) +{ +} + +WebConfig::~WebConfig() +{ + stop(); +} + + +void WebConfig::start() +{ + if ( _server == nullptr ) + _server = new StaticFileServing (_baseUrl, _port, this); +} + +void WebConfig::stop() +{ + if ( _server != nullptr ) + { + delete _server; + _server = nullptr; + } +} + + diff --git a/src/hyperiond/CMakeLists.txt b/src/hyperiond/CMakeLists.txt index ed79f4c..3f0829e 100644 --- a/src/hyperiond/CMakeLists.txt +++ b/src/hyperiond/CMakeLists.txt @@ -9,8 +9,11 @@ target_link_libraries(hyperiond effectengine jsonserver boblightserver - protoserver + protoserver ) +if (ENABLE_QT5) + target_link_libraries(hyperiond webconfig) +endif () if (ENABLE_DISPMANX) target_link_libraries(hyperiond dispmanx-grabber) @@ -40,4 +43,4 @@ install ( TARGETS hyperiond DESTINATION "bin" COMPONENT ambilight ) install ( DIRECTORY ${CMAKE_SOURCE_DIR}/effects DESTINATION "share/hyperion/" COMPONENT ambilight ) install ( DIRECTORY ${CMAKE_SOURCE_DIR}/bin/service DESTINATION "share/hyperion/" COMPONENT ambilight ) install ( DIRECTORY ${CMAKE_SOURCE_DIR}/config DESTINATION "share/hyperion/" COMPONENT ambilight ) - +install ( DIRECTORY ${CMAKE_SOURCE_DIR}/assets/webconfig DESTINATION "share/hyperion/" COMPONENT ambilight ) diff --git a/src/hyperiond/hyperiond.cpp b/src/hyperiond/hyperiond.cpp index 6a41edc..7575f74 100644 --- a/src/hyperiond/hyperiond.cpp +++ b/src/hyperiond/hyperiond.cpp @@ -58,8 +58,16 @@ #include #endif -// JsonServer includes +// network servers #include +#include +#include +#include + +#include +#include + +using namespace vlofgren; // ProtoServer includes #include @@ -187,7 +195,11 @@ void startXBMCVideoChecker(const Json::Value &config, XBMCVideoChecker* &xbmcVid } } +#ifdef ENABLE_QT5 +void startNetworkServices(const Json::Value &config, Hyperion &hyperion, JsonServer* &jsonServer, ProtoServer* &protoServer, BoblightServer* &boblightServer, WebConfig* &webConfig, XBMCVideoChecker* &xbmcVideoChecker, QObject* parent) +#else void startNetworkServices(const Json::Value &config, Hyperion &hyperion, JsonServer* &jsonServer, ProtoServer* &protoServer, BoblightServer* &boblightServer, XBMCVideoChecker* &xbmcVideoChecker) +#endif { // Create Json server if configuration is present unsigned int jsonPort = 19444; @@ -218,6 +230,24 @@ void startNetworkServices(const Json::Value &config, Hyperion &hyperion, JsonSer } std::cout << "INFO: Proto server created and started on port " << protoServer->getPort() << std::endl; +#ifdef ENABLE_QT5 + // webconfig server + std::string webconfigPath = "/usr/share/hyperion/webconfig"; + quint16 webconfigPort = 80; + bool webconfigEnable = true; + if (config.isMember("webConfig")) + { + const Json::Value & webconfigConfig = config["webConfig"]; + webconfigEnable = webconfigConfig.get("enable", true).asBool(); + webconfigPort = webconfigConfig.get("port", 80).asUInt(); + webconfigPath = webconfigConfig.get("document_root", "/usr/share/hyperion/webconfig").asString(); + } + + webConfig = new WebConfig(webconfigPath, webconfigPort, parent); + if ( webconfigEnable ) + webConfig->start(); +#endif + #ifdef ENABLE_ZEROCONF const Json::Value & deviceConfig = config["device"]; const std::string deviceName = deviceConfig.get("name", "").asString(); @@ -492,7 +522,12 @@ int main(int argc, char** argv) JsonServer * jsonServer = nullptr; ProtoServer * protoServer = nullptr; BoblightServer * boblightServer = nullptr; +#ifdef ENABLE_QT5 + WebConfig * webConfig = nullptr; + startNetworkServices(config, hyperion, jsonServer, protoServer, boblightServer, webConfig, xbmcVideoChecker, &app); +#else startNetworkServices(config, hyperion, jsonServer, protoServer, boblightServer, xbmcVideoChecker); +#endif // ---- grabber -----