GUACAMOLE-346: Merge Avoid blocking main thread when seeking.

This commit is contained in:
Nick Couchman
2017-07-19 14:35:44 -04:00
4 changed files with 203 additions and 57 deletions

View File

@@ -28,10 +28,19 @@
<body> <body>
<!-- Guacamole recording player --> <!-- Guacamole recording player -->
<div class="player"> <div id="player">
<!-- Player display --> <!-- Player display -->
<div id="display"></div> <div id="display">
<div class="notification-container">
<div class="seek-notification">
<p>
Seek in progress...
<button id="cancel-seek">Cancel</button>
</p>
</div>
</div>
</div>
<!-- Player controls --> <!-- Player controls -->
<div class="controls"> <div class="controls">

View File

@@ -17,11 +17,46 @@
* under the License. * under the License.
*/ */
.player { #player {
width: 640px; 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%; width: 100%;
@@ -52,11 +87,11 @@
} }
.player .controls > * { #player .controls > * {
margin: 0.25em; margin: 0.25em;
} }
.player .controls #position-slider { #player .controls #position-slider {
-ms-flex: 1 1 auto; -ms-flex: 1 1 auto;
-moz-box-flex: 1; -moz-box-flex: 1;
-webkit-box-flex: 1; -webkit-box-flex: 1;
@@ -64,16 +99,16 @@
flex: 1 1 auto; flex: 1 1 auto;
} }
.player .controls #play-pause { #player .controls #play-pause {
margin-left: 0; margin-left: 0;
min-width: 5em; min-width: 5em;
} }
.player .controls #position, #player .controls #position,
.player .controls #duration { #player .controls #duration {
font-family: monospace; font-family: monospace;
} }
.player .controls #duration { #player .controls #duration {
margin-right: 0; margin-right: 0;
} }

View File

@@ -27,6 +27,13 @@
*/ */
var RECORDING_URL = 'recording.guac'; 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. * The element which will contain the recording display.
* *
@@ -41,6 +48,13 @@
*/ */
var playPause = document.getElementById('play-pause'); 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 * Text status display indicating the current playback position within the
* recording. * recording.
@@ -163,6 +177,13 @@
recording.pause(); 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 // Fit display within containing div
recordingDisplay.onresize = function displayResized(width, height) { recordingDisplay.onresize = function displayResized(width, height) {
@@ -189,7 +210,18 @@
// Seek within recording if slider is moved // Seek within recording if slider is moved
positionSlider.onchange = function sliderPositionChanged() { 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';
}; };
})(); })();

View File

@@ -60,6 +60,17 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
*/ */
var KEYFRAME_TIME_INTERVAL = 5000; 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. * All frames parsed from the provided tunnel.
* *
@@ -141,14 +152,14 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
var startRealTimestamp = null; var startRealTimestamp = null;
/** /**
* The ID of the timeout which will play the next frame, if playback is in * The ID of the timeout which will continue the in-progress seek
* progress. If playback is not in progress, the ID stored here (if any) * operation. If no seek operation is in progress, the ID stored here (if
* will not be valid. * any) will not be valid.
* *
* @private * @private
* @type {Number} * @type {Number}
*/ */
var playbackTimeout = null; var seekTimeout = null;
// Start playback client connected // Start playback client connected
playbackClient.connect(); playbackClient.connect();
@@ -294,50 +305,96 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
/** /**
* Moves the playback position to the given frame, resetting the state of * 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 * @private
* @param {Number} index * @param {Number} index
* The index of the frame which should become the new playback * The index of the frame which should become the new playback
* position. * 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 // Replay frames asynchronously
for (startIndex = index; startIndex >= 0; startIndex--) { seekTimeout = window.setTimeout(function continueSeek() {
var frame = frames[startIndex]; var startIndex;
// If we've reached the current frame, startIndex represents // Back up until startIndex represents current state
// current state by definition for (startIndex = index; startIndex >= 0; startIndex--) {
if (startIndex === currentFrame)
break; 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 var startTime = new Date().getTime();
startIndex++;
// Replay any applicable incremental frames // Replay any applicable incremental frames
for (; startIndex <= index; startIndex++) for (; startIndex <= index; startIndex++) {
replayFrame(startIndex);
// Current frame is now at requested index // Stop seeking if the operation is taking too long
currentFrame = index; var currentTime = new Date().getTime();
if (currentTime - startTime >= MAXIMUM_SEEK_TIME)
break;
// Notify of changes in position replayFrame(startIndex);
if (recording.onseek) }
recording.onseek(recording.getPosition());
// 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() { var continuePlayback = function continuePlayback() {
// Advance to next frame
seekToFrame(currentFrame + 1);
// If frames remain after advancing, schedule next frame // If frames remain after advancing, schedule next frame
if (currentFrame + 1 < frames.length) { if (currentFrame + 1 < frames.length) {
@@ -367,7 +421,7 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
var delay = Math.max(nextRealTimestamp - new Date().getTime(), 0); var delay = Math.max(nextRealTimestamp - new Date().getTime(), 0);
// Advance to next frame after enough time has elapsed // Advance to next frame after enough time has elapsed
playbackTimeout = window.setTimeout(function frameDelayElapsed() { seekToFrame(currentFrame + 1, function frameDelayElapsed() {
continuePlayback(); continuePlayback();
}, delay); }, delay);
@@ -502,7 +556,9 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
* until no further frames exist. Playback is initially paused when a * until no further frames exist. Playback is initially paused when a
* Guacamole.SessionRecording is created, and must be explicitly started * Guacamole.SessionRecording is created, and must be explicitly started
* through a call to this function. If playback is already in progress, * 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() { 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 * Seeks to the given position within the recording. If the recording is
* currently being played back, playback will continue after the seek is * currently being played back, playback will continue after the seek is
* performed. If the recording is currently paused, playback will be * 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 * @param {Number} position
* The position within the recording to seek to, in milliseconds. * 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 // Do not seek if no frames exist
if (frames.length === 0) if (frames.length === 0)
@@ -547,22 +608,32 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
recording.pause(); recording.pause();
// Perform seek // Perform seek
seekToFrame(findFrame(0, frames.length - 1, position)); seekToFrame(findFrame(0, frames.length - 1, position), function restorePlaybackState() {
// Restore playback state // Restore playback state
if (originallyPlaying) if (originallyPlaying)
recording.play(); recording.play();
// Notify that seek has completed
if (callback)
callback();
});
}; };
/** /**
* Pauses playback of the recording, if playback is currently in progress. * Pauses playback of the recording, if playback is currently in progress.
* If playback is not in progress, this function has no effect. Playback is * If playback is not in progress, this function has no effect. If a seek
* initially paused when a Guacamole.SessionRecording is created, and must * operation is in progress, the seek is aborted. Playback is initially
* be explicitly started through a call to play(). * paused when a Guacamole.SessionRecording is created, and must be
* explicitly started through a call to play().
*/ */
this.pause = function pause() { this.pause = function pause() {
// Abort any in-progress seek / playback
abortSeek();
// Stop playback only if playback is in progress // Stop playback only if playback is in progress
if (recording.isPlaying()) { if (recording.isPlaying()) {
@@ -570,8 +641,7 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
if (recording.onpause) if (recording.onpause)
recording.onpause(); recording.onpause();
// Stop playback // Playback is stopped
window.clearTimeout(playbackTimeout);
startVideoTimestamp = null; startVideoTimestamp = null;
startRealTimestamp = null; startRealTimestamp = null;