diff --git a/doc/guacamole-playback-example/src/main/webapp/playback.css b/doc/guacamole-playback-example/src/main/webapp/playback.css
index 210506f35..bda4d3a99 100644
--- a/doc/guacamole-playback-example/src/main/webapp/playback.css
+++ b/doc/guacamole-playback-example/src/main/webapp/playback.css
@@ -17,11 +17,46 @@
* under the License.
*/
-.player {
+#player {
width: 640px;
}
-.player .controls {
+#display {
+ position: relative;
+}
+
+#player .notification-container {
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ right: 0;
+ left: 0;
+ bottom: 0;
+}
+
+#player .seek-notification {
+
+ color: white;
+ background: rgba(0, 0, 0, 0.75);
+
+ display: none; /* Initially hidden */
+ width: 100%;
+ height: 100%;
+
+}
+
+#player.seeking .seek-notification {
+ display: table;
+}
+
+#player .seek-notification p {
+ display: table-cell;
+ text-align: center;
+ vertical-align: middle;
+ font-family: sans-serif;
+}
+
+#player .controls {
width: 100%;
@@ -52,11 +87,11 @@
}
-.player .controls > * {
+#player .controls > * {
margin: 0.25em;
}
-.player .controls #position-slider {
+#player .controls #position-slider {
-ms-flex: 1 1 auto;
-moz-box-flex: 1;
-webkit-box-flex: 1;
@@ -64,16 +99,16 @@
flex: 1 1 auto;
}
-.player .controls #play-pause {
+#player .controls #play-pause {
margin-left: 0;
min-width: 5em;
}
-.player .controls #position,
-.player .controls #duration {
+#player .controls #position,
+#player .controls #duration {
font-family: monospace;
}
-.player .controls #duration {
+#player .controls #duration {
margin-right: 0;
}
diff --git a/doc/guacamole-playback-example/src/main/webapp/playback.js b/doc/guacamole-playback-example/src/main/webapp/playback.js
index 170a2b5ed..6fb4c0ba8 100644
--- a/doc/guacamole-playback-example/src/main/webapp/playback.js
+++ b/doc/guacamole-playback-example/src/main/webapp/playback.js
@@ -27,6 +27,13 @@
*/
var RECORDING_URL = 'recording.guac';
+ /**
+ * The element representing the session recording player.
+ *
+ * @type Element
+ */
+ var player = document.getElementById('player');
+
/**
* The element which will contain the recording display.
*
@@ -41,6 +48,13 @@
*/
var playPause = document.getElementById('play-pause');
+ /**
+ * Button for cancelling in-progress seek operations.
+ *
+ * @type Element
+ */
+ var cancelSeek = document.getElementById('cancel-seek');
+
/**
* Text status display indicating the current playback position within the
* recording.
@@ -163,6 +177,13 @@
recording.pause();
};
+ // Resume playback when cancel button is clicked
+ cancelSeek.onclick = function cancelSeekOperation(e) {
+ recording.play();
+ player.className = '';
+ e.stopPropagation();
+ };
+
// Fit display within containing div
recordingDisplay.onresize = function displayResized(width, height) {
@@ -189,7 +210,18 @@
// Seek within recording if slider is moved
positionSlider.onchange = function sliderPositionChanged() {
- recording.seek(positionSlider.value);
+
+ // Request seek
+ recording.seek(positionSlider.value, function seekComplete() {
+
+ // Seek has completed
+ player.className = '';
+
+ });
+
+ // Seek is in progress
+ player.className = 'seeking';
+
};
})();
diff --git a/guacamole-common-js/src/main/webapp/modules/SessionRecording.js b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js
index 139597b1d..87be39c2e 100644
--- a/guacamole-common-js/src/main/webapp/modules/SessionRecording.js
+++ b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js
@@ -60,6 +60,17 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
*/
var KEYFRAME_TIME_INTERVAL = 5000;
+ /**
+ * The maximum amount of time to spend in any particular seek operation
+ * before returning control to the main thread, in milliseconds. Seek
+ * operations exceeding this amount of time will proceed asynchronously.
+ *
+ * @private
+ * @constant
+ * @type {Number}
+ */
+ var MAXIMUM_SEEK_TIME = 5;
+
/**
* All frames parsed from the provided tunnel.
*
@@ -141,14 +152,14 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
var startRealTimestamp = null;
/**
- * The ID of the timeout which will play the next frame, if playback is in
- * progress. If playback is not in progress, the ID stored here (if any)
- * will not be valid.
+ * The ID of the timeout which will continue the in-progress seek
+ * operation. If no seek operation is in progress, the ID stored here (if
+ * any) will not be valid.
*
* @private
* @type {Number}
*/
- var playbackTimeout = null;
+ var seekTimeout = null;
// Start playback client connected
playbackClient.connect();
@@ -294,50 +305,96 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
/**
* Moves the playback position to the given frame, resetting the state of
- * the playback client and replaying frames as necessary.
+ * the playback client and replaying frames as necessary. The seek
+ * operation will proceed asynchronously. If a seek operation is already in
+ * progress, that seek is first aborted. The progress of the seek operation
+ * can be observed through the onseek handler and the provided callback.
*
* @private
* @param {Number} index
* The index of the frame which should become the new playback
* position.
+ *
+ * @param {function} callback
+ * The callback to invoke once the seek operation has completed.
+ *
+ * @param {Number} [delay=0]
+ * The number of milliseconds that the seek operation should be
+ * scheduled to take.
*/
- var seekToFrame = function seekToFrame(index) {
+ var seekToFrame = function seekToFrame(index, callback, delay) {
- var startIndex;
+ // Abort any in-progress seek
+ abortSeek();
- // Back up until startIndex represents current state
- for (startIndex = index; startIndex >= 0; startIndex--) {
+ // Replay frames asynchronously
+ seekTimeout = window.setTimeout(function continueSeek() {
- var frame = frames[startIndex];
+ var startIndex;
- // If we've reached the current frame, startIndex represents
- // current state by definition
- if (startIndex === currentFrame)
- break;
+ // Back up until startIndex represents current state
+ for (startIndex = index; startIndex >= 0; startIndex--) {
+
+ var frame = frames[startIndex];
+
+ // If we've reached the current frame, startIndex represents
+ // current state by definition
+ if (startIndex === currentFrame)
+ break;
+
+ // If frame has associated absolute state, make that frame the
+ // current state
+ if (frame.clientState) {
+ playbackClient.importState(frame.clientState);
+ break;
+ }
- // If frame has associated absolute state, make that frame the
- // current state
- if (frame.clientState) {
- playbackClient.importState(frame.clientState);
- break;
}
- }
+ // Advance to frame index after current state
+ startIndex++;
- // Advance to frame index after current state
- startIndex++;
+ var startTime = new Date().getTime();
- // Replay any applicable incremental frames
- for (; startIndex <= index; startIndex++)
- replayFrame(startIndex);
+ // Replay any applicable incremental frames
+ for (; startIndex <= index; startIndex++) {
- // Current frame is now at requested index
- currentFrame = index;
+ // Stop seeking if the operation is taking too long
+ var currentTime = new Date().getTime();
+ if (currentTime - startTime >= MAXIMUM_SEEK_TIME)
+ break;
- // Notify of changes in position
- if (recording.onseek)
- recording.onseek(recording.getPosition());
+ replayFrame(startIndex);
+ }
+ // Current frame is now at requested index
+ currentFrame = startIndex - 1;
+
+ // Notify of changes in position
+ if (recording.onseek)
+ recording.onseek(recording.getPosition());
+
+ // If the seek operation has not yet completed, schedule continuation
+ if (currentFrame !== index)
+ seekToFrame(index, callback,
+ Math.max(delay - (new Date().getTime() - startTime), 0));
+
+ // Notify that the requested seek has completed
+ else
+ callback();
+
+ }, delay || 0);
+
+ };
+
+ /**
+ * Aborts the seek operation currently in progress, if any. If no seek
+ * operation is in progress, this function has no effect.
+ *
+ * @private
+ */
+ var abortSeek = function abortSeek() {
+ window.clearTimeout(seekTimeout);
};
/**
@@ -349,9 +406,6 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
*/
var continuePlayback = function continuePlayback() {
- // Advance to next frame
- seekToFrame(currentFrame + 1);
-
// If frames remain after advancing, schedule next frame
if (currentFrame + 1 < frames.length) {
@@ -367,7 +421,7 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
var delay = Math.max(nextRealTimestamp - new Date().getTime(), 0);
// Advance to next frame after enough time has elapsed
- playbackTimeout = window.setTimeout(function frameDelayElapsed() {
+ seekToFrame(currentFrame + 1, function frameDelayElapsed() {
continuePlayback();
}, delay);
@@ -502,7 +556,9 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
* until no further frames exist. Playback is initially paused when a
* Guacamole.SessionRecording is created, and must be explicitly started
* through a call to this function. If playback is already in progress,
- * this function has no effect.
+ * this function has no effect. If a seek operation is in progress,
+ * playback resumes at the current position, and the seek is aborted as if
+ * completed.
*/
this.play = function play() {
@@ -531,12 +587,17 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
* Seeks to the given position within the recording. If the recording is
* currently being played back, playback will continue after the seek is
* performed. If the recording is currently paused, playback will be
- * paused after the seek is performed.
+ * paused after the seek is performed. If a seek operation is already in
+ * progress, that seek is first aborted. The seek operation will proceed
+ * asynchronously.
*
* @param {Number} position
* The position within the recording to seek to, in milliseconds.
+ *
+ * @param {function} [callback]
+ * The callback to invoke once the seek operation has completed.
*/
- this.seek = function seek(position) {
+ this.seek = function seek(position, callback) {
// Do not seek if no frames exist
if (frames.length === 0)
@@ -547,22 +608,32 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
recording.pause();
// Perform seek
- seekToFrame(findFrame(0, frames.length - 1, position));
+ seekToFrame(findFrame(0, frames.length - 1, position), function restorePlaybackState() {
- // Restore playback state
- if (originallyPlaying)
- recording.play();
+ // Restore playback state
+ if (originallyPlaying)
+ recording.play();
+
+ // Notify that seek has completed
+ if (callback)
+ callback();
+
+ });
};
/**
* Pauses playback of the recording, if playback is currently in progress.
- * If playback is not in progress, this function has no effect. Playback is
- * initially paused when a Guacamole.SessionRecording is created, and must
- * be explicitly started through a call to play().
+ * If playback is not in progress, this function has no effect. If a seek
+ * operation is in progress, the seek is aborted. Playback is initially
+ * paused when a Guacamole.SessionRecording is created, and must be
+ * explicitly started through a call to play().
*/
this.pause = function pause() {
+ // Abort any in-progress seek / playback
+ abortSeek();
+
// Stop playback only if playback is in progress
if (recording.isPlaying()) {
@@ -570,8 +641,7 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
if (recording.onpause)
recording.onpause();
- // Stop playback
- window.clearTimeout(playbackTimeout);
+ // Playback is stopped
startVideoTimestamp = null;
startRealTimestamp = null;