diff --git a/doc/guacamole-playback-example/src/main/webapp/index.html b/doc/guacamole-playback-example/src/main/webapp/index.html index 69e79a963..c5ff62123 100644 --- a/doc/guacamole-playback-example/src/main/webapp/index.html +++ b/doc/guacamole-playback-example/src/main/webapp/index.html @@ -28,10 +28,19 @@ -
+
-
+
+
+
+

+ Seek in progress... + +

+
+
+
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;