GUACAMOLE-1820: Create UI for viewing, searching, and navigating to key events in session recording player.

This commit is contained in:
James Muehlner
2023-05-15 23:29:58 +00:00
parent dbbc7553f7
commit 2a2cef9189
19 changed files with 1197 additions and 96 deletions

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 types
const TextBatch = $injector.get('TextBatch');
// Required services
const playerTimeService = $injector.get('playerTimeService');
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,53 @@ 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.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 +356,11 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay
$scope.$evalAsync();
};
// Append any extracted batches of typed text
$scope.recording.ontext = function appendTextBatch(text, timestamp) {
$scope.textBatches.push({text, timestamp});
}
// Notify listeners when current position within the recording
// has changed
$scope.recording.onseek = function positionChanged(position, current, total) {

View File

@@ -0,0 +1,137 @@
/*
* 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 types
const TextBatch = $injector.get('TextBatch');
// 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: 'text'})
.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,82 @@
/*
* 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.
*/
/**
* 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,115 @@
/*
* 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.
*/
.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;
}

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,23 @@
<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.timestamp})">
<div class="timestamp">{{ formatTime(batch.timestamp) }}</div>
<div class="text">{{ batch.text }}</div>
</span>
</div>
</div>

View File

@@ -0,0 +1,57 @@
/*
* 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.
*/
/**
* 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 {TextBatch|Object} [template={}]
* The object whose properties should be copied within the new TextBatch.
*/
const TextBatch = function TextBatch(template) {
// Use empty object by default
template = template || {};
/**
* The text that was typed in this batch.
*
* @type String
*/
this.text = template.text;
/**
* The timestamp at which the batch of text was typed.
*
* @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"
},