Source: main/webapp/modules/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} or Blob,
 * the Guacamole.SessionRecording automatically parses Guacamole instructions
 * within the recording source as it plays back the recording. 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. Parsing of the contents of the recording will
 * begin immediately and automatically after this constructor is invoked.
 *
 * @constructor
 * @param {!Blob|Guacamole.Tunnel} source
 *     The Blob from which the instructions of the recording should
 *     be read.
 */
Guacamole.SessionRecording = function SessionRecording(source) {

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

    /**
     * The Blob from which the instructions of the recording should be read.
     * Note that this value is initialized far below.
     *
     * @private
     * @type {!Blob}
     */
    var recordingBlob;

    /**
     * The tunnel from which the recording should be read, if the recording is
     * being read from a tunnel. If the recording was supplied as a Blob, this
     * will be null.
     *
     * @private
     * @type {Guacamole.Tunnel}
     */
    var tunnel = null;

    /**
     * The number of bytes that this Guacamole.SessionRecording should attempt
     * to read from the given blob in each read operation. Larger blocks will
     * generally read the blob more quickly, but may result in excessive
     * time being spent within the parser, making the page unresponsive
     * while the recording is loading.
     *
     * @private
     * @constant
     * @type {Number}
     */
    var BLOCK_SIZE = 262144;

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

    /**
     * All frames parsed from the provided blob.
     *
     * @private
     * @type {!Guacamole.SessionRecording._Frame[]}
     */
    var frames = [];

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

    /**
     * An object containing a single "aborted" property which is set to
     * true if the in-progress seek operation should be aborted. If no seek
     * operation is in progress, this will be null.
     *
     * @private
     * @type {object}
     */
    var activeSeek = null;

    /**
     * The byte offset within the recording blob of the first character of
     * the first instruction of the current frame. Here, "current frame"
     * refers to the frame currently being parsed when the provided
     * recording is initially loading. If the recording is not being
     * loaded, this value has no meaning.
     *
     * @private
     * @type {!number}
     */
    var frameStart = 0;

    /**
     * The byte offset within the recording blob of the character which
     * follows the last character of the most recently parsed instruction
     * of the current frame. Here, "current frame" refers to the frame
     * currently being parsed when the provided recording is initially
     * loading. If the recording is not being loaded, this value has no
     * meaning.
     *
     * @private
     * @type {!number}
     */
    var frameEnd = 0;

    /**
     * Whether the initial loading process has been aborted. If the loading
     * process has been aborted, no further blocks of data should be read
     * from the recording.
     *
     * @private
     * @type {!boolean}
     */
    var aborted = false;

    /**
     * The function to invoke when the seek operation initiated by a call
     * to seek() is cancelled or successfully completed. If no seek
     * operation is in progress, this will be null.
     *
     * @private
     * @type {function}
     */
    var seekCallback = null;

    /**
     * Parses all Guacamole instructions within the given blob, invoking
     * the provided instruction callback for each such instruction. Once
     * the end of the blob has been reached (no instructions remain to be
     * parsed), the provided completion callback is invoked. If a parse
     * error prevents reading instructions from the blob, the onerror
     * callback of the Guacamole.SessionRecording is invoked, and no further
     * data is handled within the blob.
     *
     * @private
     * @param {!Blob} blob
     *     The blob to parse Guacamole instructions from.
     *
     * @param {function} [instructionCallback]
     *     The callback to invoke for each Guacamole instruction read from
     *     the given blob. This function must accept the same arguments
     *     as the oninstruction handler of Guacamole.Parser.
     *
     * @param {function} [completionCallback]
     *     The callback to invoke once all instructions have been read from
     *     the given blob.
     */
    var parseBlob = function parseBlob(blob, instructionCallback, completionCallback) {

        // Do not read any further blocks if loading has been aborted
        if (aborted && blob === recordingBlob)
            return;

        // Prepare a parser to handle all instruction data within the blob,
        // automatically invoking the provided instruction callback for all
        // parsed instructions
        var parser = new Guacamole.Parser();
        parser.oninstruction = instructionCallback;

        var offset = 0;
        var reader = new FileReader();

        /**
         * Reads the block of data at offset bytes within the blob. If no
         * such block exists, then the completion callback provided to
         * parseBlob() is invoked as all data has been read.
         *
         * @private
         */
        var readNextBlock = function readNextBlock() {

            // Do not read any further blocks if loading has been aborted
            if (aborted && blob === recordingBlob)
                return;

            // Parse all instructions within the block, invoking the
            // onerror handler if a parse error occurs
            if (reader.readyState === 2 /* DONE */) {
                try {
                    parser.receive(reader.result);
                }
                catch (parseError) {
                    if (recording.onerror) {
                        recording.onerror(parseError.message);
                    }
                    return;
                }
            }

            // If no data remains, the read operation is complete and no
            // further blocks need to be read
            if (offset >= blob.size) {
                if (completionCallback)
                    completionCallback();
            }

            // Otherwise, read the next block
            else {
                var block = blob.slice(offset, offset + BLOCK_SIZE);
                offset += block.size;
                reader.readAsText(block);
            }

        };

        // Read blocks until the end of the given blob is reached
        reader.onload = readNextBlock;
        readNextBlock();

    };

    /**
     * Calculates the size of the given Guacamole instruction element, in
     * Unicode characters. The size returned includes the characters which
     * make up the length, the "." separator between the length and the
     * element itself, and the "," or ";" terminator which follows the
     * element.
     *
     * @private
     * @param {!string} value
     *     The value of the element which has already been parsed (lacks
     *     the initial length, "." separator, and "," or ";" terminator).
     *
     * @returns {!number}
     *     The number of Unicode characters which would make up the given
     *     element within a Guacamole instruction.
     */
    var getElementSize = function getElementSize(value) {

        var valueLength = value.length;

        // Calculate base size, assuming at least one digit, the "."
        // separator, and the "," or ";" terminator
        var protocolSize = valueLength + 3;

        // Add one character for each additional digit that would occur
        // in the element length prefix
        while (valueLength >= 10) {
            protocolSize++;
            valueLength = Math.floor(valueLength / 10);
        }

        return protocolSize;

    };

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

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

    /**
     * Handles a newly-received instruction, whether from the main Blob or a
     * tunnel, adding new frames and keyframes as necessary. Load progress is
     * reported via onprogress automatically.
     *
     * @private
     * @param {!string} opcode
     *     The opcode of the instruction to handle.
     *
     * @param {!string[]} args
     *     The arguments of the received instruction, if any.
     */
    var loadInstruction = function loadInstruction(opcode, args) {

        // Advance end of frame by overall length of parsed instruction
        frameEnd += getElementSize(opcode);
        for (var i = 0; i < args.length; i++)
            frameEnd += getElementSize(args[i]);

        // 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, frameStart, frameEnd);
            frames.push(frame);
            frameStart = frameEnd;

            // 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 || (frameEnd - frames[lastKeyframe].start >= KEYFRAME_CHAR_INTERVAL
                    && timestamp - frames[lastKeyframe].timestamp >= KEYFRAME_TIME_INTERVAL)) {
                frame.keyframe = true;
                lastKeyframe = frames.length - 1;
            }

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

        }

    };

    /**
     * Notifies that the session recording has been fully loaded. If the onload
     * handler has not been defined, this function has no effect.
     *
     * @private
     */
    var notifyLoaded = function notifyLoaded() {
        if (recording.onload)
            recording.onload();
    };

    // Read instructions from provided blob, extracting each frame
    if (source instanceof Blob) {
        recordingBlob = source;
        parseBlob(recordingBlob, loadInstruction, notifyLoaded);
    }

    // If tunnel provided instead of Blob, extract frames, etc. as instructions
    // are received, buffering things into a Blob for future seeks
    else {

        tunnel = source;
        recordingBlob = new Blob();

        var errorEncountered = false;
        var instructionBuffer = '';

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

            // Reconstitute received instruction
            instructionBuffer += opcode.length + '.' + opcode;
            args.forEach(function appendArg(arg) {
                instructionBuffer += ',' + arg.length + '.' + arg;
            });
            instructionBuffer += ';';

            // Append to Blob (creating a new Blob in the process)
            if (instructionBuffer.length >= BLOCK_SIZE) {
                recordingBlob = new Blob([recordingBlob, instructionBuffer]);
                instructionBuffer = '';
            }

            // Load parsed instruction into recording
            loadInstruction(opcode, args);

        };

        // Report any errors encountered
        tunnel.onerror = function tunnelError(status) {
            errorEncountered = true;
            if (recording.onerror)
                recording.onerror(status.message);
        };

        tunnel.onstatechange = function tunnelStateChanged(state) {
            if (state === Guacamole.Tunnel.State.CLOSED) {

                // Append any remaining instructions
                if (instructionBuffer.length) {
                    recordingBlob = new Blob([recordingBlob, instructionBuffer]);
                    instructionBuffer = '';
                }

                // Consider recording loaded if tunnel has closed without errors
                if (!errorEncountered)
                    notifyLoaded();
            }
        };

    }

    /**
     * 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.
     *
     * @param {function} callback
     *     The callback to invoke once replay of the frame has completed.
     */
    var replayFrame = function replayFrame(index, callback) {

        var frame = frames[index];

        // Replay all instructions within the retrieved frame
        parseBlob(recordingBlob.slice(frame.start, frame.end), function handleInstruction(opcode, args) {
            playbackTunnel.receiveInstruction(opcode, args);
        }, function replayCompleted() {

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

            // Update state to correctly represent the current frame
            currentFrame = index;

            if (callback)
                callback();

        });

    };

    /**
     * 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} [nextRealTimestamp]
     *     The timestamp of the point in time that the given frame should be
     *     displayed, as would be returned by new Date().getTime(). If omitted,
     *     the frame will be displayed as soon as possible.
     */
    var seekToFrame = function seekToFrame(index, callback, nextRealTimestamp) {

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

        // Note that a new seek operation is in progress
        var thisSeek = activeSeek = {
            aborted : false
        };

        var startIndex = index;

        // Replay any applicable incremental frames
        var continueReplay = function continueReplay() {

            // Notify of changes in position
            if (recording.onseek && currentFrame > startIndex) {
                recording.onseek(toRelativeTimestamp(frames[currentFrame].timestamp),
                    currentFrame - startIndex, index - startIndex);
            }

            // Cancel seek if aborted
            if (thisSeek.aborted)
                return;

            // If frames remain, replay the next frame
            if (currentFrame < index)
                replayFrame(currentFrame + 1, continueReplay);

            // Otherwise, the seek operation is completed
            else
                callback();

        };

        // Continue replay after requested delay has elapsed, or
        // immediately if no delay was requested
        var continueAfterRequiredDelay = function continueAfterRequiredDelay() {
            var delay = nextRealTimestamp ? Math.max(nextRealTimestamp - new Date().getTime(), 0) : 0;
            if (delay)
                window.setTimeout(continueReplay, delay);
            else
                continueReplay();
        };

        // Back up until startIndex represents current state
        for (; 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) {
                frame.clientState.text().then(function textReady(text) {
                    playbackClient.importState(JSON.parse(text));
                    currentFrame = startIndex;
                    continueAfterRequiredDelay();
                });
                return;
            }

        }

        continueAfterRequiredDelay();
        
    };

    /**
     * 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() {
        if (activeSeek) {
            activeSeek.aborted = true;
            activeSeek = null;
        }
    };

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

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

        }

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

    };

    /**
     * Fired when loading of this recording has completed and all frames
     * are available.
     *
     * @event
     */
    this.onload = null;

    /**
     * Fired when an error occurs which prevents the recording from being
     * played back.
     *
     * @event
     * @param {!string} message
     *     A human-readable message describing the error that occurred.
     */
    this.onerror = null;

    /**
     * Fired when further loading of this recording has been explicitly
     * aborted through a call to abort().
     *
     * @event
     */
    this.onabort = null;

    /**
     * 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.
     *
     * @param {!number} parsedSize
     *     The number of bytes that have been loaded/parsed.
     */
    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.
     *
     * @param {!number} current
     *     The number of frames that have been seeked through. If not
     *     seeking through multiple frames due to a call to seek(), this
     *     will be 1.
     *
     * @param {!number} total
     *     The number of frames that are being seeked through in the
     *     current seek operation. If not seeking through multiple frames
     *     due to a call to seek(), this will be 1.
     */
    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. If the underlying
     * recording source is a Blob, this function has no effect.
     *
     * @param {string} [data]
     *     The data to send to the tunnel when connecting.
     */
    this.connect = function connect(data) {
        if (tunnel)
            tunnel.connect(data);
    };

    /**
     * Disconnects the underlying tunnel, stopping further download of the
     * Guacamole session. If the underlying recording source is a Blob, this
     * function has no effect.
     */
    this.disconnect = function disconnect() {
        if (tunnel)
            tunnel.disconnect();
    };

    /**
     * Aborts the loading process, stopping further processing of the
     * provided data. If the underlying recording source is a Guacamole tunnel,
     * it will be disconnected.
     */
    this.abort = function abort() {
        if (!aborted) {

            aborted = true;
            if (recording.onabort)
                recording.onabort();

            if (tunnel)
                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;

        // Abort active seek operation, if any
        recording.cancel();

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

        // Restore playback when seek is completed or cancelled
        seekCallback = function restorePlaybackState() {

            // Seek is no longer in progress
            seekCallback = null;

            // Restore playback state
            if (originallyPlaying) {
                recording.play();
                originallyPlaying = null;
            }

            // Notify that seek has completed
            if (callback)
                callback();

        };

        // Perform seek
        seekToFrame(findFrame(0, frames.length - 1, position), seekCallback);

    };

    /**
     * Cancels the current seek operation, setting the current frame of the
     * recording to wherever the seek operation was able to reach prior to
     * being cancelled. If a callback was provided to seek(), that callback
     * is invoked. If a seek operation is not currently underway, this
     * function has no effect.
     */
    this.cancel = function cancel() {
        if (seekCallback) {
            abortSeek();
            seekCallback();
        }
    };

    /**
     * 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 {!number} start
 *     The byte offset within the blob of the first character of the first
 *     instruction of this frame.
 *
 * @param {!number} end
 *     The byte offset within the blob of character which follows the last
 *     character of the last instruction of this frame.
 */
Guacamole.SessionRecording._Frame = function _Frame(timestamp, start, end) {

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

    /**
     * The byte offset within the blob of the first character of the first
     * instruction of this frame.
     *
     * @type {!number}
     */
    this.start = start;

    /**
     * The byte offset within the blob of character which follows the last
     * character of the last instruction of this frame.
     *
     * @type {!number}
     */
    this.end = end;

    /**
     * A snapshot of client state after this frame was rendered, as returned by
     * a call to exportState(), serialized as JSON, and stored within a Blob.
     * Use of Blobs here is required to ensure the browser can make use of
     * larger disk-backed storage if the size of the recording is large. If no
     * such snapshot has been taken, this will be null.
     *
     * @type {Blob}
     * @default null
     */
    this.clientState = null;

};

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

};