GUACAMOLE-1803: Update session recording playback at least once per second and allow arbitrary seeking.

This commit is contained in:
James Muehlner
2023-06-02 23:12:05 +00:00
parent 466476412a
commit b8de8ebb90
2 changed files with 156 additions and 40 deletions

View File

@@ -32,8 +32,13 @@ var Guacamole = Guacamole || {};
* @param {!Blob|Guacamole.Tunnel} source * @param {!Blob|Guacamole.Tunnel} source
* The Blob from which the instructions of the recording should * The Blob from which the instructions of the recording should
* be read. * 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.
*/ */
Guacamole.SessionRecording = function SessionRecording(source) { Guacamole.SessionRecording = function SessionRecording(source, refreshInterval) {
/** /**
* Reference to this Guacamole.SessionRecording. * Reference to this Guacamole.SessionRecording.
@@ -139,22 +144,20 @@ Guacamole.SessionRecording = function SessionRecording(source) {
var currentFrame = -1; var currentFrame = -1;
/** /**
* The timestamp of the frame when playback began, in milliseconds. If * True if the player is currently playing, or false otherwise.
* playback is not in progress, this will be null.
* *
* @private * @private
* @type {number} * @type {boolean}
*/ */
var startVideoTimestamp = null; var currentlyPlaying = null;
/** /**
* The real-world timestamp when playback began, in milliseconds. If * The current position within the recording, in milliseconds.
* playback is not in progress, this will be null.
* *
* @private * @private
* @type {number} * @type {!number}
*/ */
var startRealTimestamp = null; var currentPosition = 0;
/** /**
* An object containing a single "aborted" property which is set to * An object containing a single "aborted" property which is set to
@@ -211,6 +214,25 @@ Guacamole.SessionRecording = function SessionRecording(source) {
*/ */
var seekCallback = null; 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 * Parses all Guacamole instructions within the given blob, invoking
* the provided instruction callback for each such instruction. Once * the provided instruction callback for each such instruction. Once
@@ -482,8 +504,9 @@ Guacamole.SessionRecording = function SessionRecording(source) {
}; };
/** /**
* Searches through the given region of frames for the frame having a * Searches through the given region of frames for the closest frame
* relative timestamp closest to the timestamp given. * having a relative timestamp less than or equal to the to the given
* relative timestamp.
* *
* @private * @private
* @param {!number} minIndex * @param {!number} minIndex
@@ -504,10 +527,23 @@ Guacamole.SessionRecording = function SessionRecording(source) {
*/ */
var findFrame = function findFrame(minIndex, maxIndex, timestamp) { var findFrame = function findFrame(minIndex, maxIndex, timestamp) {
// Do not search if the region contains only one element // The region has only one frame - determine if it is before or after
if (minIndex === maxIndex) // 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; 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 // Split search region into two halves
var midIndex = Math.floor((minIndex + maxIndex) / 2); var midIndex = Math.floor((minIndex + maxIndex) / 2);
var midTimestamp = toRelativeTimestamp(frames[midIndex].timestamp); var midTimestamp = toRelativeTimestamp(frames[midIndex].timestamp);
@@ -599,10 +635,11 @@ Guacamole.SessionRecording = function SessionRecording(source) {
// Replay any applicable incremental frames // Replay any applicable incremental frames
var continueReplay = function continueReplay() { var continueReplay = function continueReplay() {
// Notify of changes in position // Set the current position and notify changes
if (recording.onseek && currentFrame > startIndex) { if (recording.onseek && currentFrame > startIndex) {
recording.onseek(toRelativeTimestamp(frames[currentFrame].timestamp), currentPosition = toRelativeTimestamp(frames[currentFrame].timestamp);
currentFrame - startIndex, index - startIndex); recording.onseek(currentPosition, currentFrame - startIndex,
index - startIndex);
} }
// Cancel seek if aborted // Cancel seek if aborted
@@ -623,8 +660,18 @@ Guacamole.SessionRecording = function SessionRecording(source) {
// immediately if no delay was requested // immediately if no delay was requested
var continueAfterRequiredDelay = function continueAfterRequiredDelay() { var continueAfterRequiredDelay = function continueAfterRequiredDelay() {
var delay = nextRealTimestamp ? Math.max(nextRealTimestamp - new Date().getTime(), 0) : 0; var delay = nextRealTimestamp ? Math.max(nextRealTimestamp - new Date().getTime(), 0) : 0;
if (delay) if (delay) {
window.setTimeout(continueReplay, 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 else
continueReplay(); continueReplay();
}; };
@@ -678,20 +725,62 @@ Guacamole.SessionRecording = function SessionRecording(source) {
*/ */
var continuePlayback = function continuePlayback() { 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 frames remain after advancing, schedule next frame
if (currentFrame + 1 < frames.length) { if (currentFrame + 1 < frames.length) {
// Pull the upcoming frame // Pull the upcoming frame
var next = frames[currentFrame + 1]; var next = frames[currentFrame + 1];
// Calculate the real timestamp corresponding to when the next // The position at which the next frame should be rendered
// frame begins var nextFramePosition = toRelativeTimestamp(next.timestamp);
var nextRealTimestamp = next.timestamp - startVideoTimestamp + startRealTimestamp;
// The position at which 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 nextRefreshPosition = refreshInterval > 0 ? refreshInterval * (
Math.floor((currentPosition + refreshInterval) / refreshInterval)
) : nextFramePosition;
// If the next frame will occur before the next refresh interval,
// advance to the frame after the appropriate delay
if (nextFramePosition <= nextRefreshPosition)
// Advance to next frame after enough time has elapsed
seekToFrame(currentFrame + 1, function frameDelayElapsed() { seekToFrame(currentFrame + 1, function frameDelayElapsed() {
// Record when the timestamp was updated and continue on
lastUpdateTimestamp = Date.now();
continuePlayback(); continuePlayback();
}, nextRealTimestamp);
}, Date.now() + (nextFramePosition - currentPosition));
// 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 = nextRefreshPosition;
// 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();
}, nextRefreshPosition - currentPosition);
}
} }
@@ -839,7 +928,7 @@ Guacamole.SessionRecording = function SessionRecording(source) {
* true if playback is currently in progress, false otherwise. * true if playback is currently in progress, false otherwise.
*/ */
this.isPlaying = function isPlaying() { this.isPlaying = function isPlaying() {
return !!startVideoTimestamp; return !!currentlyPlaying;
}; };
/** /**
@@ -899,13 +988,9 @@ Guacamole.SessionRecording = function SessionRecording(source) {
if (recording.onplay) if (recording.onplay)
recording.onplay(); recording.onplay();
// Store timestamp of playback start for relative scheduling of
// future frames
var next = frames[currentFrame + 1];
startVideoTimestamp = next.timestamp;
startRealTimestamp = new Date().getTime();
// Begin playback of video // Begin playback of video
currentlyPlaying = true;
lastUpdateTimestamp = Date.now();
continuePlayback(); continuePlayback();
} }
@@ -957,8 +1042,23 @@ Guacamole.SessionRecording = function SessionRecording(source) {
}; };
// Perform seek // Find the index of the closest frame at or before the requested position
seekToFrame(findFrame(0, frames.length - 1, position), seekCallback); 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();
});
}; };
@@ -988,6 +1088,14 @@ Guacamole.SessionRecording = function SessionRecording(source) {
// Abort any in-progress seek / playback // Abort any in-progress seek / playback
abortSeek(); 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;
lastUpdateTimestamp = null;
// Stop playback only if playback is in progress // Stop playback only if playback is in progress
if (recording.isPlaying()) { if (recording.isPlaying()) {
@@ -996,8 +1104,7 @@ Guacamole.SessionRecording = function SessionRecording(source) {
recording.onpause(); recording.onpause();
// Playback is stopped // Playback is stopped
startVideoTimestamp = null; currentlyPlaying = false;
startRealTimestamp = null;
} }

View File

@@ -96,6 +96,14 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay
config.controller = ['$scope', '$element', '$injector', config.controller = ['$scope', '$element', '$injector',
function guacPlayerController($scope) { function guacPlayerController($scope) {
/**
* The minimum number of milliseconds that should occur between updates
* to the progress indicator.
*
* @type {number}
*/
const PROGRESS_REFRESH_INTERVAL = 1000;
/** /**
* Guacamole.SessionRecording instance to be used to playback the * Guacamole.SessionRecording instance to be used to playback the
* session recording given via $scope.src. If the recording has not * session recording given via $scope.src. If the recording has not
@@ -303,7 +311,8 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay
// Otherwise, begin loading the provided recording // Otherwise, begin loading the provided recording
else { else {
$scope.recording = new Guacamole.SessionRecording(src); $scope.recording = new Guacamole.SessionRecording(
src, PROGRESS_REFRESH_INTERVAL);
// Begin downloading the recording // Begin downloading the recording
$scope.recording.connect(); $scope.recording.connect();