diff --git a/docker/guacd/Dockerfile b/docker/guacd/Dockerfile
index eb639087..5bbaf75c 100644
--- a/docker/guacd/Dockerfile
+++ b/docker/guacd/Dockerfile
@@ -1,4 +1,4 @@
-FROM guacamole/guacd:1.5.5
+FROM guacamole/guacd:1.6.0
USER root
# 系统时区
ARG TZ=Asia/Shanghai
diff --git a/orion-visor-dependencies/pom.xml b/orion-visor-dependencies/pom.xml
index f617b4c6..6688829d 100644
--- a/orion-visor-dependencies/pom.xml
+++ b/orion-visor-dependencies/pom.xml
@@ -34,7 +34,7 @@
4.11.0
1.0.7
7.2.11.RELEASE
- 1.5.5
+ 1.6.0
diff --git a/orion-visor-ui/libs/guacamole-common-js.js b/orion-visor-ui/libs/guacamole-common-js.js
index 845dd632..09363803 100644
--- a/orion-visor-ui/libs/guacamole-common-js.js
+++ b/orion-visor-ui/libs/guacamole-common-js.js
@@ -58,13 +58,27 @@ Guacamole.ArrayBufferReader = function(stream) {
// Receive blobs as array buffers
stream.onblob = function(data) {
- // Convert to ArrayBuffer
- var binary = window.atob(data);
- var arrayBuffer = new ArrayBuffer(binary.length);
- var bufferView = new Uint8Array(arrayBuffer);
+ var arrayBuffer, bufferView;
- for (var i = 0; i < binary.length; i++)
- bufferView[i] = binary.charCodeAt(i);
+ // Use native methods for directly decoding base64 to an array buffer
+ // when possible
+ if(Uint8Array.fromBase64) {
+ bufferView = Uint8Array.fromBase64(data);
+ arrayBuffer = bufferView.buffer;
+ }
+
+ // Rely on binary strings and manual conversions where native methods
+ // like fromBase64() are not available
+ else {
+
+ var binary = window.atob(data);
+ arrayBuffer = new ArrayBuffer(binary.length);
+ bufferView = new Uint8Array(arrayBuffer);
+
+ for (var i = 0; i < binary.length; i++)
+ bufferView[i] = binary.charCodeAt(i);
+
+ }
// Call handler, if present
if(guac_reader.ondata)
@@ -1833,14 +1847,7 @@ Guacamole.Client = function(tunnel) {
var guac_client = this;
- var STATE_IDLE = 0;
- var STATE_CONNECTING = 1;
- var STATE_WAITING = 2;
- var STATE_CONNECTED = 3;
- var STATE_DISCONNECTING = 4;
- var STATE_DISCONNECTED = 5;
-
- var currentState = STATE_IDLE;
+ var currentState = Guacamole.Client.State.IDLE;
var currentTimestamp = 0;
@@ -1962,8 +1969,8 @@ Guacamole.Client = function(tunnel) {
}
function isConnected() {
- return currentState == STATE_CONNECTED
- || currentState == STATE_WAITING;
+ return currentState == Guacamole.Client.State.CONNECTED
+ || currentState == Guacamole.Client.State.WAITING;
}
/**
@@ -2728,6 +2735,12 @@ Guacamole.Client = function(tunnel) {
* @event
* @param {!number} timestamp
* The timestamp associated with the sync instruction.
+ *
+ * @param {!number} frames
+ * The number of frames that were considered or combined to produce the
+ * frame associated with this sync instruction, or zero if this value
+ * is not known or the remote desktop server provides no concept of
+ * frames.
*/
this.onsync = null;
@@ -3456,6 +3469,7 @@ Guacamole.Client = function(tunnel) {
'sync': function(parameters) {
var timestamp = parseInt(parameters[0]);
+ var frames = parameters[1] ? parseInt(parameters[1]) : 0;
// Flush display, send sync when done
display.flush(function displaySyncComplete() {
@@ -3473,15 +3487,15 @@ Guacamole.Client = function(tunnel) {
currentTimestamp = timestamp;
}
- });
+ }, timestamp, frames);
// If received first update, no longer waiting.
- if(currentState === STATE_WAITING)
- setState(STATE_CONNECTED);
+ if(currentState === Guacamole.Client.State.WAITING)
+ setState(Guacamole.Client.State.CONNECTED);
// Call sync handler if defined
if(guac_client.onsync)
- guac_client.onsync(timestamp);
+ guac_client.onsync(timestamp, frames);
},
@@ -3633,10 +3647,10 @@ Guacamole.Client = function(tunnel) {
this.disconnect = function() {
// Only attempt disconnection not disconnected.
- if(currentState != STATE_DISCONNECTED
- && currentState != STATE_DISCONNECTING) {
+ if(currentState != Guacamole.Client.State.DISCONNECTED
+ && currentState != Guacamole.Client.State.DISCONNECTING) {
- setState(STATE_DISCONNECTING);
+ setState(Guacamole.Client.State.DISCONNECTING);
// Stop sending keep-alive messages
stopKeepAlive();
@@ -3644,7 +3658,7 @@ Guacamole.Client = function(tunnel) {
// Send disconnect message and disconnect
tunnel.sendMessage('disconnect');
tunnel.disconnect();
- setState(STATE_DISCONNECTED);
+ setState(Guacamole.Client.State.DISCONNECTED);
}
@@ -3663,12 +3677,12 @@ Guacamole.Client = function(tunnel) {
*/
this.connect = function(data) {
- setState(STATE_CONNECTING);
+ setState(Guacamole.Client.State.CONNECTING);
try {
tunnel.connect(data);
} catch (status) {
- setState(STATE_IDLE);
+ setState(Guacamole.Client.State.IDLE);
throw status;
}
@@ -3676,38 +3690,61 @@ Guacamole.Client = function(tunnel) {
// still here, even if not active
scheduleKeepAlive();
- setState(STATE_WAITING);
+ setState(Guacamole.Client.State.WAITING);
};
};
-// SETTED
+/**
+ * All possible Guacamole Client states.
+ *
+ * @type {!Object.}
+ */
Guacamole.Client.State = {
+
/**
* The client is idle, with no active connection.
+ *
+ * @type number
*/
'IDLE': 0,
+
/**
* The client is in the process of establishing a connection.
+ *
+ * @type {!number}
*/
'CONNECTING': 1,
+
/**
* The client is waiting on further information or a remote server to
* establish the connection.
+ *
+ * @type {!number}
*/
'WAITING': 2,
+
/**
* The client is actively connected to a remote server.
+ *
+ * @type {!number}
*/
'CONNECTED': 3,
+
/**
* The client is in the process of disconnecting from the remote server.
+ *
+ * @type {!number}
*/
'DISCONNECTING': 4,
+
/**
* The client has completed the connection and is no longer connected.
+ *
+ * @type {!number}
*/
- 'DISCONNECTED': 5,
+ 'DISCONNECTED': 5
+
};
/**
@@ -4059,6 +4096,17 @@ Guacamole.Display = function() {
*/
this.cursorY = 0;
+ /**
+ * The number of milliseconds over which display rendering statistics
+ * should be gathered, dispatching {@link #onstatistics} events as those
+ * statistics are available. If set to zero, no statistics will be
+ * gathered.
+ *
+ * @default 0
+ * @type {!number}
+ */
+ this.statisticWindow = 0;
+
/**
* Fired when the default layer (and thus the entire Guacamole display)
* is resized.
@@ -4089,6 +4137,18 @@ Guacamole.Display = function() {
*/
this.oncursor = null;
+ /**
+ * Fired whenever performance statistics are available for recently-
+ * rendered frames. This event will fire only if {@link #statisticWindow}
+ * is non-zero.
+ *
+ * @event
+ * @param {!Guacamole.Display.Statistics} stats
+ * An object containing general rendering performance statistics for
+ * the remote desktop, Guacamole server, and Guacamole client.
+ */
+ this.onstatistics = null;
+
/**
* The queue of all pending Tasks. Tasks will be run in order, with new
* tasks added at the end of the queue and old tasks removed from the
@@ -4110,11 +4170,20 @@ Guacamole.Display = function() {
var frames = [];
/**
- * Flushes all pending frames.
+ * Flushes all pending frames synchronously. This function will block until
+ * all pending frames have rendered. If a frame is currently blocked by an
+ * asynchronous operation like an image load, this function will return
+ * after reaching that operation and the flush operation will
+ * automamtically resume after that operation completes.
+ *
* @private
*/
- function __flush_frames() {
+ var syncFlush = function syncFlush() {
+ var localTimestamp = 0;
+ var remoteTimestamp = 0;
+
+ var renderedLogicalFrames = 0;
var rendered_frames = 0;
// Draw all pending frames, if ready
@@ -4125,6 +4194,10 @@ Guacamole.Display = function() {
break;
frame.flush();
+
+ localTimestamp = frame.localTimestamp;
+ remoteTimestamp = frame.remoteTimestamp;
+ renderedLogicalFrames += frame.logicalFrames;
rendered_frames++;
}
@@ -4132,6 +4205,112 @@ Guacamole.Display = function() {
// Remove rendered frames from array
frames.splice(0, rendered_frames);
+ if(rendered_frames)
+ notifyFlushed(localTimestamp, remoteTimestamp, renderedLogicalFrames);
+
+ };
+
+ /**
+ * Recently-gathered display render statistics, as made available by calls
+ * to notifyFlushed(). The contents of this array will be trimmed to
+ * contain only up to {@link #statisticWindow} milliseconds of statistics.
+ *
+ * @private
+ * @type {Guacamole.Display.Statistics[]}
+ */
+ var statistics = [];
+
+ /**
+ * Notifies that one or more frames have been successfully rendered
+ * (flushed) to the display.
+ *
+ * @private
+ * @param {!number} localTimestamp
+ * The local timestamp of the point in time at which the most recent,
+ * flushed frame was received by the display, in milliseconds since the
+ * Unix Epoch.
+ *
+ * @param {!number} remoteTimestamp
+ * The remote timestamp of sync instruction associated with the most
+ * recent, flushed frame received by the display. This timestamp is in
+ * milliseconds, but is arbitrary, having meaning only relative to
+ * other timestamps in the same connection.
+ *
+ * @param {!number} logicalFrames
+ * The number of remote desktop frames that were flushed.
+ */
+ var notifyFlushed = function notifyFlushed(localTimestamp, remoteTimestamp, logicalFrames) {
+
+ // Ignore if statistics are not being gathered
+ if(!guac_display.statisticWindow)
+ return;
+
+ var current = new Date().getTime();
+
+ // Find the first statistic that is still within the configured time
+ // window
+ for (var first = 0; first < statistics.length; first++) {
+ if(current - statistics[first].timestamp <= guac_display.statisticWindow)
+ break;
+ }
+
+ // Remove all statistics except those within the time window
+ statistics.splice(0, first - 1);
+
+ // Record statistics for latest frame
+ statistics.push({
+ localTimestamp: localTimestamp,
+ remoteTimestamp: remoteTimestamp,
+ timestamp: current,
+ frames: logicalFrames
+ });
+
+ // Determine the actual time interval of the available statistics (this
+ // will not perfectly match the configured interval, which is an upper
+ // bound)
+ var statDuration = (statistics[statistics.length - 1].timestamp - statistics[0].timestamp) / 1000;
+
+ // Determine the amount of time that elapsed remotely (within the
+ // remote desktop)
+ var remoteDuration = (statistics[statistics.length - 1].remoteTimestamp - statistics[0].remoteTimestamp) / 1000;
+
+ // Calculate the number of frames that have been rendered locally
+ // within the configured time interval
+ var localFrames = statistics.length;
+
+ // Calculate the number of frames actually received from the remote
+ // desktop by the Guacamole server
+ var remoteFrames = statistics.reduce(function sumFrames(prev, stat) {
+ return prev + stat.frames;
+ }, 0);
+
+ // Calculate the number of frames that the Guacamole server had to
+ // drop or combine with other frames
+ var drops = statistics.reduce(function sumDrops(prev, stat) {
+ return prev + Math.max(0, stat.frames - 1);
+ }, 0);
+
+ // Produce lag and FPS statistics from above raw measurements
+ var stats = new Guacamole.Display.Statistics({
+ processingLag: current - localTimestamp,
+ desktopFps: (remoteDuration && remoteFrames) ? remoteFrames / remoteDuration : null,
+ clientFps: statDuration ? localFrames / statDuration : null,
+ serverFps: remoteDuration ? localFrames / remoteDuration : null,
+ dropRate: remoteDuration ? drops / remoteDuration : null
+ });
+
+ // Notify of availability of new statistics
+ if(guac_display.onstatistics)
+ guac_display.onstatistics(stats);
+
+ };
+
+ /**
+ * Flushes all pending frames.
+ * @private
+ */
+ function __flush_frames() {
+ syncFlush();
}
/**
@@ -4145,8 +4324,43 @@ Guacamole.Display = function() {
*
* @param {!Task[]} tasks
* The set of tasks which must be executed to render this frame.
+ *
+ * @param {number} [timestamp]
+ * The remote timestamp of sync instruction associated with this frame.
+ * This timestamp is in milliseconds, but is arbitrary, having meaning
+ * only relative to other remote timestamps in the same connection. If
+ * omitted, a compatible but local timestamp will be used instead.
+ *
+ * @param {number} [logicalFrames=0]
+ * The number of remote desktop frames that were combined to produce
+ * this frame, or zero if this value is unknown or inapplicable.
*/
- function Frame(callback, tasks) {
+ var Frame = function Frame(callback, tasks, timestamp, logicalFrames) {
+
+ /**
+ * The local timestamp of the point in time at which this frame was
+ * received by the display, in milliseconds since the Unix Epoch.
+ *
+ * @type {!number}
+ */
+ this.localTimestamp = new Date().getTime();
+
+ /**
+ * The remote timestamp of sync instruction associated with this frame.
+ * This timestamp is in milliseconds, but is arbitrary, having meaning
+ * only relative to other remote timestamps in the same connection.
+ *
+ * @type {!number}
+ */
+ this.remoteTimestamp = timestamp || this.localTimestamp;
+
+ /**
+ * The number of remote desktop frames that were combined to produce
+ * this frame. If unknown or not applicable, this will be zero.
+ *
+ * @type {!number}
+ */
+ this.logicalFrames = logicalFrames || 0;
/**
* Cancels rendering of this frame and all associated tasks. The
@@ -4201,7 +4415,7 @@ Guacamole.Display = function() {
};
- }
+ };
/**
* A container for an task handler. Each operation which must be ordered
@@ -4250,7 +4464,10 @@ Guacamole.Display = function() {
this.unblock = function() {
if(task.blocked) {
task.blocked = false;
- __flush_frames();
+
+ if(frames.length)
+ __flush_frames();
+
}
};
@@ -4378,11 +4595,20 @@ Guacamole.Display = function() {
* @param {function} [callback]
* The function to call when this frame is flushed. This may happen
* immediately, or later when blocked tasks become unblocked.
+ *
+ * @param {number} timestamp
+ * The remote timestamp of sync instruction associated with this frame.
+ * This timestamp is in milliseconds, but is arbitrary, having meaning
+ * only relative to other remote timestamps in the same connection.
+ *
+ * @param {number} logicalFrames
+ * The number of remote desktop frames that were combined to produce
+ * this frame.
*/
- this.flush = function(callback) {
+ this.flush = function(callback, timestamp, logicalFrames) {
// Add frame, reset tasks
- frames.push(new Frame(callback, tasks));
+ frames.push(new Frame(callback, tasks, timestamp, logicalFrames));
tasks = [];
// Attempt flush
@@ -4666,17 +4892,38 @@ Guacamole.Display = function() {
*/
this.drawStream = function drawStream(layer, x, y, stream, mimetype) {
- // If createImageBitmap() is available, load the image as a blob so
- // that function can be used
- if(window.createImageBitmap) {
- var reader = new Guacamole.BlobReader(stream, mimetype);
- reader.onend = function drawImageBlob() {
- guac_display.drawBlob(layer, x, y, reader.getBlob());
- };
+ // Leverage ImageDecoder to decode the image stream as it is received
+ // whenever possible, as this reduces latency that might otherwise be
+ // caused by waiting for the full image to be received
+ if(window.ImageDecoder && window.ReadableStream) {
+
+ var imageDecoder = new ImageDecoder({
+ type: mimetype,
+ data: stream.toReadableStream()
+ });
+
+ var decodedFrame = null;
+
+ // Draw image once loaded
+ var task = scheduleTask(function drawImageBitmap() {
+ layer.drawImage(x, y, decodedFrame);
+ }, true);
+
+ imageDecoder.decode({ completeFramesOnly: true }).then(function bitmapLoaded(result) {
+ decodedFrame = result.image;
+ task.unblock();
+ });
+
}
- // Lacking createImageBitmap(), fall back to data URIs and the Image
- // object
+ // NOTE: We do not use Blobs and createImageBitmap() here, as doing so
+ // is very latent compared to the old data URI method and the new
+ // ImageDecoder object. The new ImageDecoder object is currently
+ // supported by most browsers, with other browsers being much faster if
+ // data URIs are used. The iOS version of Safari is particularly laggy
+ // if Blobs and createImageBitmap() are used instead.
+
+ // Lacking ImageDecoder, fall back to data URIs and the Image object
else {
var reader = new Guacamole.DataURIReader(stream, mimetype);
reader.onend = function drawImageDataURI() {
@@ -5803,6 +6050,82 @@ Guacamole.Display.VisibleLayer = function(width, height) {
*/
Guacamole.Display.VisibleLayer.__next_id = 0;
+/**
+ * A set of Guacamole display performance statistics, describing the speed at
+ * which the remote desktop, Guacamole server, and Guacamole client are
+ * rendering frames.
+ *
+ * @constructor
+ * @param {Guacamole.Display.Statistics|Object} [template={}]
+ * The object whose properties should be copied within the new
+ * Guacamole.Display.Statistics.
+ */
+Guacamole.Display.Statistics = function Statistics(template) {
+
+ template = template || {};
+
+ /**
+ * The amount of time that the Guacamole client is taking to render
+ * individual frames, in milliseconds, if known. If this value is unknown,
+ * such as if the there are insufficient frame statistics recorded to
+ * calculate this value, this will be null.
+ *
+ * @type {?number}
+ */
+ this.processingLag = template.processingLag;
+
+ /**
+ * The framerate of the remote desktop currently being viewed within the
+ * relevant Gucamole.Display, independent of Guacamole, in frames per
+ * second. This represents the speed at which the remote desktop is
+ * producing frame data for the Guacamole server to consume. If this
+ * value is unknown, such as if the remote desktop server does not actually
+ * define frame boundaries, this will be null.
+ *
+ * @type {?number}
+ */
+ this.desktopFps = template.desktopFps;
+
+ /**
+ * The rate at which the Guacamole server is generating frames for the
+ * Guacamole client to consume, in frames per second. If the Guacamole
+ * server is correctly adjusting for variance in client/browser processing
+ * power, this rate should closely match the client rate, and should remain
+ * independent of any network latency. If this value is unknown, such as if
+ * the there are insufficient frame statistics recorded to calculate this
+ * value, this will be null.
+ *
+ * @type {?number}
+ */
+ this.serverFps = template.serverFps;
+
+ /**
+ * The rate at which the Guacamole client is consuming frames generated by
+ * the Guacamole server, in frames per second. If the Guacamole server is
+ * correctly adjusting for variance in client/browser processing power,
+ * this rate should closely match the server rate, regardless of any
+ * latency on the network between the server and client. If this value is
+ * unknown, such as if the there are insufficient frame statistics recorded
+ * to calculate this value, this will be null.
+ *
+ * @type {?number}
+ */
+ this.clientFps = template.clientFps;
+
+ /**
+ * The rate at which the Guacamole server is dropping or combining frames
+ * received from the remote desktop server to compensate for variance in
+ * client/browser processing power, in frames per second. This value may
+ * also be non-zero if the server is compensating for variances in its own
+ * processing power, or relative slowness in image compression vs. the rate
+ * that inbound frames are received. If this value is unknown, such as if
+ * the remote desktop server does not actually define frame boundaries,
+ * this will be null.
+ */
+ this.dropRate = template.dropRate;
+
+};
+
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -6338,6 +6661,66 @@ Guacamole.InputStream = function(client, index) {
client.sendAck(guac_stream.index, message, code);
};
+ /**
+ * Creates a new ReadableStream that receives the data sent to this stream
+ * by the Guacamole server. This function may be invoked at most once per
+ * stream, and invoking this function will overwrite any installed event
+ * handlers on this stream.
+ *
+ * A ReadableStream is a JavaScript object defined by the "Streams"
+ * standard. It is supported by most browsers, but not necessarily all
+ * browsers. The caller should verify this support is present before
+ * invoking this function. The behavior of this function when the browser
+ * does not support ReadableStream is not defined.
+ *
+ * @see {@link https://streams.spec.whatwg.org/#rs-class}
+ *
+ * @returns {!ReadableStream}
+ * A new ReadableStream that receives the bytes sent along this stream
+ * by the Guacamole server.
+ */
+ this.toReadableStream = function toReadableStream() {
+ return new ReadableStream({
+ type: 'bytes',
+ start: function startStream(controller) {
+
+ var reader = new Guacamole.ArrayBufferReader(guac_stream);
+
+ // Provide any received blocks of data to the ReadableStream
+ // controller, such that they will be read by whatever is
+ // consuming the ReadableStream
+ reader.ondata = function dataReceived(data) {
+
+ if(controller.byobRequest) {
+
+ var view = controller.byobRequest.view;
+ var length = Math.min(view.byteLength, data.byteLength);
+ var byobBlock = new Uint8Array(data, 0, length);
+
+ view.buffer.set(byobBlock);
+ controller.byobRequest.respond(length);
+
+ if(length < data.byteLength) {
+ controller.enqueue(data.slice(length));
+ }
+
+ } else {
+ controller.enqueue(new Uint8Array(data));
+ }
+
+ };
+
+ // Notify the ReadableStream when the end of the stream is
+ // reached
+ reader.onend = function dataComplete() {
+ controller.close();
+ };
+
+ }
+ });
+
+ };
+
};
/*
@@ -6832,6 +7215,10 @@ Guacamole.Keyboard = function Keyboard(element) {
// Determine whether default action for Alt+combinations must be prevented
var prevent_alt = !this.modifiers.ctrl && !quirks.altIsTypableOnly;
+ // If alt is typeable only, and this is actually an alt key event, treat as AltGr instead
+ if(quirks.altIsTypableOnly && (this.keysym === 0xFFE9 || this.keysym === 0xFFEA))
+ this.keysym = 0xFE03;
+
// Determine whether default action for Ctrl+combinations must be prevented
var prevent_ctrl = !this.modifiers.alt;
@@ -6932,7 +7319,7 @@ Guacamole.Keyboard = function Keyboard(element) {
13: [0xFF0D], // enter
16: [0xFFE1, 0xFFE1, 0xFFE2], // shift
17: [0xFFE3, 0xFFE3, 0xFFE4], // ctrl
- 18: [0xFFE9, 0xFFE9, 0xFE03], // alt
+ 18: [0xFFE9, 0xFFE9, 0xFFEA], // alt
19: [0xFF13], // pause/break
20: [0xFFE5], // caps lock
27: [0xFF1B], // escape
@@ -6993,7 +7380,7 @@ Guacamole.Keyboard = function Keyboard(element) {
'Again': [0xFF66],
'AllCandidates': [0xFF3D],
'Alphanumeric': [0xFF30],
- 'Alt': [0xFFE9, 0xFFE9, 0xFE03],
+ 'Alt': [0xFFE9, 0xFFE9, 0xFFEA],
'Attn': [0xFD0E],
'AltGraph': [0xFE03],
'ArrowDown': [0xFF54],
@@ -7004,7 +7391,7 @@ Guacamole.Keyboard = function Keyboard(element) {
'CapsLock': [0xFFE5],
'Cancel': [0xFF69],
'Clear': [0xFF0B],
- 'Convert': [0xFF21],
+ 'Convert': [0xFF23],
'Copy': [0xFD15],
'Crsel': [0xFD1C],
'CrSel': [0xFD1C],
@@ -7071,6 +7458,7 @@ Guacamole.Keyboard = function Keyboard(element) {
'Left': [0xFF51],
'Meta': [0xFFE7, 0xFFE7, 0xFFE8],
'ModeChange': [0xFF7E],
+ 'NonConvert': [0xFF22],
'NumLock': [0xFF7F],
'PageDown': [0xFF56],
'PageUp': [0xFF55],
@@ -7080,6 +7468,7 @@ Guacamole.Keyboard = function Keyboard(element) {
'PrintScreen': [0xFF61],
'Redo': [0xFF66],
'Right': [0xFF53],
+ 'Romaji': [0xFF24],
'RomanCharacters': null,
'Scroll': [0xFF14],
'Select': [0xFF60],
@@ -7851,9 +8240,10 @@ Guacamole.Keyboard = function Keyboard(element) {
var keydownEvent = new KeydownEvent(e);
- // Ignore (but do not prevent) the "composition" keycode sent by some
- // browsers when an IME is in use (see: http://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html)
- if(keydownEvent.keyCode === 229)
+ // Ignore (but do not prevent) the event if explicitly marked as composing,
+ // or when the "composition" keycode sent by some browsers when an IME is in use
+ // (see: http://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html)
+ if(e.isComposing || keydownEvent.keyCode === 229)
return;
// Log event
@@ -7902,8 +8292,6 @@ Guacamole.Keyboard = function Keyboard(element) {
/**
* Handles the given "input" event, typing the data within the input text.
- * If the event is complete (text is provided), handling of "compositionend"
- * events is suspended, as such events may conflict with input events.
*
* @private
* @param {!InputEvent} e
@@ -7918,24 +8306,37 @@ Guacamole.Keyboard = function Keyboard(element) {
if(!markEvent(e)) return;
// Type all content written
- if(e.data && !e.isComposing) {
- element.removeEventListener('compositionend', handleComposition, false);
+ if(e.data && !e.isComposing)
guac_keyboard.type(e.data);
- }
+
+ };
+
+ /**
+ * Handles the given "compositionstart" event, automatically removing
+ * the "input" event handler, as "input" events should only be handled
+ * if composition events are not provided by the browser.
+ *
+ * @private
+ * @param {!CompositionEvent} e
+ * The "compositionstart" event to handle.
+ */
+ var handleCompositionStart = function handleCompositionStart(e) {
+
+ // Remove the "input" event handler now that the browser is known
+ // to send composition events
+ element.removeEventListener('input', handleInput, false);
};
/**
* Handles the given "compositionend" event, typing the data within the
- * composed text. If the event is complete (composed text is provided),
- * handling of "input" events is suspended, as such events may conflict
- * with composition events.
+ * composed text.
*
* @private
* @param {!CompositionEvent} e
* The "compositionend" event to handle.
*/
- var handleComposition = function handleComposition(e) {
+ var handleCompositionEnd = function handleCompositionEnd(e) {
// Only intercept if handler set
if(!guac_keyboard.onkeydown && !guac_keyboard.onkeyup) return;
@@ -7944,16 +8345,15 @@ Guacamole.Keyboard = function Keyboard(element) {
if(!markEvent(e)) return;
// Type all content written
- if(e.data) {
- element.removeEventListener('input', handleInput, false);
+ if(e.data)
guac_keyboard.type(e.data);
- }
};
// Automatically type text entered into the wrapped field
element.addEventListener('input', handleInput, false);
- element.addEventListener('compositionend', handleComposition, false);
+ element.addEventListener('compositionend', handleCompositionEnd, false);
+ element.addEventListener('compositionstart', handleCompositionStart, false);
};
@@ -8067,6 +8467,343 @@ Guacamole.Keyboard.ModifierState.fromKeyboardEvent = function(e) {
var Guacamole = Guacamole || {};
+/**
+ * An object that will accept raw key events and produce a chronologically
+ * ordered array of key event objects. These events can be obtained by
+ * calling getEvents().
+ *
+ * @constructor
+ * @param {number} [startTimestamp=0]
+ * The starting timestamp for the recording being intepreted. If provided,
+ * the timestamp of each intepreted event will be relative to this timestamp.
+ * If not provided, the raw recording timestamp will be used.
+ */
+Guacamole.KeyEventInterpreter = function KeyEventInterpreter(startTimestamp) {
+
+ // Default to 0 seconds to keep the raw timestamps
+ if(startTimestamp === undefined || startTimestamp === null)
+ startTimestamp = 0;
+
+ /**
+ * A precursor array to the KNOWN_KEYS map. The objects contained within
+ * will be constructed into full KeyDefinition objects.
+ *
+ * @constant
+ * @private
+ * @type {Object[]}
+ */
+ var _KNOWN_KEYS = [
+ { keysym: 0xFE03, name: 'AltGr' },
+ { keysym: 0xFF08, name: 'Backspace' },
+ { keysym: 0xFF09, name: 'Tab' },
+ { keysym: 0xFF0B, name: 'Clear' },
+ { keysym: 0xFF0D, name: 'Return', value: '\n' },
+ { keysym: 0xFF13, name: 'Pause' },
+ { keysym: 0xFF14, name: 'Scroll' },
+ { keysym: 0xFF15, name: 'SysReq' },
+ { keysym: 0xFF1B, name: 'Escape' },
+ { keysym: 0xFF50, name: 'Home' },
+ { keysym: 0xFF51, name: 'Left' },
+ { keysym: 0xFF52, name: 'Up' },
+ { keysym: 0xFF53, name: 'Right' },
+ { keysym: 0xFF54, name: 'Down' },
+ { keysym: 0xFF55, name: 'Page Up' },
+ { keysym: 0xFF56, name: 'Page Down' },
+ { keysym: 0xFF57, name: 'End' },
+ { keysym: 0xFF63, name: 'Insert' },
+ { keysym: 0xFF65, name: 'Undo' },
+ { keysym: 0xFF6A, name: 'Help' },
+ { keysym: 0xFF7F, name: 'Num' },
+ { keysym: 0xFF80, name: 'Space', value: ' ' },
+ { keysym: 0xFF8D, name: 'Enter', value: '\n' },
+ { keysym: 0xFF95, name: 'Home' },
+ { keysym: 0xFF96, name: 'Left' },
+ { keysym: 0xFF97, name: 'Up' },
+ { keysym: 0xFF98, name: 'Right' },
+ { keysym: 0xFF99, name: 'Down' },
+ { keysym: 0xFF9A, name: 'Page Up' },
+ { keysym: 0xFF9B, name: 'Page Down' },
+ { keysym: 0xFF9C, name: 'End' },
+ { keysym: 0xFF9E, name: 'Insert' },
+ { keysym: 0xFFAA, name: '*', value: '*' },
+ { keysym: 0xFFAB, name: '+', value: '+' },
+ { keysym: 0xFFAD, name: '-', value: '-' },
+ { keysym: 0xFFAE, name: '.', value: '.' },
+ { keysym: 0xFFAF, name: '/', value: '/' },
+ { keysym: 0xFFB0, name: '0', value: '0' },
+ { keysym: 0xFFB1, name: '1', value: '1' },
+ { keysym: 0xFFB2, name: '2', value: '2' },
+ { keysym: 0xFFB3, name: '3', value: '3' },
+ { keysym: 0xFFB4, name: '4', value: '4' },
+ { keysym: 0xFFB5, name: '5', value: '5' },
+ { keysym: 0xFFB6, name: '6', value: '6' },
+ { keysym: 0xFFB7, name: '7', value: '7' },
+ { keysym: 0xFFB8, name: '8', value: '8' },
+ { keysym: 0xFFB9, name: '9', value: '9' },
+ { keysym: 0xFFBE, name: 'F1' },
+ { keysym: 0xFFBF, name: 'F2' },
+ { keysym: 0xFFC0, name: 'F3' },
+ { keysym: 0xFFC1, name: 'F4' },
+ { keysym: 0xFFC2, name: 'F5' },
+ { keysym: 0xFFC3, name: 'F6' },
+ { keysym: 0xFFC4, name: 'F7' },
+ { keysym: 0xFFC5, name: 'F8' },
+ { keysym: 0xFFC6, name: 'F9' },
+ { keysym: 0xFFC7, name: 'F10' },
+ { keysym: 0xFFC8, name: 'F11' },
+ { keysym: 0xFFC9, name: 'F12' },
+ { keysym: 0xFFCA, name: 'F13' },
+ { keysym: 0xFFCB, name: 'F14' },
+ { keysym: 0xFFCC, name: 'F15' },
+ { keysym: 0xFFCD, name: 'F16' },
+ { keysym: 0xFFCE, name: 'F17' },
+ { keysym: 0xFFCF, name: 'F18' },
+ { keysym: 0xFFD0, name: 'F19' },
+ { keysym: 0xFFD1, name: 'F20' },
+ { keysym: 0xFFD2, name: 'F21' },
+ { keysym: 0xFFD3, name: 'F22' },
+ { keysym: 0xFFD4, name: 'F23' },
+ { keysym: 0xFFD5, name: 'F24' },
+ { keysym: 0xFFE1, name: 'Shift' },
+ { keysym: 0xFFE2, name: 'Shift' },
+ { keysym: 0xFFE3, name: 'Ctrl' },
+ { keysym: 0xFFE4, name: 'Ctrl' },
+ { keysym: 0xFFE5, name: 'Caps' },
+ { keysym: 0xFFE7, name: 'Meta' },
+ { keysym: 0xFFE8, name: 'Meta' },
+ { keysym: 0xFFE9, name: 'Alt' },
+ { keysym: 0xFFEA, name: 'Alt' },
+ { keysym: 0xFFEB, name: 'Super' },
+ { keysym: 0xFFEC, name: 'Super' },
+ { keysym: 0xFFED, name: 'Hyper' },
+ { keysym: 0xFFEE, name: 'Hyper' },
+ { keysym: 0xFFFF, name: 'Delete' }
+ ];
+
+ /**
+ * All known keys, as a map of X11 keysym to KeyDefinition.
+ *
+ * @constant
+ * @private
+ * @type {Object.}
+ */
+ var KNOWN_KEYS = {};
+ _KNOWN_KEYS.forEach(function createKeyDefinitionMap(keyDefinition) {
+
+ // Construct a map of keysym to KeyDefinition object
+ KNOWN_KEYS[keyDefinition.keysym] = (
+ new Guacamole.KeyEventInterpreter.KeyDefinition(keyDefinition));
+
+ });
+
+ /**
+ * All key events parsed as of the most recent handleKeyEvent() invocation.
+ *
+ * @private
+ * @type {!Guacamole.KeyEventInterpreter.KeyEvent[]}
+ */
+ var parsedEvents = [];
+
+ /**
+ * If the provided keysym corresponds to a valid UTF-8 character, return
+ * a KeyDefinition for that keysym. Otherwise, return null.
+ *
+ * @private
+ * @param {Number} keysym
+ * The keysym to produce a UTF-8 KeyDefinition for, if valid.
+ *
+ * @returns {Guacamole.KeyEventInterpreter.KeyDefinition}
+ * A KeyDefinition for the provided keysym, if it's a valid UTF-8
+ * keysym, or null otherwise.
+ */
+ function getUnicodeKeyDefinition(keysym) {
+
+ // Translate only if keysym maps to Unicode
+ if(keysym < 0x00 || (keysym > 0xFF && (keysym | 0xFFFF) != 0x0100FFFF))
+ return null;
+
+ // Convert to UTF8 string
+ var codepoint = keysym & 0xFFFF;
+ var name = String.fromCharCode(codepoint);
+
+ // Create and return the definition
+ return new Guacamole.KeyEventInterpreter.KeyDefinition({
+ keysym: keysym, name: name, value: name
+ });
+
+ }
+
+ /**
+ * Return a KeyDefinition corresponding to the provided keysym.
+ *
+ * @private
+ * @param {Number} keysym
+ * The keysym to return a KeyDefinition for.
+ *
+ * @returns {KeyDefinition}
+ * A KeyDefinition corresponding to the provided keysym.
+ */
+ function getKeyDefinitionByKeysym(keysym) {
+
+ // If it's a known type, return the existing definition
+ if(keysym in KNOWN_KEYS)
+ return KNOWN_KEYS[keysym];
+
+ // Return a UTF-8 KeyDefinition, if valid
+ var definition = getUnicodeKeyDefinition(keysym);
+ if(definition != null)
+ return definition;
+
+ // If it's not UTF-8, return an unknown definition, with the name
+ // just set to the hex value of the keysym
+ return new Guacamole.KeyEventInterpreter.KeyDefinition({
+ keysym: keysym,
+ name: '0x' + String(keysym.toString(16))
+ });
+
+ }
+
+ /**
+ * Handles a raw key event, appending a new key event object for every
+ * handled raw event.
+ *
+ * @param {!string[]} args
+ * The arguments of the key event.
+ */
+ this.handleKeyEvent = function handleKeyEvent(args) {
+
+ // The X11 keysym
+ var keysym = parseInt(args[0]);
+
+ // Either 1 or 0 for pressed or released, respectively
+ var pressed = parseInt(args[1]);
+
+ // The timestamp when this key event occured
+ var timestamp = parseInt(args[2]);
+
+ // The timestamp relative to the provided initial timestamp
+ var relativeTimestap = timestamp - startTimestamp;
+
+ // Known information about the parsed key
+ var definition = getKeyDefinitionByKeysym(keysym);
+
+ // Push the latest parsed event into the list
+ parsedEvents.push(new Guacamole.KeyEventInterpreter.KeyEvent({
+ definition: definition,
+ pressed: pressed,
+ timestamp: relativeTimestap
+ }));
+
+ };
+
+ /**
+ * Return the current batch of typed text. Note that the batch may be
+ * incomplete, as more key events might be processed before the next
+ * batch starts.
+ *
+ * @returns {Guacamole.KeyEventInterpreter.KeyEvent[]}
+ * The current batch of text.
+ */
+ this.getEvents = function getEvents() {
+ return parsedEvents;
+ };
+
+};
+
+/**
+ * A definition for a known key.
+ *
+ * @constructor
+ * @param {Guacamole.KeyEventInterpreter.KeyDefinition|object} [template={}]
+ * The object whose properties should be copied within the new
+ * KeyDefinition.
+ */
+Guacamole.KeyEventInterpreter.KeyDefinition = function KeyDefinition(template) {
+
+ // Use empty object by default
+ template = template || {};
+
+ /**
+ * The X11 keysym of the key.
+ * @type {!number}
+ */
+ this.keysym = parseInt(template.keysym);
+
+ /**
+ * A human-readable name for the key.
+ * @type {!String}
+ */
+ this.name = template.name;
+
+ /**
+ * The value which would be typed in a typical text editor, if any. If the
+ * key is not associated with any typeable value, this will be undefined.
+ * @type {String}
+ */
+ this.value = template.value;
+
+};
+
+/**
+ * A granular description of an extracted key event, including a human-readable
+ * text representation of the event, whether the event is directly typed or not,
+ * and the timestamp when the event occured.
+ *
+ * @constructor
+ * @param {Guacamole.KeyEventInterpreter.KeyEvent|object} [template={}]
+ * The object whose properties should be copied within the new
+ * KeyEvent.
+ */
+Guacamole.KeyEventInterpreter.KeyEvent = function KeyEvent(template) {
+
+ // Use empty object by default
+ template = template || {};
+
+ /**
+ * The key definition for the pressed key.
+ *
+ * @type {!Guacamole.KeyEventInterpreter.KeyDefinition}
+ */
+ this.definition = template.definition;
+
+ /**
+ * True if the key was pressed to create this event, or false if it was
+ * released.
+ *
+ * @type {!boolean}
+ */
+ this.pressed = !!template.pressed;
+
+ /**
+ * The timestamp from the recording when this event occured.
+ *
+ * @type {!Number}
+ */
+ this.timestamp = template.timestamp;
+
+};
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+var Guacamole = Guacamole || {};
+
/**
* Abstract ordered drawing surface. Each Layer contains a canvas element and
* provides simple drawing instructions for drawing to that canvas element,
@@ -9461,9 +10198,15 @@ Guacamole.Mouse = function Mouse(element) {
}
- element.addEventListener('DOMMouseScroll', mousewheel_handler, false);
- element.addEventListener('mousewheel', mousewheel_handler, false);
- element.addEventListener('wheel', mousewheel_handler, false);
+ if(window.WheelEvent) {
+ // All modern browsers support wheel events.
+ element.addEventListener('wheel', mousewheel_handler, false);
+ } else {
+ // Legacy FireFox wheel events.
+ element.addEventListener('DOMMouseScroll', mousewheel_handler, false);
+ // Legacy Chrome/IE/other wheel events.
+ element.addEventListener('mousewheel', mousewheel_handler, false);
+ }
/**
* Whether the browser supports CSS3 cursor styling, including hotspot
@@ -12377,8 +13120,18 @@ var Guacamole = Guacamole || {};
* @param {!Blob|Guacamole.Tunnel} source
* The Blob from which the instructions of the recording should
* be read.
+ * @param {number} [refreshInterval=1000]
+ * The minimum number of milliseconds between updates to the recording
+ * position through the provided onseek() callback. If non-positive, this
+ * parameter will be ignored, and the recording position will only be
+ * updated when seek requests are made, or when new frames are rendered.
+ * If not specified, refreshInterval will default to 1000 milliseconds.
*/
-Guacamole.SessionRecording = function SessionRecording(source) {
+Guacamole.SessionRecording = function SessionRecording(source, refreshInterval) {
+
+ // Default the refresh interval to 1 second if not specified otherwise
+ if(refreshInterval === undefined)
+ refreshInterval = 1000;
/**
* Reference to this Guacamole.SessionRecording.
@@ -12484,13 +13237,13 @@ Guacamole.SessionRecording = function SessionRecording(source) {
var currentFrame = -1;
/**
- * The timestamp of the frame when playback began, in milliseconds. If
+ * The position of the recording when playback began, in milliseconds. If
* playback is not in progress, this will be null.
*
* @private
* @type {number}
*/
- var startVideoTimestamp = null;
+ var startVideoPosition = null;
/**
* The real-world timestamp when playback began, in milliseconds. If
@@ -12501,6 +13254,14 @@ Guacamole.SessionRecording = function SessionRecording(source) {
*/
var startRealTimestamp = null;
+ /**
+ * The current position within the recording, in milliseconds.
+ *
+ * @private
+ * @type {!number}
+ */
+ var currentPosition = 0;
+
/**
* An object containing a single "aborted" property which is set to
* true if the in-progress seek operation should be aborted. If no seek
@@ -12556,6 +13317,25 @@ Guacamole.SessionRecording = function SessionRecording(source) {
*/
var seekCallback = null;
+ /**
+ * Any current timeout associated with scheduling frame replay, or updating
+ * the current position, or null if no frame position increment is currently
+ * scheduled.
+ *
+ * @private
+ * @type {number}
+ */
+ var updateTimeout = null;
+
+ /**
+ * The browser timestamp of the last time that currentPosition was updated
+ * while playing, or null if the recording is not currently playing.
+ *
+ * @private
+ * @type {number}
+ */
+ var lastUpdateTimestamp = null;
+
/**
* Parses all Guacamole instructions within the given blob, invoking
* the provided instruction callback for each such instruction. Once
@@ -12682,6 +13462,28 @@ Guacamole.SessionRecording = function SessionRecording(source) {
// Hide cursor unless mouse position is received
playbackClient.getDisplay().showCursor(false);
+ /**
+ * A key event interpreter to split all key events in this recording into
+ * human-readable batches of text. Constrcution is deferred until the first
+ * event is processed, to enable recording-relative timestamps.
+ *
+ * @type {!Guacamole.KeyEventInterpreter}
+ */
+ var keyEventInterpreter = null;
+
+ /**
+ * Initialize the key interpreter. This function should be called only once
+ * with the first timestamp in the recording as an argument.
+ *
+ * @private
+ * @param {!number} startTimestamp
+ * The timestamp of the first frame in the recording, i.e. the start of
+ * the recording.
+ */
+ function initializeKeyInterpreter(startTimestamp) {
+ keyEventInterpreter = new Guacamole.KeyEventInterpreter(startTimestamp);
+ }
+
/**
* Handles a newly-received instruction, whether from the main Blob or a
* tunnel, adding new frames and keyframes as necessary. Load progress is
@@ -12713,6 +13515,11 @@ Guacamole.SessionRecording = function SessionRecording(source) {
frames.push(frame);
frameStart = frameEnd;
+ // If this is the first frame, intialize the key event interpreter
+ // with the timestamp of the first frame
+ if(frames.length === 1)
+ initializeKeyInterpreter(timestamp);
+
// This frame should eventually become a keyframe if enough data
// has been processed and enough recording time has elapsed, or if
// this is the absolute first frame
@@ -12720,14 +13527,15 @@ Guacamole.SessionRecording = function SessionRecording(source) {
&& timestamp - frames[lastKeyframe].timestamp >= KEYFRAME_TIME_INTERVAL)) {
frame.keyframe = true;
lastKeyframe = frames.length - 1;
+
}
// Notify that additional content is available
if(recording.onprogress)
recording.onprogress(recording.getDuration(), frameEnd);
- }
-
+ } else if(opcode === 'key')
+ keyEventInterpreter.handleKeyEvent(args);
};
/**
@@ -12794,6 +13602,11 @@ Guacamole.SessionRecording = function SessionRecording(source) {
instructionBuffer = '';
}
+ // Now that the recording is fully processed, and all key events
+ // have been extracted, call the onkeyevents handler if defined
+ if(recording.onkeyevents)
+ recording.onkeyevents(keyEventInterpreter.getEvents());
+
// Consider recording loaded if tunnel has closed without errors
if(!errorEncountered)
notifyLoaded();
@@ -12826,8 +13639,9 @@ Guacamole.SessionRecording = function SessionRecording(source) {
};
/**
- * Searches through the given region of frames for the frame having a
- * relative timestamp closest to the timestamp given.
+ * Searches through the given region of frames for the closest frame
+ * having a relative timestamp less than or equal to the to the given
+ * relative timestamp.
*
* @private
* @param {!number} minIndex
@@ -12848,9 +13662,22 @@ Guacamole.SessionRecording = function SessionRecording(source) {
*/
var findFrame = function findFrame(minIndex, maxIndex, timestamp) {
- // Do not search if the region contains only one element
- if(minIndex === maxIndex)
- return minIndex;
+ // The region has only one frame - determine if it is before or after
+ // the requested timestamp
+ if(minIndex === maxIndex) {
+
+ // Skip checking if this is the very first frame - no frame could
+ // possibly be earlier
+ if(minIndex === 0)
+ return minIndex;
+
+ // If the closest frame occured after the requested timestamp,
+ // return the previous frame, which will be the closest with a
+ // timestamp before the requested timestamp
+ if(toRelativeTimestamp(frames[minIndex].timestamp) > timestamp)
+ return minIndex - 1;
+
+ }
// Split search region into two halves
var midIndex = Math.floor((minIndex + maxIndex) / 2);
@@ -12943,10 +13770,11 @@ Guacamole.SessionRecording = function SessionRecording(source) {
// Replay any applicable incremental frames
var continueReplay = function continueReplay() {
- // Notify of changes in position
+ // Set the current position and notify changes
if(recording.onseek && currentFrame > startIndex) {
- recording.onseek(toRelativeTimestamp(frames[currentFrame].timestamp),
- currentFrame - startIndex, index - startIndex);
+ currentPosition = toRelativeTimestamp(frames[currentFrame].timestamp);
+ recording.onseek(currentPosition, currentFrame - startIndex,
+ index - startIndex);
}
// Cancel seek if aborted
@@ -12967,9 +13795,18 @@ Guacamole.SessionRecording = function SessionRecording(source) {
// immediately if no delay was requested
var continueAfterRequiredDelay = function continueAfterRequiredDelay() {
var delay = nextRealTimestamp ? Math.max(nextRealTimestamp - new Date().getTime(), 0) : 0;
- if(delay)
- window.setTimeout(continueReplay, delay);
- else
+ if(delay) {
+
+ // Clear any already-scheduled update before scheduling again
+ // to avoid multiple updates in flight at the same time
+ updateTimeout && clearTimeout(updateTimeout);
+
+ // Schedule with the appropriate delay
+ updateTimeout = window.setTimeout(function timeoutComplete() {
+ updateTimeout = null;
+ continueReplay();
+ }, delay);
+ } else
continueReplay();
};
@@ -13022,20 +13859,73 @@ Guacamole.SessionRecording = function SessionRecording(source) {
*/
var continuePlayback = function continuePlayback() {
+ // Do not continue playback if the recording is paused
+ if(!recording.isPlaying())
+ return;
+
// If frames remain after advancing, schedule next frame
if(currentFrame + 1 < frames.length) {
// Pull the upcoming frame
var next = frames[currentFrame + 1];
- // Calculate the real timestamp corresponding to when the next
- // frame begins
- var nextRealTimestamp = next.timestamp - startVideoTimestamp + startRealTimestamp;
+ // The number of elapsed milliseconds on the clock since playback began
+ var realLifePlayTime = Date.now() - startRealTimestamp;
- // Advance to next frame after enough time has elapsed
- seekToFrame(currentFrame + 1, function frameDelayElapsed() {
- continuePlayback();
- }, nextRealTimestamp);
+ // The number of milliseconds between the recording position when
+ // playback started and the position of the next frame
+ var timestampOffset = (
+ toRelativeTimestamp(next.timestamp) - startVideoPosition);
+
+ // The delay until the next frame should be rendered, taking into
+ // account any accumulated delays from rendering frames so far
+ var nextFrameDelay = timestampOffset - realLifePlayTime;
+
+ // The delay until the refresh interval would induce an update to
+ // the current recording position, rounded to the nearest whole
+ // multiple of refreshInterval to ensure consistent timing for
+ // refresh intervals even with inconsistent frame timing
+ var nextRefreshDelay = refreshInterval >= 0
+ ? (refreshInterval * (Math.floor(
+ (currentPosition + refreshInterval) / refreshInterval))
+ ) - currentPosition
+ : nextFrameDelay;
+
+ // If the next frame will occur before the next refresh interval,
+ // advance to the frame after the appropriate delay
+ if(nextFrameDelay <= nextRefreshDelay)
+
+ seekToFrame(currentFrame + 1, function frameDelayElapsed() {
+
+ // Record when the timestamp was updated and continue on
+ lastUpdateTimestamp = Date.now();
+ continuePlayback();
+
+ }, Date.now() + nextFrameDelay);
+
+ // The position needs to be incremented before the next frame
+ else {
+
+ // Clear any existing update timeout
+ updateTimeout && window.clearTimeout(updateTimeout);
+
+ updateTimeout = window.setTimeout(function incrementPosition() {
+
+ updateTimeout = null;
+
+ // Update the position
+ currentPosition += nextRefreshDelay;
+
+ // Notifiy the new position using the onseek handler
+ if(recording.onseek)
+ recording.onseek(currentPosition);
+
+ // Record when the timestamp was updated and continue on
+ lastUpdateTimestamp = Date.now();
+ continuePlayback();
+
+ }, nextRefreshDelay);
+ }
}
@@ -13101,6 +13991,17 @@ Guacamole.SessionRecording = function SessionRecording(source) {
*/
this.onpause = null;
+ /**
+ * Fired with all extracted key events when the recording is fully
+ * processed. The callback will be invoked with an empty list
+ * if no key events were extracted.
+ *
+ * @event
+ * @param {!Guacamole.KeyEventInterpreter.KeyEvent[]} batch
+ * The extracted key events.
+ */
+ this.onkeyevents = null;
+
/**
* Fired whenever the playback position within the recording changes.
*
@@ -13183,7 +14084,7 @@ Guacamole.SessionRecording = function SessionRecording(source) {
* true if playback is currently in progress, false otherwise.
*/
this.isPlaying = function isPlaying() {
- return !!startVideoTimestamp;
+ return !!startRealTimestamp;
};
/**
@@ -13195,13 +14096,7 @@ Guacamole.SessionRecording = function SessionRecording(source) {
*/
this.getPosition = function getPosition() {
- // Position is simply zero if playback has not started at all
- if(currentFrame === -1)
- return 0;
-
- // Return current position as a millisecond timestamp relative to the
- // start of the recording
- return toRelativeTimestamp(frames[currentFrame].timestamp);
+ return currentPosition;
};
@@ -13245,11 +14140,11 @@ Guacamole.SessionRecording = function SessionRecording(source) {
// Store timestamp of playback start for relative scheduling of
// future frames
- var next = frames[currentFrame + 1];
- startVideoTimestamp = next.timestamp;
- startRealTimestamp = new Date().getTime();
+ startVideoPosition = currentPosition;
+ startRealTimestamp = Date.now();
// Begin playback of video
+ lastUpdateTimestamp = Date.now();
continuePlayback();
}
@@ -13301,8 +14196,23 @@ Guacamole.SessionRecording = function SessionRecording(source) {
};
- // Perform seek
- seekToFrame(findFrame(0, frames.length - 1, position), seekCallback);
+ // Find the index of the closest frame at or before the requested position
+ var closestFrame = findFrame(0, frames.length - 1, position);
+
+ // Seek to the closest frame before or at the requested position
+ seekToFrame(closestFrame, function seekComplete() {
+
+ // Update the current position to the requested position
+ // and invoke the the onseek callback. Note that this is the
+ // position provided to this function, NOT the position of the
+ // frame that was just seeked
+ currentPosition = position;
+ if(recording.onseek)
+ recording.onseek(position);
+
+ seekCallback();
+
+ });
};
@@ -13332,6 +14242,13 @@ Guacamole.SessionRecording = function SessionRecording(source) {
// Abort any in-progress seek / playback
abortSeek();
+ // Cancel any currently-scheduled updates
+ updateTimeout && clearTimeout(updateTimeout);
+
+ // Increment the current position by the amount of time passed since the
+ // the last time it was updated
+ currentPosition += Date.now() - lastUpdateTimestamp;
+
// Stop playback only if playback is in progress
if(recording.isPlaying()) {
@@ -13340,7 +14257,8 @@ Guacamole.SessionRecording = function SessionRecording(source) {
recording.onpause();
// Playback is stopped
- startVideoTimestamp = null;
+ lastUpdateTimestamp = null;
+ startVideoPosition = null;
startRealTimestamp = null;
}
@@ -13469,6 +14387,7 @@ Guacamole.SessionRecording._PlaybackTunnel = function _PlaybackTunnel() {
};
};
+
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -15907,7 +16826,7 @@ var Guacamole = Guacamole || {};
*
* @type {!string}
*/
-Guacamole.API_VERSION = '1.5.4';
+Guacamole.API_VERSION = '1.6.0';
/*
* Licensed to the Apache Software Foundation (ASF) under one