GUACAMOLE-1820: Merge support for viewing key events in recording player.

This commit is contained in:
Mike Jumper
2023-07-13 12:12:07 -07:00
committed by GitHub
19 changed files with 1353 additions and 96 deletions

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Stephen Kamenar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,8 @@
fuzzysort (https://github.com/farzher/fuzzysort/tree/master)
---------------------------------------------
Version: 2.0.4
From: 'Stephen Kamenar' (https://github.com/farzher)
License(s):
MIT (bundled/fuzzysort-2.0.4/LICENSE)

View File

@@ -0,0 +1 @@
fuzzysort:2.0.4

View File

@@ -0,0 +1,550 @@
/*
* 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 || {};
/**
* An object that will accept raw key events and produce human readable text
* batches, seperated by at least `batchSeperation` milliseconds, which can be
* retrieved through the onbatch callback or by calling getCurrentBatch().
*
* NOTE: The event processing logic and output format is based on the `guaclog`
* tool, with the addition of batching support.
*
* @constructor
*
* @param {number} [batchSeperation=5000]
* The minimum number of milliseconds that must elapse between subsequent
* batches of key-event-generated text. If 0 or negative, no splitting will
* occur, resulting in a single batch for all provided key events.
*
* @param {number} [startTimestamp=0]
* The starting timestamp for the recording being intepreted. If provided,
* the timestamp of each intepreted event will be relative to this timestamp.
* If not provided, the raw recording timestamp will be used.
*/
Guacamole.KeyEventInterpreter = function KeyEventInterpreter(batchSeperation, startTimestamp) {
/**
* Reference to this Guacamole.KeyEventInterpreter.
*
* @private
* @type {!Guacamole.SessionRecording}
*/
var interpreter = this;
// Default to 5 seconds if the batch seperation was not provided
if (batchSeperation === undefined || batchSeperation === null)
batchSeperation = 5000;
// Default to 0 seconds to keep the raw timestamps
if (startTimestamp === undefined || startTimestamp === null)
startTimestamp = 0;
/**
* A definition for a known key.
*
* @constructor
* @private
* @param {KeyDefinition|object} [template={}]
* The object whose properties should be copied within the new
* KeyDefinition.
*/
var KeyDefinition = function KeyDefinition(template) {
/**
* The X11 keysym of the key.
* @type {!number}
*/
this.keysym = parseInt(template.keysym);
/**
* A human-readable name for the key.
* @type {!String}
*/
this.name = template.name;
/**
* The value which would be typed in a typical text editor, if any. If the
* key is not associated with any typable value, or if the typable value is
* not generally useful in an auditing context, this will be undefined.
* @type {String}
*/
this.value = template.value;
/**
* Whether this key is a modifier which may affect the interpretation of
* other keys, and thus should be tracked as it is held down.
* @type {!boolean}
* @default false
*/
this.modifier = template.modifier || false;
};
/**
* A precursor array to the KNOWN_KEYS map. The objects contained within
* will be constructed into full KeyDefinition objects.
*
* @constant
* @private
* @type {Object[]}
*/
var _KNOWN_KEYS = [
{keysym: 0xFE03, name: 'AltGr', value: "", modifier: true },
{keysym: 0xFF08, name: 'Backspace' },
{keysym: 0xFF09, name: 'Tab' },
{keysym: 0xFF0B, name: 'Clear' },
{keysym: 0xFF0D, name: 'Return', value: "\n" },
{keysym: 0xFF13, name: 'Pause' },
{keysym: 0xFF14, name: 'Scroll' },
{keysym: 0xFF15, name: 'SysReq' },
{keysym: 0xFF1B, name: 'Escape' },
{keysym: 0xFF50, name: 'Home' },
{keysym: 0xFF51, name: 'Left' },
{keysym: 0xFF52, name: 'Up' },
{keysym: 0xFF53, name: 'Right' },
{keysym: 0xFF54, name: 'Down' },
{keysym: 0xFF55, name: 'Page Up' },
{keysym: 0xFF56, name: 'Page Down' },
{keysym: 0xFF57, name: 'End' },
{keysym: 0xFF63, name: 'Insert' },
{keysym: 0xFF65, name: 'Undo' },
{keysym: 0xFF6A, name: 'Help' },
{keysym: 0xFF7F, name: 'Num' },
{keysym: 0xFF80, name: 'Space', value: " " },
{keysym: 0xFF8D, name: 'Enter', value: "\n" },
{keysym: 0xFF95, name: 'Home' },
{keysym: 0xFF96, name: 'Left' },
{keysym: 0xFF97, name: 'Up' },
{keysym: 0xFF98, name: 'Right' },
{keysym: 0xFF99, name: 'Down' },
{keysym: 0xFF9A, name: 'Page Up' },
{keysym: 0xFF9B, name: 'Page Down' },
{keysym: 0xFF9C, name: 'End' },
{keysym: 0xFF9E, name: 'Insert' },
{keysym: 0xFFAA, name: '*', value: "*" },
{keysym: 0xFFAB, name: '+', value: "+" },
{keysym: 0xFFAD, name: '-', value: "-" },
{keysym: 0xFFAE, name: '.', value: "." },
{keysym: 0xFFAF, name: '/', value: "/" },
{keysym: 0xFFB0, name: '0', value: "0" },
{keysym: 0xFFB1, name: '1', value: "1" },
{keysym: 0xFFB2, name: '2', value: "2" },
{keysym: 0xFFB3, name: '3', value: "3" },
{keysym: 0xFFB4, name: '4', value: "4" },
{keysym: 0xFFB5, name: '5', value: "5" },
{keysym: 0xFFB6, name: '6', value: "6" },
{keysym: 0xFFB7, name: '7', value: "7" },
{keysym: 0xFFB8, name: '8', value: "8" },
{keysym: 0xFFB9, name: '9', value: "9" },
{keysym: 0xFFBE, name: 'F1' },
{keysym: 0xFFBF, name: 'F2' },
{keysym: 0xFFC0, name: 'F3' },
{keysym: 0xFFC1, name: 'F4' },
{keysym: 0xFFC2, name: 'F5' },
{keysym: 0xFFC3, name: 'F6' },
{keysym: 0xFFC4, name: 'F7' },
{keysym: 0xFFC5, name: 'F8' },
{keysym: 0xFFC6, name: 'F9' },
{keysym: 0xFFC7, name: 'F10' },
{keysym: 0xFFC8, name: 'F11' },
{keysym: 0xFFC9, name: 'F12' },
{keysym: 0xFFCA, name: 'F13' },
{keysym: 0xFFCB, name: 'F14' },
{keysym: 0xFFCC, name: 'F15' },
{keysym: 0xFFCD, name: 'F16' },
{keysym: 0xFFCE, name: 'F17' },
{keysym: 0xFFCF, name: 'F18' },
{keysym: 0xFFD0, name: 'F19' },
{keysym: 0xFFD1, name: 'F20' },
{keysym: 0xFFD2, name: 'F21' },
{keysym: 0xFFD3, name: 'F22' },
{keysym: 0xFFD4, name: 'F23' },
{keysym: 0xFFD5, name: 'F24' },
{keysym: 0xFFE1, name: 'Shift', value: "", modifier: true },
{keysym: 0xFFE2, name: 'Shift', value: "", modifier: true },
{keysym: 0xFFE3, name: 'Ctrl', value: null, modifier: true },
{keysym: 0xFFE4, name: 'Ctrl', value: null, modifier: true },
{keysym: 0xFFE5, name: 'Caps' },
{keysym: 0xFFE7, name: 'Meta', value: null, modifier: true },
{keysym: 0xFFE8, name: 'Meta', value: null, modifier: true },
{keysym: 0xFFE9, name: 'Alt', value: null, modifier: true },
{keysym: 0xFFEA, name: 'Alt', value: null, modifier: true },
{keysym: 0xFFEB, name: 'Super', value: null, modifier: true },
{keysym: 0xFFEC, name: 'Super', value: null, modifier: true },
{keysym: 0xFFED, name: 'Hyper', value: null, modifier: true },
{keysym: 0xFFEE, name: 'Hyper', value: null, modifier: true },
{keysym: 0xFFFF, name: 'Delete' }
];
/**
* All known keys, as a map of X11 keysym to KeyDefinition.
*
* @constant
* @private
* @type {Object.<String, KeyDefinition>}
*/
var KNOWN_KEYS = {};
_KNOWN_KEYS.forEach(function createKeyDefinitionMap(keyDefinition) {
// Construct a map of keysym to KeyDefinition object
KNOWN_KEYS[keyDefinition.keysym] = new KeyDefinition(keyDefinition)
});
/**
* A map of X11 keysyms to a KeyDefinition object, if the corresponding
* key is currently pressed. If a keysym has no entry in this map at all,
* it means that the key is not being pressed. Note that not all keysyms
* are necessarily tracked within this map - only those that are explicitly
* tracked.
*
* @private
* @type {Object.<String,KeyDefinition> }
*/
var pressedKeys = {};
/**
* The current key event batch, containing a representation of all key
* events processed since the end of the last batch passed to onbatch.
* Null if no key events have been processed yet.
*
* @private
* @type {!KeyEventBatch}
*/
var currentBatch = null;
/**
* The timestamp of the most recent key event processed.
*
* @private
* @type {Number}
*/
var lastKeyEvent = 0;
/**
* Returns true if the currently-pressed keys are part of a shortcut, or
* false otherwise.
*
* @private
* @returns {!boolean}
* True if the currently-pressed keys are part of a shortcut, or false
* otherwise.
*/
function isShortcut() {
// If one of the currently-pressed keys is non-printable, a shortcut
// is being typed
for (var keysym in pressedKeys) {
if (pressedKeys[keysym].value === null)
return true;
}
return false;
}
/**
* If the provided keysym corresponds to a valid UTF-8 character, return
* a KeyDefinition for that keysym. Otherwise, return null.
*
* @private
* @param {Number} keysym
* The keysym to produce a UTF-8 KeyDefinition for, if valid.
*
* @returns {KeyDefinition}
* A KeyDefinition for the provided keysym, if it's a valid UTF-8
* keysym, or null otherwise.
*/
function getUnicodeKeyDefinition(keysym) {
// Translate only if keysym maps to Unicode
if (keysym < 0x00 || (keysym > 0xFF && (keysym | 0xFFFF) != 0x0100FFFF))
return null;
// Convert to UTF8 string
var codepoint = keysym & 0xFFFF;
var name = String.fromCharCode(codepoint);
// Create and return the definition
return new KeyDefinition({keysym: keysym, name: name, value: name, modifier: false});
}
/**
* Return a KeyDefinition corresponding to the provided keysym.
*
* @private
* @param {Number} keysym
* The keysym to return a KeyDefinition for.
*
* @returns {KeyDefinition}
* A KeyDefinition corresponding to the provided keysym.
*/
function getKeyDefinitionByKeysym(keysym) {
// If it's a known type, return the existing definition
if (keysym in KNOWN_KEYS)
return KNOWN_KEYS[keysym];
// Return a UTF-8 KeyDefinition, if valid
var definition = getUnicodeKeyDefinition(keysym);
if (definition != null)
return definition;
// If it's not UTF-8, return an unknown definition, with the name
// just set to the hex value of the keysym
return new KeyDefinition({
keysym: keysym,
name: '0x' + String(keysym.toString(16))
})
}
/**
* Fired whenever a new batch of typed text extracted from key events
* is available. A new batch will be provided every time a new key event
* is processed after more than batchSeperation milliseconds after the
* previous key event.
*
* @event
* @param {!Guacamole.KeyEventInterpreter.KeyEventBatch}
*/
this.onbatch = null;
/**
* Handles a raw key event, potentially appending typed text to the
* current batch, and calling onbatch with the current batch, if the
* callback is set and a new batch is about to be started.
*
* @param {!string[]} args
* The arguments of the key event.
*/
this.handleKeyEvent = function handleKeyEvent(args) {
// The X11 keysym
var keysym = parseInt(args[0]);
// Either 1 or 0 for pressed or released, respectively
var pressed = parseInt(args[1]);
// The timestamp when this key event occured
var timestamp = parseInt(args[2]);
// If no current batch exists, start a new one now
if (!currentBatch)
currentBatch = new Guacamole.KeyEventInterpreter.KeyEventBatch();
// Only switch to a new batch of text if sufficient time has passed
// since the last key event
var newBatch = (batchSeperation >= 0
&& (timestamp - lastKeyEvent) >= batchSeperation);
lastKeyEvent = timestamp;
if (newBatch) {
// Call the handler with the current batch of text and the timestamp
// at which the current batch started
if (currentBatch.events.length && interpreter.onbatch)
interpreter.onbatch(currentBatch);
// Move on to the next batch of text
currentBatch = new Guacamole.KeyEventInterpreter.KeyEventBatch();
}
var keyDefinition = getKeyDefinitionByKeysym(keysym);
// Mark down whether the key was pressed or released
if (keyDefinition.modifier) {
if (pressed)
pressedKeys[keysym] = keyDefinition;
else
delete pressedKeys[keysym];
}
// Append to the current typed value when a printable
// (non-modifier) key is pressed
else if (pressed) {
var relativeTimestap = timestamp - startTimestamp;
if (isShortcut()) {
var shortcutText = '<';
var firstKey = true;
// Compose entry by inspecting the state of each tracked key.
// At least one key must be pressed when in a shortcut.
for (var keysym in pressedKeys) {
var pressedKeyDefinition = pressedKeys[keysym];
// Print name of key
if (firstKey) {
shortcutText += pressedKeyDefinition.name;
firstKey = false;
}
else
shortcutText += ('+' + pressedKeyDefinition.name);
}
// Finally, append the printable key to close the shortcut
shortcutText += ('+' + keyDefinition.name + '>')
// Add the shortcut to the current batch
currentBatch.simpleValue += shortcutText;
currentBatch.events.push(new Guacamole.KeyEventInterpreter.KeyEvent(
shortcutText, false, relativeTimestap));
}
// Print the key itself
else {
var keyText;
var typed;
// Print the value if explicitly defined
if (keyDefinition.value != null) {
keyText = keyDefinition.value;
typed = true;
}
// Otherwise print the name
else {
keyText = ('<' + keyDefinition.name + '>');
// While this is a representation for a single character,
// the key text is the name of the key, not the actual
// character itself
typed = false;
}
// Add the key to the current batch
currentBatch.simpleValue += keyText;
currentBatch.events.push(new Guacamole.KeyEventInterpreter.KeyEvent(
keyText, typed, relativeTimestap));
}
}
};
/**
* Return the current batch of typed text. Note that the batch may be
* incomplete, as more key events might be processed before the next
* batch starts.
*
* @returns {Guacamole.KeyEventInterpreter.KeyEventBatch}
* The current batch of text.
*/
this.getCurrentBatch = function getCurrentBatch() {
return currentBatch;
};
};
/**
* A granular description of an extracted key event, including a human-readable
* text representation of the event, whether the event is directly typed or not,
* and the timestamp when the event occured.
*
* @constructor
* @param {!String} text
* A human-readable representation of the event.
*
* @param {!boolean} typed
* True if this event represents a directly-typed character, or false
* otherwise.
*
* @param {!Number} timestamp
* The timestamp from the recording when this event occured.
*/
Guacamole.KeyEventInterpreter.KeyEvent = function KeyEvent(text, typed, timestamp) {
/**
* A human-readable representation of the event. If a printable character
* was directly typed, this will just be that character. Otherwise it will
* be a string describing the event.
*
* @type {!String}
*/
this.text = text;
/**
* True if this text of this event is exactly a typed character, or false
* otherwise.
*
* @type {!boolean}
*/
this.typed = typed;
/**
* The timestamp from the recording when this event occured. If a
* `startTimestamp` value was provided to the interpreter constructor, this
* will be relative to start of the recording. If not, it will be the raw
* timestamp from the key event.
*
* @type {!Number}
*/
this.timestamp = timestamp;
};
/**
* A series of intepreted key events, seperated by at least the configured
* batchSeperation value from any other key events in the recording corresponding
* to the interpreted key events. A batch will always consist of at least one key
* event, and an associated simplified representation of the event(s).
*
* @constructor
* @param {!Guacamole.KeyEventInterpreter.KeyEvent[]} events
* The interpreted key events for this batch.
*
* @param {!String} simpleValue
* The simplified, human-readable value representing the key events for
* this batch.
*/
Guacamole.KeyEventInterpreter.KeyEventBatch = function KeyEventBatch(events, simpleValue) {
/**
* All key events for this batch.
*
* @type {!Guacamole.KeyEventInterpreter.KeyEvent[]}
*/
this.events = events || [];
/**
* The simplified, human-readable value representing the key events for
* this batch, equivalent to concatenating the `text` field of all key
* events in the batch.
*
* @type {!String}
*/
this.simpleValue = simpleValue || '';
};

View File

@@ -375,6 +375,38 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval)
// Hide cursor unless mouse position is received
playbackClient.getDisplay().showCursor(false);
/**
* A key event interpreter to split all key events in this recording into
* human-readable batches of text. Constrcution is deferred until the first
* event is processed, to enable recording-relative timestamps.
*
* @type {!Guacamole.KeyEventInterpreter}
*/
var keyEventInterpreter = null;
/**
* Initialize the key interpreter. This function should be called only once
* with the first timestamp in the recording as an argument.
*
* @private
* @param {!number} startTimestamp
* The timestamp of the first frame in the recording, i.e. the start of
* the recording.
*/
function initializeKeyInterpreter(startTimestamp) {
keyEventInterpreter = new Guacamole.KeyEventInterpreter(null, startTimestamp);
// Pass through any received batches to the recording ontext handler
keyEventInterpreter.onbatch = function onbatch(batch) {
// Pass the batch through if a handler is set
if (recording.ontext)
recording.ontext(batch);
};
}
/**
* Handles a newly-received instruction, whether from the main Blob or a
* tunnel, adding new frames and keyframes as necessary. Load progress is
@@ -406,6 +438,11 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval)
frames.push(frame);
frameStart = frameEnd;
// If this is the first frame, intialize the key event interpreter
// with the timestamp of the first frame
if (frames.length === 1)
initializeKeyInterpreter(timestamp);
// 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
@@ -413,6 +450,7 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval)
&& timestamp - frames[lastKeyframe].timestamp >= KEYFRAME_TIME_INTERVAL)) {
frame.keyframe = true;
lastKeyframe = frames.length - 1;
}
// Notify that additional content is available
@@ -421,6 +459,8 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval)
}
else if (opcode === 'key')
keyEventInterpreter.handleKeyEvent(args);
};
/**
@@ -487,6 +527,12 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval)
instructionBuffer = '';
}
// If there's any typed text that's yet to be sent to the ontext
// handler, send it now
var batch = keyEventInterpreter.getCurrentBatch();
if (batch && recording.ontext)
recording.ontext(batch);
// Consider recording loaded if tunnel has closed without errors
if (!errorEncountered)
notifyLoaded();
@@ -872,6 +918,16 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval)
*/
this.onpause = null;
/**
* Fired whenever a new batch of typed text extracted from key events
* is available.
*
* @event
* @param {!Guacamole.KeyEventInterpreter.KeyEventBatch} batch
* The batch of extracted text.
*/
this.ontext = null;
/**
* Fired whenever the playback position within the recording changes.
*

View File

@@ -16,6 +16,7 @@
"csv": "^6.2.5",
"datalist-polyfill": "^1.25.1",
"file-saver": "^2.0.5",
"fuzzysort": "^2.0.4",
"jquery": "^3.6.4",
"jstz": "^2.1.1",
"lodash": "^4.17.21",
@@ -5632,6 +5633,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuzzysort": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz",
"integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw=="
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -15703,6 +15709,11 @@
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
"dev": true
},
"fuzzysort": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz",
"integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw=="
},
"gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",

View File

@@ -15,6 +15,7 @@
"csv": "^6.2.5",
"datalist-polyfill": "^1.25.1",
"file-saver": "^2.0.5",
"fuzzysort": "^2.0.4",
"jquery": "^3.6.4",
"jstz": "^2.1.1",
"lodash": "^4.17.21",

View File

@@ -77,6 +77,12 @@
*/
angular.module('player').directive('guacPlayer', ['$injector', function guacPlayer($injector) {
// Required services
const playerTimeService = $injector.get('playerTimeService');
// Required types
const TextBatch = $injector.get('TextBatch');
const config = {
restrict : 'E',
templateUrl : 'app/player/templates/player.html'
@@ -142,6 +148,21 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay
*/
$scope.seekPosition = null;
/**
* Any batches of text typed during the recording.
*
* @type {TextBatch[]}
*/
$scope.textBatches = [];
/**
* Whether or not the key log viewer should be displayed. False by
* default unless explicitly enabled by user interaction.
*
* @type {boolean}
*/
$scope.showKeyLog = false;
/**
* Whether a seek request is currently in progress. A seek request is
* in progress if the user is attempting to change the current playback
@@ -161,57 +182,29 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay
var resumeAfterSeekRequest = false;
/**
* Formats the given number as a decimal string, adding leading zeroes
* such that the string contains at least two digits. The given number
* MUST NOT be negative.
* Return true if any batches of key event logs are available for this
* recording, or false otherwise.
*
* @param {!number} value
* The number to format.
*
* @returns {!string}
* The decimal string representation of the given value, padded
* with leading zeroes up to a minimum length of two digits.
* @return
* True if any batches of key event logs are avaiable for this
* recording, or false otherwise.
*/
const zeroPad = function zeroPad(value) {
return value > 9 ? value : '0' + value;
$scope.hasTextBatches = function hasTextBatches () {
return $scope.textBatches.length >= 0;
};
/**
* Formats the given quantity of milliseconds as days, hours, minutes,
* and whole seconds, separated by colons (DD:HH:MM:SS). Hours are
* included only if the quantity is at least one hour, and days are
* included only if the quantity is at least one day. All included
* groups are zero-padded to two digits with the exception of the
* left-most group.
*
* @param {!number} value
* The time to format, in milliseconds.
*
* @returns {!string}
* The given quantity of milliseconds formatted as "DD:HH:MM:SS".
* Toggle the visibility of the text key log viewer.
*/
$scope.formatTime = function formatTime(value) {
// Round provided value down to whole seconds
value = Math.floor((value || 0) / 1000);
// Separate seconds into logical groups of seconds, minutes,
// hours, etc.
var groups = [ 1, 24, 60, 60 ];
for (var i = groups.length - 1; i >= 0; i--) {
var placeValue = groups[i];
groups[i] = zeroPad(value % placeValue);
value = Math.floor(value / placeValue);
}
// Format groups separated by colons, stripping leading zeroes and
// groups which are entirely zeroes, leaving at least minutes and
// seconds
var formatted = groups.join(':');
return /^[0:]*([0-9]{1,2}(?::[0-9]{2})+)$/.exec(formatted)[1];
$scope.toggleKeyLogView = function toggleKeyLogView() {
$scope.showKeyLog = !$scope.showKeyLog;
};
/**
* @borrows playerTimeService.formatTime
*/
$scope.formatTime = playerTimeService.formatTime;
/**
* Pauses playback and decouples the position slider from current
* playback position, allowing the user to manipulate the slider
@@ -242,32 +235,54 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay
// If a recording is present and there is an active seek request,
// restore the playback state at the time that request began and
// begin seeking to the requested position
if ($scope.recording && pendingSeekRequest) {
$scope.seekPosition = null;
$scope.operationMessage = 'PLAYER.INFO_SEEK_IN_PROGRESS';
$scope.operationProgress = 0;
// Cancel seek when requested, updating playback position if
// that position changed
$scope.cancelOperation = function abortSeek() {
$scope.recording.cancel();
$scope.playbackPosition = $scope.seekPosition || $scope.playbackPosition;
};
resumeAfterSeekRequest && $scope.recording.play();
$scope.recording.seek($scope.playbackPosition, function seekComplete() {
$scope.operationMessage = null;
$scope.$evalAsync();
});
}
if ($scope.recording && pendingSeekRequest)
$scope.seekToPlaybackPosition();
// Flag seek request as completed
pendingSeekRequest = false;
};
/**
* Seek the recording to the specified position within the recording,
* in milliseconds.
*
* @param {Number} timestamp
* The position to seek to within the current record,
* in milliseconds.
*/
$scope.seekToTimestamp = function seekToTimestamp(timestamp) {
// Set the timestamp and seek to it
$scope.playbackPosition = timestamp;
$scope.seekToPlaybackPosition();
};
/**
* Seek the recording to the current playback position value.
*/
$scope.seekToPlaybackPosition = function seekToPlaybackPosition() {
$scope.seekPosition = null;
$scope.operationMessage = 'PLAYER.INFO_SEEK_IN_PROGRESS';
$scope.operationProgress = 0;
// Cancel seek when requested, updating playback position if
// that position changed
$scope.cancelOperation = function abortSeek() {
$scope.recording.cancel();
$scope.playbackPosition = $scope.seekPosition || $scope.playbackPosition;
};
resumeAfterSeekRequest && $scope.recording.play();
$scope.recording.seek($scope.playbackPosition, function seekComplete() {
$scope.seekPosition = null;
$scope.operationMessage = null;
$scope.$evalAsync();
});
};
/**
* Toggles the current playback state. If playback is currently paused,
* playback is resumed. If playback is currently active, playback is
@@ -342,6 +357,14 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay
$scope.$evalAsync();
};
// Append any extracted batches of typed text
$scope.recording.ontext = function appendTextBatch(batch) {
// Convert to the display-optimized TextBatch type
$scope.textBatches.push(new TextBatch(batch));
};
// Notify listeners when current position within the recording
// has changed
$scope.recording.onseek = function positionChanged(position, current, total) {

View File

@@ -0,0 +1,134 @@
/*
* 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.
*/
const fuzzysort = require('fuzzysort')
/**
* Directive which plays back session recordings.
*/
angular.module('player').directive('guacPlayerTextView',
['$injector', function guacPlayer($injector) {
// Required services
const playerTimeService = $injector.get('playerTimeService');
const config = {
restrict : 'E',
templateUrl : 'app/player/templates/textView.html'
};
config.scope = {
/**
* All the batches of text extracted from this recording.
*
* @type {!TextBatch[]}
*/
textBatches : '=',
/**
* A callback that accepts a timestamp, and seeks the recording to
* that provided timestamp.
*
* @type {!Function}
*/
seek: '&',
/**
* The current position within the recording.
*
* @type {!Number}
*/
currentPosition: '='
};
config.controller = ['$scope', '$element', '$injector',
function guacPlayerController($scope, $element) {
/**
* The phrase to search within the text batches in order to produce the
* filtered list for display.
*
* @type {String}
*/
$scope.searchPhrase = '';
/**
* The text batches that match the current search phrase, or all
* batches if no search phrase is set.
*
* @type {!TextBatch[]}
*/
$scope.filteredBatches = $scope.textBatches;
/**
* Whether or not the key log viewer should be full-screen. False by
* default unless explicitly enabled by user interaction.
*
* @type {boolean}
*/
$scope.fullscreenKeyLog = false;
/**
* Toggle whether the key log viewer should take up the whole screen.
*/
$scope.toggleKeyLogFullscreen = function toggleKeyLogFullscreen() {
$element.toggleClass("fullscreen");
};
/**
* Filter the provided text batches using the provided search phrase to
* generate the list of filtered batches, or set to all provided
* batches if no search phrase is provided.
*
* @param {String} searchPhrase
* The phrase to search the text batches for. If no phrase is
* provided, the list of batches will not be filtered.
*/
const applyFilter = searchPhrase => {
// If there's search phrase entered, search the text within the
// batches for it
if (searchPhrase)
$scope.filteredBatches = fuzzysort.go(
searchPhrase, $scope.textBatches, {key: 'simpleValue'})
.map(result => result.obj);
// Otherwise, do not filter the batches
else
$scope.filteredBatches = $scope.textBatches;
};
// Reapply the filter to the updated text batches
$scope.$watch('textBatches', applyFilter);
// Reapply the filter whenever the search phrase is updated
$scope.$watch('searchPhrase', applyFilter);
/**
* @borrows playerTimeService.formatTime
*/
$scope.formatTime = playerTimeService.formatTime;
}];
return config;
}]);

View File

@@ -0,0 +1,109 @@
/*
* 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.
*/
/*
* NOTE: This session recording player implementation is based on the Session
* Recording Player for Glyptodon Enterprise which is available at
* https://github.com/glyptodon/glyptodon-enterprise-player under the
* following license:
*
* Copyright (C) 2019 Glyptodon, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
/**
* A service for formatting time, specifically for the recording player.
*/
angular.module('player').factory('playerTimeService',
['$injector', function playerTimeService($injector) {
const service = {};
/**
* Formats the given number as a decimal string, adding leading zeroes
* such that the string contains at least two digits. The given number
* MUST NOT be negative.
*
* @param {!number} value
* The number to format.
*
* @returns {!string}
* The decimal string representation of the given value, padded
* with leading zeroes up to a minimum length of two digits.
*/
const zeroPad = function zeroPad(value) {
return value > 9 ? value : '0' + value;
};
/**
* Formats the given quantity of milliseconds as days, hours, minutes,
* and whole seconds, separated by colons (DD:HH:MM:SS). Hours are
* included only if the quantity is at least one hour, and days are
* included only if the quantity is at least one day. All included
* groups are zero-padded to two digits with the exception of the
* left-most group.
*
* @param {!number} value
* The time to format, in milliseconds.
*
* @returns {!string}
* The given quantity of milliseconds formatted as "DD:HH:MM:SS".
*/
service.formatTime = function formatTime(value) {
// Round provided value down to whole seconds
value = Math.floor((value || 0) / 1000);
// Separate seconds into logical groups of seconds, minutes,
// hours, etc.
var groups = [ 1, 24, 60, 60 ];
for (var i = groups.length - 1; i >= 0; i--) {
var placeValue = groups[i];
groups[i] = zeroPad(value % placeValue);
value = Math.floor(value / placeValue);
}
// Format groups separated by colons, stripping leading zeroes and
// groups which are entirely zeroes, leaving at least minutes and
// seconds
var formatted = groups.join(':');
return /^[0:]*([0-9]{1,2}(?::[0-9]{2})+)$/.exec(formatted)[1];
};
return service;
}]);

View File

@@ -50,7 +50,6 @@ guac-player {
}
guac-player .guac-player-display {
position: absolute;
top: 0;
left: 0;
width: 100%;
@@ -60,6 +59,7 @@ guac-player .guac-player-display {
guac-player .guac-player-controls {
position: absolute;
padding-bottom: 0;
left: 0;
bottom: 0;
width: 100%;
@@ -107,6 +107,18 @@ guac-player .guac-player-controls {
background-image: url('images/action-icons/guac-pause.svg');
}
.guac-player-controls .guac-player-buttons {
display: flex;
flex-direction: row;
align-items: center;
}
.guac-player-controls .guac-player-keys {
margin-left: auto;
padding-right: 0.5em;
cursor: pointer;
}
guac-player .guac-player-status {
position: fixed;
@@ -145,3 +157,50 @@ guac-player .guac-player-status {
flex-direction: column;
}
.guac-player-container {
height: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -moz-box;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-ms-flex-pack: space-between;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
justify-content: space-between;
}
guac-player-display {
flex-grow: 5;
/* Required for horizontal resizing to work */
min-width: 0;
}
guac-player-text-view {
min-width: 25em;
flex-basis: 0;
/* Make room for the control bar at the bottom */
height: calc(100% - 48px);
}
guac-player-text-view.fullscreen {
min-width: 100%;
}

View File

@@ -0,0 +1,110 @@
/*
* 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.
*/
.text-batches {
display: flex;
flex-direction: column;
overflow-y: scroll;
}
.text-batches .text-batch {
margin-bottom: 1em;
margin-left: 0.5em;
cursor: pointer;
}
.text-batches .text-batch .timestamp {
white-space: pre-wrap;
color: blue;
}
.guac-player-text-container {
height: 100%;
display: flex;
flex-direction: column;
background-color: white;
color: black;
}
.guac-player-text-container .text-controls {
display: flex;
flex-direction: row;
align-items: center;
}
.guac-player-text-container .text-controls .filter {
flex-grow: 5;
}
.guac-player-text-container .text-controls .fullscreen-button {
background-image: url('images/fullscreen.svg');
background-size: contain;
cursor: pointer;
height: 22px;
width: 22px;
margin-right: 0.25em;
}
.guac-player-text-container .result-count {
font-weight: bold;
margin: 0.5em;
}
.guac-player-text-container .filter {
margin: 0.25em;
}
.text-batches .text {
display: block;
margin: 0;
padding: 0;
max-width: 100%;
overflow-wrap: break-word;
}
.text-batches .text .not-typed {
font-weight: bold;
}
.text-batches .text .future {
color: dimgray;
}

View File

@@ -1,9 +1,25 @@
<!-- Actual playback display -->
<guac-player-display display="recording.getDisplay()"
ng-click="togglePlayback()"></guac-player-display>
<div class="guac-player-container">
<!-- Actual playback display -->
<guac-player-display display="recording.getDisplay()"
ng-click="togglePlayback()"></guac-player-display>
<!-- Show the text viewer only if text exists -->
<guac-player-text-view ng-show="showKeyLog"
text-batches="textBatches"
seek="seekToTimestamp(timestamp)"
current-position="seekPosition || playbackPosition"
duration="recording.getDuration()"
></guac-player-text-view>
</div>
<!-- Player controls -->
<div class="guac-player-controls" ng-show="recording">
<div class="guac-player-controls" ng-show="recording"
ng-class="{
'paused' : !recording.isPlaying(),
'playing' : recording.isPlaying()
}">
<!-- Playback position slider -->
<input class="guac-player-seek" type="range" min="0" step="1"
@@ -12,22 +28,33 @@
ng-model="playbackPosition"
ng-on-change="commitSeekRequest()">
<!-- Play button -->
<button class="guac-player-play"
ng-attr-title="{{ 'PLAYER.ACTION_PLAY' | translate }}"
ng-click="recording.play()"
ng-hide="recording.isPlaying()"><span class="play-icon"></span></button>
<div class="guac-player-buttons">
<!-- Pause button -->
<button class="guac-player-pause"
ng-attr-title="{{ 'PLAYER.ACTION_PAUSE' | translate }}"
ng-click="recording.pause()"
ng-show="recording.isPlaying()"><span class="pause-icon"></span></button>
<!-- Play button -->
<button class="guac-player-play"
ng-attr-title="{{ 'PLAYER.ACTION_PLAY' | translate }}"
ng-click="recording.play()"
ng-hide="recording.isPlaying()"><span class="play-icon"></span></button>
<!-- Playback position and duration -->
<span class="guac-player-position">
{{ formatTime(playbackPosition) }} / {{ formatTime(recording.getDuration()) }}
</span>
<!-- Pause button -->
<button class="guac-player-pause"
ng-attr-title="{{ 'PLAYER.ACTION_PAUSE' | translate }}"
ng-click="recording.pause()"
ng-show="recording.isPlaying()"><span class="pause-icon"></span></button>
<!-- Playback position and duration -->
<span class="guac-player-position">
{{ formatTime(playbackPosition) }} / {{ formatTime(recording.getDuration()) }}
</span>
<span ng-show="hasTextBatches()" class="guac-player-keys" ng-click="toggleKeyLogView()">
{{ 'PLAYER.ACTION_SHOW_KEY_LOG' | translate }}
</span>
<span ng-show="!hasTextBatches()" class="guac-player-keys disabled">
{{ 'PLAYER.INFO_NO_KEY_LOG' | translate }}
</span>
</div>
</div>

View File

@@ -0,0 +1,29 @@
<div class="guac-player-text-container">
<div class="text-controls">
<div class="filter">
<input class="search-string"
placeholder="{{'PLAYER.FIELD_PLACEHOLDER_TEXT_BATCH_FILTER' | translate}}"
type="text" ng-model="searchPhrase">
</div>
<span class="fullscreen-button" ng-click="toggleKeyLogFullscreen()"></span>
</div>
<div class="result-count"
ng-show="searchPhrase.length"
translate="PLAYER.INFO_NUMBER_OF_RESULTS"
translate-values="{RESULTS: filteredBatches.length}"></div>
<div class="text-batches">
<div ng-repeat="batch in filteredBatches" class="text-batch" ng-click="seek({timestamp: batch.events[0].timestamp})">
<div class="timestamp">{{ formatTime(batch.events[0].timestamp) }}</div>
<div class="text">
<span
ng-repeat="event in batch.events"
class="key-event"
ng-class="{ 'not-typed' : !event.typed, 'future': event.timestamp >= currentPosition }"
>{{ event.text }}</span>
</div>
</span>
</div>
</div>

View File

@@ -0,0 +1,117 @@
/*
* 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.
*/
/* global _ */
/**
* Service which defines the TextBatch class.
*/
angular.module('player').factory('TextBatch', [function defineTextBatch() {
/**
* A batch of text associated with a recording. The batch consists of a
* string representation of the text that would be typed based on the key
* events in the recording, as well as a timestamp when the batch started.
*
* @constructor
* @param {Guacamole.KeyEventInterpreter.KeyEvent|TextBatch|Object} [template={}]
* The object whose properties should be copied within the new TextBatch.
*/
const TextBatch = function TextBatch(template) {
/**
* All key events for this batch, with sequences of key events having
* the same `typed` field value combined.
*
* @type {!KeyEventBatch[]}
*/
this.events = _.reduce(template.events, (consolidatedEvents, rawEvent) => {
const currentEvent = _.last(consolidatedEvents);
// If a current event exists with the same `typed` value, conslidate
// the raw text event into it
if (currentEvent && currentEvent.typed === rawEvent.typed)
currentEvent.text += rawEvent.text;
// Otherwise, create a new conslidated event starting now
else
consolidatedEvents.push(new TextBatch.ConsolidatedKeyEvent(rawEvent));
return consolidatedEvents;
}, []);
/**
* The simplified, human-readable value representing the key events for
* this batch, equivalent to concatenating the `text` field of all key
* events in the batch.
*
* @type {!String}
*/
this.simpleValue = template.simpleValue || '';
};
/**
* A granular description of an extracted key event or sequence of events.
* Similar to the Guacamole.KeyEventInterpreter.KeyEvent type, except that
* this KeyEventBatch may contain multiple contiguous events of the same type,
* meaning that all event(s) that were combined into this event must have
* had the same `typed` field value. A single timestamp for the first combined
* event will be used for the whole batch.
*
* @constructor
* @param {Guacamole.KeyEventInterpreter.KeyEventBatch|ConsolidatedKeyEvent|Object} [template={}]
* The object whose properties should be copied within the new KeyEventBatch.
*/
TextBatch.ConsolidatedKeyEvent = function ConsolidatedKeyEvent(template) {
/**
* A human-readable representation of the event(s). If a series of printable
* characters was directly typed, this will just be those character(s).
* Otherwise it will be a string describing the event(s).
*
* @type {!String}
*/
this.text = template.text;
/**
* True if this text of this event is exactly a typed character, or false
* otherwise.
*
* @type {!boolean}
*/
this.typed = template.typed;
/**
* The timestamp from the recording when this event occured. If a
* `startTimestamp` value was provided to the interpreter constructor, this
* will be relative to start of the recording. If not, it will be the raw
* timestamp from the key event.
*
* @type {!Number}
*/
this.timestamp = template.timestamp;
};
return TextBatch;
}]);

View File

@@ -96,7 +96,7 @@
background: rgba(0, 0, 0, 0.5);
}
.settings.connectionHistoryPlayer.playing .guac-player-controls {
.settings.connectionHistoryPlayer .guac-player-controls.playing {
opacity: 0;
-webkit-transition: opacity 0.25s linear 0.25s;
-moz-transition: opacity 0.25s linear 0.25s;
@@ -104,8 +104,8 @@
transition: opacity 0.25s linear 0.25s;
}
.settings.connectionHistoryPlayer.paused .guac-player-controls,
.settings.connectionHistoryPlayer.playing:hover .guac-player-controls {
.settings.connectionHistoryPlayer .guac-player-controls.paused,
.settings.connectionHistoryPlayer .guac-player-controls.playing:hover {
opacity: 1;
-webkit-transition-delay: 0s;
-moz-transition-delay: 0s;

View File

@@ -1,11 +1,6 @@
<guac-viewport class="settings view connectionHistoryPlayer"
ng-class="{
'no-recording' : !selectedRecording,
'paused' : !playing,
'playing' : playing
}">
<guac-viewport class="settings view connectionHistoryPlayer">
<!-- Player for selected recording -->
<guac-player src="tunnel"></guac-player>
</guac-viewport>
</guac-viewport>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="540" height="540" viewBox="0 0 142.875 142.875"><path d="M0 0v160h40V40h120V0H0zm380 0v40h120v120h40V0H380zM0 380v160h160v-40H40V380H0zm500 0v120H380v40h160V380h-40z" style="fill:#000;stroke-width:.999999" transform="scale(.26458)"/><path d="M84.667 68.792h58.208v148.167H84.667z" style="fill:none;stroke-width:.264583" transform="translate(-42.333 -68.792)"/><path d="M52.917 79.375h121.708v121.708H52.917z" style="fill:none;stroke-width:.264583" transform="translate(-42.333 -68.792)"/></svg>

After

Width:  |  Height:  |  Size: 541 B

View File

@@ -478,12 +478,17 @@
"PLAYER" : {
"ACTION_CANCEL" : "@:APP.ACTION_CANCEL",
"ACTION_PAUSE" : "@:APP.ACTION_PAUSE",
"ACTION_PLAY" : "@:APP.ACTION_PLAY",
"ACTION_CANCEL" : "@:APP.ACTION_CANCEL",
"ACTION_PAUSE" : "@:APP.ACTION_PAUSE",
"ACTION_PLAY" : "@:APP.ACTION_PLAY",
"ACTION_SHOW_KEY_LOG" : "Keystroke Log",
"INFO_LOADING_RECORDING" : "Your recording is now being loaded. Please wait...",
"INFO_SEEK_IN_PROGRESS" : "Seeking to the requested position. Please wait..."
"INFO_NO_KEY_LOG" : "Keystroke Log Unavailable",
"INFO_NUMBER_OF_RESULTS" : "{RESULTS} {RESULTS, plural, one{Match} other{Matches}}",
"INFO_SEEK_IN_PROGRESS" : "Seeking to the requested position. Please wait...",
"FIELD_PLACEHOLDER_TEXT_BATCH_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER"
},