Source: SessionRecording.js

/*
 * 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 || {};

/**
 * A recording of a Guacamole session. Given a {@link Guacamole.Tunnel}, the
 * Guacamole.SessionRecording automatically handles incoming Guacamole
 * instructions, storing them for playback. Playback of the recording may be
 * controlled through function calls to the Guacamole.SessionRecording, even
 * while the recording has not yet finished being created or downloaded.
 *
 * @constructor
 * @param {Guacamole.Tunnel} tunnel
 *     The Guacamole.Tunnel from which the instructions of the recording should
 *     be read.
 */
Guacamole.SessionRecording = function SessionRecording(tunnel) {

    /**
     * Reference to this Guacamole.SessionRecording.
     *
     * @private
     * @type {Guacamole.SessionRecording}
     */
    var recording = this;

    /**
     * The minimum number of characters which must have been read between
     * keyframes.
     *
     * @private
     * @constant
     * @type {Number}
     */
    var KEYFRAME_CHAR_INTERVAL = 16384;

    /**
     * The minimum number of milliseconds which must elapse between keyframes.
     *
     * @private
     * @constant
     * @type {Number}
     */
    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.
     *
     * @private
     * @type {Guacamole.SessionRecording._Frame[]}
     */
    var frames = [];

    /**
     * All instructions which have been read since the last frame was added to
     * the frames array.
     *
     * @private
     * @type {Guacamole.SessionRecording._Frame.Instruction[]}
     */
    var instructions = [];

    /**
     * The approximate number of characters which have been read from the
     * provided tunnel since the last frame was flagged for use as a keyframe.
     *
     * @private
     * @type {Number}
     */
    var charactersSinceLastKeyframe = 0;

    /**
     * The timestamp of the last frame which was flagged for use as a keyframe.
     * If no timestamp has yet been flagged, this will be 0.
     *
     * @private
     * @type {Number}
     */
    var lastKeyframeTimestamp = 0;

    /**
     * Tunnel which feeds arbitrary instructions to the client used by this
     * Guacamole.SessionRecording for playback of the session recording.
     *
     * @private
     * @type {Guacamole.SessionRecording._PlaybackTunnel}
     */
    var playbackTunnel = new Guacamole.SessionRecording._PlaybackTunnel();

    /**
     * Guacamole.Client instance used for visible playback of the session
     * recording.
     *
     * @private
     * @type {Guacamole.Client}
     */
    var playbackClient = new Guacamole.Client(playbackTunnel);

    /**
     * The current frame rendered within the playback client. If no frame is
     * yet rendered, this will be -1.
     *
     * @private
     * @type {Number}
     */
    var currentFrame = -1;

    /**
     * The timestamp of the frame when playback began, in milliseconds. If
     * playback is not in progress, this will be null.
     *
     * @private
     * @type {Number}
     */
    var startVideoTimestamp = null;

    /**
     * The real-world timestamp when playback began, in milliseconds. If
     * playback is not in progress, this will be null.
     *
     * @private
     * @type {Number}
     */
    var startRealTimestamp = null;

    /**
     * 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 seekTimeout = null;

    // Start playback client connected
    playbackClient.connect();

    // Hide cursor unless mouse position is received
    playbackClient.getDisplay().showCursor(false);

    // Read instructions from provided tunnel, extracting each frame
    tunnel.oninstruction = function handleInstruction(opcode, args) {

        // Store opcode and arguments for received instruction
        var instruction = new Guacamole.SessionRecording._Frame.Instruction(opcode, args.slice());
        instructions.push(instruction);
        charactersSinceLastKeyframe += instruction.getSize();

        // Once a sync is received, store all instructions since the last
        // frame as a new frame
        if (opcode === 'sync') {

            // Parse frame timestamp from sync instruction
            var timestamp = parseInt(args[0]);

            // Add a new frame containing the instructions read since last frame
            var frame = new Guacamole.SessionRecording._Frame(timestamp, instructions);
            frames.push(frame);

            // 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
            if (frames.length === 1 || (charactersSinceLastKeyframe >= KEYFRAME_CHAR_INTERVAL
                    && timestamp - lastKeyframeTimestamp >= KEYFRAME_TIME_INTERVAL)) {
                frame.keyframe = true;
                lastKeyframeTimestamp = timestamp;
                charactersSinceLastKeyframe = 0;
            }

            // Clear set of instructions in preparation for next frame
            instructions = [];

            // Notify that additional content is available
            if (recording.onprogress)
                recording.onprogress(recording.getDuration());

        }

    };

    /**
     * Converts the given absolute timestamp to a timestamp which is relative
     * to the first frame in the recording.
     *
     * @private
     * @param {Number} timestamp
     *     The timestamp to convert to a relative timestamp.
     *
     * @returns {Number}
     *     The difference in milliseconds between the given timestamp and the
     *     first frame of the recording, or zero if no frames yet exist.
     */
    var toRelativeTimestamp = function toRelativeTimestamp(timestamp) {

        // If no frames yet exist, all timestamps are zero
        if (frames.length === 0)
            return 0;

        // Calculate timestamp relative to first frame
        return timestamp - frames[0].timestamp;

    };

    /**
     * Searches through the given region of frames for the frame having a
     * relative timestamp closest to the timestamp given.
     *
     * @private
     * @param {Number} minIndex
     *     The index of the first frame in the region (the frame having the
     *     smallest timestamp).
     *
     * @param {Number} maxIndex
     *     The index of the last frame in the region (the frame having the
     *     largest timestamp).
     *
     * @param {Number} timestamp
     *     The relative timestamp to search for, where zero denotes the first
     *     frame in the recording.
     *
     * @returns {Number}
     *     The index of the frame having a relative timestamp closest to the
     *     given value.
     */
    var findFrame = function findFrame(minIndex, maxIndex, timestamp) {

        // Do not search if the region contains only one element
        if (minIndex === maxIndex)
            return minIndex;

        // Split search region into two halves
        var midIndex = Math.floor((minIndex + maxIndex) / 2);
        var midTimestamp = toRelativeTimestamp(frames[midIndex].timestamp);

        // If timestamp is within lesser half, search again within that half
        if (timestamp < midTimestamp && midIndex > minIndex)
            return findFrame(minIndex, midIndex - 1, timestamp);

        // If timestamp is within greater half, search again within that half
        if (timestamp > midTimestamp && midIndex < maxIndex)
            return findFrame(midIndex + 1, maxIndex, timestamp);

        // Otherwise, we lucked out and found a frame with exactly the
        // desired timestamp
        return midIndex;

    };

    /**
     * Replays the instructions associated with the given frame, sending those
     * instructions to the playback client.
     *
     * @private
     * @param {Number} index
     *     The index of the frame within the frames array which should be
     *     replayed.
     */
    var replayFrame = function replayFrame(index) {

        var frame = frames[index];

        // Replay all instructions within the retrieved frame
        for (var i = 0; i < frame.instructions.length; i++) {
            var instruction = frame.instructions[i];
            playbackTunnel.receiveInstruction(instruction.opcode, instruction.args);
        }

        // Store client state if frame is flagged as a keyframe
        if (frame.keyframe && !frame.clientState) {
            playbackClient.exportState(function storeClientState(state) {
                frame.clientState = state;
            });
        }

    };

    /**
     * Moves the playback position to the given frame, resetting the state of
     * 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, callback, delay) {

        // Abort any in-progress seek
        abortSeek();

        // Replay frames asynchronously
        seekTimeout = window.setTimeout(function continueSeek() {

            var startIndex;

            // 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;
                }

            }

            // Advance to frame index after current state
            startIndex++;

            var startTime = new Date().getTime();

            // Replay any applicable incremental frames
            for (; startIndex <= index; startIndex++) {

                // Stop seeking if the operation is taking too long
                var currentTime = new Date().getTime();
                if (currentTime - startTime >= MAXIMUM_SEEK_TIME)
                    break;

                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);
    };

    /**
     * Advances playback to the next frame in the frames array and schedules
     * playback of the frame following that frame based on their associated
     * timestamps. If no frames exist after the next frame, playback is paused.
     *
     * @private
     */
    var continuePlayback = function continuePlayback() {

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

            // Calculate the relative delay between the current time and
            // the next frame start
            var delay = Math.max(nextRealTimestamp - new Date().getTime(), 0);

            // Advance to next frame after enough time has elapsed
            seekToFrame(currentFrame + 1, function frameDelayElapsed() {
                continuePlayback();
            }, delay);

        }

        // Otherwise stop playback
        else
            recording.pause();

    };

    /**
     * Fired when new frames have become available while the recording is
     * being downloaded.
     *
     * @event
     * @param {Number} duration
     *     The new duration of the recording, in milliseconds.
     */
    this.onprogress = null;

    /**
     * Fired whenever playback of the recording has started.
     *
     * @event
     */
    this.onplay = null;

    /**
     * Fired whenever playback of the recording has been paused. This may
     * happen when playback is explicitly paused with a call to pause(), or
     * when playback is implicitly paused due to reaching the end of the
     * recording.
     *
     * @event
     */
    this.onpause = null;

    /**
     * Fired whenever the playback position within the recording changes.
     *
     * @event
     * @param {Number} position
     *     The new position within the recording, in milliseconds.
     */
    this.onseek = null;

    /**
     * Connects the underlying tunnel, beginning download of the Guacamole
     * session. Playback of the Guacamole session cannot occur until at least
     * one frame worth of instructions has been downloaded.
     *
     * @param {String} data
     *     The data to send to the tunnel when connecting.
     */
    this.connect = function connect(data) {
        tunnel.connect(data);
    };

    /**
     * Disconnects the underlying tunnel, stopping further download of the
     * Guacamole session.
     */
    this.disconnect = function disconnect() {
        tunnel.disconnect();
    };

    /**
     * Returns the underlying display of the Guacamole.Client used by this
     * Guacamole.SessionRecording for playback. The display contains an Element
     * which can be added to the DOM, causing the display (and thus playback of
     * the recording) to become visible.
     *
     * @return {Guacamole.Display}
     *     The underlying display of the Guacamole.Client used by this
     *     Guacamole.SessionRecording for playback.
     */
    this.getDisplay = function getDisplay() {
        return playbackClient.getDisplay();
    };

    /**
     * Returns whether playback is currently in progress.
     *
     * @returns {Boolean}
     *     true if playback is currently in progress, false otherwise.
     */
    this.isPlaying = function isPlaying() {
        return !!startVideoTimestamp;
    };

    /**
     * Returns the current playback position within the recording, in
     * milliseconds, where zero is the start of the recording.
     *
     * @returns {Number}
     *     The current playback position within the recording, in milliseconds.
     */
    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);

    };

    /**
     * Returns the duration of this recording, in milliseconds. If the
     * recording is still being downloaded, this value will gradually increase.
     *
     * @returns {Number}
     *     The duration of this recording, in milliseconds.
     */
    this.getDuration = function getDuration() {

        // If no frames yet exist, duration is zero
        if (frames.length === 0)
            return 0;

        // Recording duration is simply the timestamp of the last frame
        return toRelativeTimestamp(frames[frames.length - 1].timestamp);

    };

    /**
     * Begins continuous playback of the recording downloaded thus far.
     * Playback of the recording will continue until pause() is invoked or
     * 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. 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() {

        // If playback is not already in progress and frames remain,
        // begin playback
        if (!recording.isPlaying() && currentFrame + 1 < frames.length) {

            // Notify that playback is starting
            if (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
            continuePlayback();

        }

    };

    /**
     * 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. 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, callback) {

        // Do not seek if no frames exist
        if (frames.length === 0)
            return;

        // Pause playback, preserving playback state
        var originallyPlaying = recording.isPlaying();
        recording.pause();

        // Perform seek
        seekToFrame(findFrame(0, frames.length - 1, position), function restorePlaybackState() {

            // 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. 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()) {

            // Notify that playback is stopping
            if (recording.onpause)
                recording.onpause();

            // Playback is stopped
            startVideoTimestamp = null;
            startRealTimestamp = null;

        }

    };

};

/**
 * A single frame of Guacamole session data. Each frame is made up of the set
 * of instructions used to generate that frame, and the timestamp as dictated
 * by the "sync" instruction terminating the frame. Optionally, a frame may
 * also be associated with a snapshot of Guacamole client state, such that the
 * frame can be rendered without replaying all previous frames.
 *
 * @private
 * @constructor
 * @param {Number} timestamp
 *     The timestamp of this frame, as dictated by the "sync" instruction which
 *     terminates the frame.
 *
 * @param {Guacamole.SessionRecording._Frame.Instruction[]} instructions
 *     All instructions which are necessary to generate this frame relative to
 *     the previous frame in the Guacamole session.
 */
Guacamole.SessionRecording._Frame = function _Frame(timestamp, instructions) {

    /**
     * Whether this frame should be used as a keyframe if possible. This value
     * is purely advisory. The stored clientState must eventually be manually
     * set for the frame to be used as a keyframe. By default, frames are not
     * keyframes.
     *
     * @type {Boolean}
     * @default false
     */
    this.keyframe = false;

    /**
     * The timestamp of this frame, as dictated by the "sync" instruction which
     * terminates the frame.
     *
     * @type {Number}
     */
    this.timestamp = timestamp;

    /**
     * All instructions which are necessary to generate this frame relative to
     * the previous frame in the Guacamole session.
     *
     * @type {Guacamole.SessionRecording._Frame.Instruction[]}
     */
    this.instructions = instructions;

    /**
     * A snapshot of client state after this frame was rendered, as returned by
     * a call to exportState(). If no such snapshot has been taken, this will
     * be null.
     *
     * @type {Object}
     * @default null
     */
    this.clientState = null;

};

/**
 * A Guacamole protocol instruction. Each Guacamole protocol instruction is
 * made up of an opcode and set of arguments.
 *
 * @private
 * @constructor
 * @param {String} opcode
 *     The opcode of this Guacamole instruction.
 *
 * @param {String[]} args
 *     All arguments associated with this Guacamole instruction.
 */
Guacamole.SessionRecording._Frame.Instruction = function Instruction(opcode, args) {

    /**
     * Reference to this Guacamole.SessionRecording._Frame.Instruction.
     *
     * @private
     * @type {Guacamole.SessionRecording._Frame.Instruction}
     */
    var instruction = this;

    /**
     * The opcode of this Guacamole instruction.
     *
     * @type {String}
     */
    this.opcode = opcode;

    /**
     * All arguments associated with this Guacamole instruction.
     *
     * @type {String[]}
     */
    this.args = args;

    /**
     * Returns the approximate number of characters which make up this
     * instruction. This value is only approximate as it excludes the length
     * prefixes and various delimiters used by the Guacamole protocol; only
     * the content of the opcode and each argument is taken into account.
     *
     * @returns {Number}
     *     The approximate size of this instruction, in characters.
     */
    this.getSize = function getSize() {

        // Init with length of opcode
        var size = instruction.opcode.length;

        // Add length of all arguments
        for (var i = 0; i < instruction.args.length; i++)
            size += instruction.args[i].length;

        return size;

    };

};

/**
 * A read-only Guacamole.Tunnel implementation which streams instructions
 * received through explicit calls to its receiveInstruction() function.
 *
 * @private
 * @constructor
 * @augments {Guacamole.Tunnel}
 */
Guacamole.SessionRecording._PlaybackTunnel = function _PlaybackTunnel() {

    /**
     * Reference to this Guacamole.SessionRecording._PlaybackTunnel.
     *
     * @private
     * @type {Guacamole.SessionRecording._PlaybackTunnel}
     */
    var tunnel = this;

    this.connect = function connect(data) {
        // Do nothing
    };

    this.sendMessage = function sendMessage(elements) {
        // Do nothing
    };

    this.disconnect = function disconnect() {
        // Do nothing
    };

    /**
     * Invokes this tunnel's oninstruction handler, notifying users of this
     * tunnel (such as a Guacamole.Client instance) that an instruction has
     * been received. If the oninstruction handler has not been set, this
     * function has no effect.
     *
     * @param {String} opcode
     *     The opcode of the Guacamole instruction.
     *
     * @param {String[]} args
     *     All arguments associated with this Guacamole instruction.
     */
    this.receiveInstruction = function receiveInstruction(opcode, args) {
        if (tunnel.oninstruction)
            tunnel.oninstruction(opcode, args);
    };

};