Start making the select info frame more change/designable

canary
WolverinDEV 2018-06-20 19:06:55 +02:00
parent 8ca6fe7e01
commit 6bb0179958
13 changed files with 351 additions and 125 deletions

View File

@ -1,9 +1,10 @@
/// <reference path="log.ts" />
/// <reference path="voice/AudioController.ts" />
/// <reference path="proto.ts" />
/// <reference path="ui/view.ts" />
/// <reference path="connection.ts" />
/// <reference path="settings.ts" />
/// <reference path="InfoBar.ts" />
/// <reference path="ui/frames/SelectedItemInfo.ts" />
/// <reference path="FileManager.ts" />
/// <reference path="permission/PermissionManager.ts" />
/// <reference path="permission/GroupManager.ts" />
@ -194,7 +195,7 @@ class TSClient {
break;
}
this.selectInfo.currentSelected = null;
this.selectInfo.setCurrentSelected(null);
this.channelTree.reset();
this.voiceConnection.dropSession();
if(this.serverConnection) this.serverConnection.disconnect();

View File

@ -1,6 +1,5 @@
/// <reference path="ui/channel.ts" />
/// <reference path="client.ts" />
/// <reference path="ui/MusicClient.ts" />
class CommandResult {
success: boolean;

View File

@ -146,12 +146,13 @@ function loadDebug() {
"js/ui/modal/ModalConnect.js",
"js/ui/modal/ModalChangeVolume.js",
"js/ui/modal/ModalBanClient.js",
"js/ui/modal/ModalYesNo.js",
"js/ui/channel.js",
"js/ui/client.js",
"js/ui/server.js",
"js/ui/view.js",
"js/ui/ControlBar.js",
"js/ui/MusicClient.js",
//Load permissions
"js/permission/PermissionManager.js",
@ -175,7 +176,7 @@ function loadDebug() {
"js/FileManager.js",
"js/client.js",
"js/chat.js",
"js/InfoBar.js",
"js/ui/frames/SelectedItemInfo.js",
"js/Identity.js"
])).then(() => {
awaitLoad(loadScripts(["js/main.js"])).then(() => {
@ -188,11 +189,14 @@ function loadDebug() {
function awaitLoad(promises: {path: string, promise: Promise<Boolean>}[]) : Promise<Boolean> {
return new Promise<Boolean>((resolve, reject) => {
let awaiting = promises.length;
let success = true;
for(let entry of promises) {
entry.promise.then(() => {
awaiting--;
if(awaiting == 0) resolve();
}).catch(error => {
success = false;
if(error instanceof TypeError) {
console.error(error);
let name = (error as any).fileName + "@" + (error as any).lineNumber + ":" + (error as any).columnNumber;
@ -236,6 +240,9 @@ function loadTemplates() {
let root = document.getElementById("templates");
while(tags.length > 0)
root.appendChild(tags.item(0));
root = document.getElementById("script");
while(tags.length > 0)
root.appendChild(tags.item(0));
}).catch(error => {
console.error("Could not load templates!");
console.log(error);
@ -254,7 +261,8 @@ function loadSide() {
["vendor/jquery/jquery.min.js", /*"https://code.jquery.com/jquery-latest.min.js"*/],
["https://webrtc.github.io/adapter/adapter-latest.js"]
])).then(() => awaitLoad(loadScripts([
["https://ajax.microsoft.com/ajax/jquery.templates/beta1/jquery.tmpl.min.js"]
//["https://ajax.microsoft.com/ajax/jquery.templates/beta1/jquery.tmpl.min.js"]
["vendor/jsrender/jsrender.min.js"]
]))).then(() => {
//Load the teaweb scripts
loadScript("js/proto.js").then(loadDebug).catch(loadRelease);

View File

@ -5,6 +5,7 @@
/// <reference path="ui/modal/ModalConnect.ts" />
/// <reference path="ui/modal/ModalCreateChannel.ts" />
/// <reference path="ui/modal/ModalBanClient.ts" />
/// <reference path="ui/modal/ModalYesNo.ts" />
/// <reference path="codec/CodecWrapper.ts" />
/// <reference path="settings.ts" />
/// <reference path="log.ts" />
@ -16,6 +17,7 @@ let chat: ChatBox;
let forumIdentity: TeaForumIdentity;
function main() {
$.views.settings.allowCode(true);
//localhost:63343/Web-Client/index.php?disableUnloadDialog=1&default_connect_type=forum&default_connect_url=localhost
//disableUnloadDialog=1&default_connect_type=forum&default_connect_url=localhost&loader_ignore_age=1
AudioController.initializeAudioController();
@ -73,6 +75,11 @@ function main() {
*/
//Modals.spawnSettingsModal();
/*
Modals.spawnYesNo("Are your sure?", "Do you really want to exit?", flag => {
console.log("Response: " + flag);
})
*/
}
app.loadedListener.push(() => main());

View File

@ -13,6 +13,7 @@ interface JQueryStatic<TElement extends Node = HTMLElement> {
spawn<K extends keyof HTMLElementTagNameMap>(tagName: K): JQuery<HTMLElementTagNameMap[K]>;
}
interface String {
format(...fmt): string;
format(arguments: string[]): string;
@ -50,6 +51,12 @@ if(typeof ($) !== "undefined") {
return $(document.createElement(tagName));
}
}
if(!$.prototype.tmpl) {
$.prototype.tmpl = function (values?: any) : JQuery {
return this.render(values);
}
}
}
if (!String.prototype.format) {

View File

@ -1,53 +0,0 @@
/// <reference path="client.ts" />
class MusicClientProperties extends ClientProperties {
music_volume: number = 0;
music_track_id: number = 0;
}
class MusicClientEntry extends ClientEntry {
constructor(clientId, clientName) {
super(clientId, clientName, new MusicClientProperties());
}
get properties() : MusicClientProperties {
return this._properties as MusicClientProperties;
}
showContextMenu(x: number, y: number, on_close: () => void = undefined): void {
spawnMenu(x, y,
{
name: "<b>Change bot name</b>",
icon: "client-change_nickname",
disabled: true,
callback: () => {},
type: MenuEntryType.ENTRY
}, {
name: "Change bot description",
icon: "client-edit",
disabled: true,
callback: () => {},
type: MenuEntryType.ENTRY
}, {
name: "Open music panel",
icon: "client-edit",
disabled: true,
callback: () => {},
type: MenuEntryType.ENTRY
},
MenuEntry.HR(),
{
name: "Delete bot",
icon: "client-delete",
disabled: true,
callback: () => {},
type: MenuEntryType.ENTRY
},
MenuEntry.CLOSE(on_close)
);
}
initializeListener(): void {
super.initializeListener();
}
}

View File

@ -544,4 +544,58 @@ class LocalClientEntry extends ClientEntry {
});
});
}
}
class MusicClientProperties extends ClientProperties {
music_volume: number = 0;
music_track_id: number = 0;
}
class MusicClientEntry extends ClientEntry {
constructor(clientId, clientName) {
super(clientId, clientName, new MusicClientProperties());
}
get properties() : MusicClientProperties {
return this._properties as MusicClientProperties;
}
showContextMenu(x: number, y: number, on_close: () => void = undefined): void {
spawnMenu(x, y,
{
name: "<b>Change bot name</b>",
icon: "client-change_nickname",
disabled: true,
callback: () => {},
type: MenuEntryType.ENTRY
}, {
name: "Change bot description",
icon: "client-edit",
disabled: true,
callback: () => {},
type: MenuEntryType.ENTRY
}, {
name: "Open music panel",
icon: "client-edit",
disabled: true,
callback: () => {},
type: MenuEntryType.ENTRY
},
MenuEntry.HR(),
{
name: "Delete bot",
icon: "client-delete",
disabled: true,
callback: () => {
//TODO
},
type: MenuEntryType.ENTRY
},
MenuEntry.CLOSE(on_close)
);
}
initializeListener(): void {
super.initializeListener();
}
}

View File

@ -1,17 +1,57 @@
/// <reference path="client.ts" />
/// <reference path="../../client.ts" />
abstract class InfoManagerBase {
private timers: NodeJS.Timer[] = [];
private intervals: number[] = [];
protected resetTimers() {
for(let interval of this.intervals)
clearInterval(interval);
}
protected resetIntervals() {
for(let timer of this.timers)
clearTimeout(timer);
}
protected registerTimer(timer: NodeJS.Timer) {
this.timers.push(timer);
}
protected registerInterval(interval: number) {
this.intervals.push(interval);
}
abstract available<V>(object: V) : boolean;
}
abstract class InfoManager<T> extends InfoManagerBase {
abstract createFrame(object: T, html_tag: JQuery<HTMLElement>);
abstract updateFrame(object: T, html_tag: JQuery<HTMLElement>);
finalizeFrame(object: T, frame: JQuery<HTMLElement>) {
this.resetIntervals();
this.resetTimers();
}
}
class InfoBar {
readonly handle: TSClient;
private _currentSelected?: ServerEntry | ChannelEntry | ClientEntry;
private current_selected?: ServerEntry | ChannelEntry | ClientEntry;
private _htmlTag: JQuery<HTMLElement>;
private timers: NodeJS.Timer[] = [];
private intervals: number[] = [];
private current_manager: InfoManagerBase = undefined;
private managers: InfoManagerBase[] = [];
constructor(client: TSClient, htmlTag: JQuery<HTMLElement>) {
this.handle = client;
this._htmlTag = htmlTag;
this.managers.push(new ClientInfoManager());
}
@ -33,54 +73,66 @@ class InfoBar {
return table;
}
set currentSelected(entry: ServerEntry | ChannelEntry | ClientEntry) {
if(this._currentSelected == entry) return;
this._currentSelected = entry;
setCurrentSelected<T extends ServerEntry | ChannelEntry | ClientEntry | undefined>(entry: T) {
if(this.current_selected == entry) return;
if(this.current_manager) {
this.current_manager.finalizeFrame.call(this.current_manager, this.current_selected, this._htmlTag);
this.current_manager = null;
this.current_selected = null;
}
this._htmlTag.empty();
this.buildBar();
this.current_selected = entry;
for(let manager of this.managers) {
if(manager.available(this.current_selected)) {
this.current_manager = manager;
break;
}
}
console.log("Got info manager: %o", this.current_manager);
if(this.current_manager) {
this.current_manager.createFrame.call(this.current_manager, this.current_selected, this._htmlTag);
} else this.buildBar(); //FIXME Remove that this is just for now (because not all types are implemented)
}
get currentSelected() : ServerEntry | ChannelEntry | ClientEntry | undefined {
return this._currentSelected;
get currentSelected() {
return this.current_selected;
}
update(){
this.buildBar();
if(this.current_manager && this.current_selected)
this.current_manager.updateFrame.call(this.current_manager, this.current_selected, this._htmlTag);
}
private updateServerTimings() {
this._htmlTag.find(".uptime").text(formatDate((this._currentSelected as ServerEntry).calculateUptime()));
this._htmlTag.find(".uptime").text(formatDate((this.current_selected as ServerEntry).calculateUptime()));
}
private updateClientTimings() {
this._htmlTag.find(".online").text(formatDate((this._currentSelected as ClientEntry).calculateOnlineTime()));
this._htmlTag.find(".online").text(formatDate((this.current_selected as ClientEntry).calculateOnlineTime()));
}
private buildBar() {
this._htmlTag.empty();
if(!this._currentSelected) return;
if(!this.current_selected) return;
for(let timer of this.timers)
clearTimeout(timer);
for(let timer of this.intervals)
clearInterval(timer);
if(this._currentSelected instanceof ServerEntry) {
if(this._currentSelected.shouldUpdateProperties()) this._currentSelected.updateProperties();
if(this.current_selected instanceof ServerEntry) {
if(this.current_selected.shouldUpdateProperties()) this.current_selected.updateProperties();
let version = this._currentSelected.properties.virtualserver_version;
let version = this.current_selected.properties.virtualserver_version;
if(version.startsWith("TeaSpeak ")) version = version.substr("TeaSpeak ".length);
this._htmlTag.append(this.createInfoTable({
"Name": this._currentSelected.properties.virtualserver_name,
"Name": this.current_selected.properties.virtualserver_name,
"Address": "unknown",
"Type": "TeaSpeak",
"Version": version + " on " + this._currentSelected.properties.virtualserver_platform,
"Uptime": "<a class='uptime'>" + formatDate(this._currentSelected.calculateUptime()) + "</a>",
"Current Channels": this._currentSelected.properties.virtualserver_channelsonline,
"Current Clients": this._currentSelected.properties.virtualserver_clientsonline,
"Current Queries": this._currentSelected.properties.virtualserver_queryclientsonline
"Version": version + " on " + this.current_selected.properties.virtualserver_platform,
"Uptime": "<a class='uptime'>" + formatDate(this.current_selected.calculateUptime()) + "</a>",
"Current Channels": this.current_selected.properties.virtualserver_channelsonline,
"Current Clients": this.current_selected.properties.virtualserver_clientsonline,
"Current Queries": this.current_selected.properties.virtualserver_queryclientsonline
}));
this._htmlTag.append($.spawn("div").css("height", "100%"));
@ -88,7 +140,7 @@ class InfoBar {
requestUpdate.css("min-height", "16px");
requestUpdate.css("bottom", 0);
requestUpdate.text("update info");
if(this._currentSelected.shouldUpdateProperties())
if(this.current_selected.shouldUpdateProperties())
requestUpdate.css("color", "green");
else {
requestUpdate.attr("disabled", "true");
@ -96,7 +148,7 @@ class InfoBar {
}
this._htmlTag.append(requestUpdate);
const _server : ServerEntry = this._currentSelected;
const _server : ServerEntry = this.current_selected;
const _this = this;
requestUpdate.click(function () {
_server.updateProperties();
@ -107,25 +159,25 @@ class InfoBar {
requestUpdate.removeAttr("disabled");
}, _server.nextInfoRequest - new Date().getTime()));
this.intervals.push(setInterval(this.updateServerTimings.bind(this),1000));
} else if(this._currentSelected instanceof ChannelEntry) {
let props = this._currentSelected.properties;
} else if(this.current_selected instanceof ChannelEntry) {
let props = this.current_selected.properties;
this._htmlTag.append(this.createInfoTable({
"Name": this._currentSelected.createChatTag(),
"Topic": this._currentSelected.properties.channel_topic,
"Codec": this._currentSelected.properties.channel_codec,
"Codec Quality": this._currentSelected.properties.channel_codec_quality,
"Type": ChannelType.normalize(this._currentSelected.channelType()),
"Current clients": this._currentSelected.channelTree.clientsByChannel(this._currentSelected).length + " / " + (props.channel_maxclients == -1 ? "Unlimited" : props.channel_maxclients),
"Name": this.current_selected.createChatTag(),
"Topic": this.current_selected.properties.channel_topic,
"Codec": this.current_selected.properties.channel_codec,
"Codec Quality": this.current_selected.properties.channel_codec_quality,
"Type": ChannelType.normalize(this.current_selected.channelType()),
"Current clients": this.current_selected.channelTree.clientsByChannel(this.current_selected).length + " / " + (props.channel_maxclients == -1 ? "Unlimited" : props.channel_maxclients),
"Subscription Status": "unknown",
"Voice Data Encryption": "unknown"
}));
} else if(this._currentSelected instanceof MusicClientEntry) {
} else if(this.current_selected instanceof MusicClientEntry) {
this._htmlTag.append("Im a music bot!");
let frame = $("#tmpl_music_frame" + (this._currentSelected.properties.music_track_id == 0 ? "_empty" : "")).tmpl({
let frame = $("#tmpl_music_frame" + (this.current_selected.properties.music_track_id == 0 ? "_empty" : "")).tmpl({
thumbnail: "img/loading_image.svg"
}).css("align-self", "center");
if(this._currentSelected.properties.music_track_id == 0) {
if(this.current_selected.properties.music_track_id == 0) {
} else {
@ -133,21 +185,22 @@ class InfoBar {
this._htmlTag.append(frame);
//TODO
} else if(this._currentSelected instanceof ClientEntry) { this._currentSelected.updateClientVariables();
let version: string = this._currentSelected.properties.client_version;
} else if(this.current_selected instanceof ClientEntry) {
this.current_selected.updateClientVariables();
let version: string = this.current_selected.properties.client_version;
if(!version) version = "";
let infos = {
"Name": this._currentSelected.createChatTag(),
"Description": this._currentSelected.properties.client_description,
"Version": MessageHelper.formatMessage("{0} on {1}", $.spawn("a").attr("title", version).text(version.split(" ")[0]), this._currentSelected.properties.client_platform),
"Online since": $.spawn("a").addClass("online").text(formatDate(this._currentSelected.calculateOnlineTime())),
"Volume": this._currentSelected.audioController.volume * 100 + " %"
"Name": this.current_selected.createChatTag(),
"Description": this.current_selected.properties.client_description,
"Version": MessageHelper.formatMessage("{0} on {1}", $.spawn("a").attr("title", version).text(version.split(" ")[0]), this.current_selected.properties.client_platform),
"Online since": $.spawn("a").addClass("online").text(formatDate(this.current_selected.calculateOnlineTime())),
"Volume": this.current_selected.audioController.volume * 100 + " %"
};
if(this._currentSelected.properties.client_teaforum_id > 0) {
if(this.current_selected.properties.client_teaforum_id > 0) {
infos["TeaSpeak Account"] = $.spawn("a")
.attr("href", "//forum.teaspeak.de/index.php?members/" + this._currentSelected.properties.client_teaforum_id)
.attr("href", "//forum.teaspeak.de/index.php?members/" + this.current_selected.properties.client_teaforum_id)
.attr("target", "_blank")
.text(this._currentSelected.properties.client_teaforum_id);
.text(this.current_selected.properties.client_teaforum_id);
}
this._htmlTag.append(this.createInfoTable(infos));
@ -166,7 +219,7 @@ class InfoBar {
$.spawn("div").text("Server groups:").css("margin-left", "3px").css("font-weight", "bold").appendTo(header);
header.appendTo(serverGroups);
for(let groupId of this._currentSelected.assignedServerGroupIds()) {
for(let groupId of this.current_selected.assignedServerGroupIds()) {
let group = this.handle.groups.serverGroup(groupId);
if(!group) continue;
@ -200,7 +253,7 @@ class InfoBar {
$.spawn("div").text("Channel group:").css("margin-left", "3px").css("font-weight", "bold").appendTo(header);
header.appendTo(channelGroup);
let group = this.handle.groups.channelGroup(this._currentSelected.assignedChannelGroup());
let group = this.handle.groups.channelGroup(this.current_selected.assignedChannelGroup());
if(group) {
let groupTag = $.spawn("div");
groupTag
@ -218,8 +271,8 @@ class InfoBar {
}
{
if(this._currentSelected.properties.client_flag_avatar.length > 0)
this.handle.fileManager.avatars.generateTag(this._currentSelected)
if(this.current_selected.properties.client_flag_avatar.length > 0)
this.handle.fileManager.avatars.generateTag(this.current_selected)
.css("max-height", "90%")
.css("max-width", "100%").appendTo(this._htmlTag);
}
@ -231,21 +284,80 @@ class InfoBar {
.append($.spawn("a").text(description).css("align-self", "center"));
};
if(!this._currentSelected.properties.client_output_hardware)
if(!this.current_selected.properties.client_output_hardware)
spawnTag("hardware_output_muted", "Speakers/Headphones disabled").appendTo(this._htmlTag);
if(!this._currentSelected.properties.client_input_hardware)
if(!this.current_selected.properties.client_input_hardware)
spawnTag("hardware_input_muted", "Microphone disabled").appendTo(this._htmlTag);
if(this._currentSelected.properties.client_output_muted)
if(this.current_selected.properties.client_output_muted)
spawnTag("output_muted", "Speakers/Headphones Muted").appendTo(this._htmlTag);
if(this._currentSelected.properties.client_input_muted)
if(this.current_selected.properties.client_input_muted)
spawnTag("input_muted", "Microphone Muted").appendTo(this._htmlTag);
}
this.intervals.push(setInterval(this.updateClientTimings.bind(this),1000));
}
}
}
class ClientInfoManager extends InfoManager<ClientEntry> {
available<V>(object: V): boolean {
return typeof object == "object" && object instanceof ClientEntry;
}
createFrame(client: ClientEntry, html_tag: JQuery<HTMLElement>) {
client.updateClientVariables();
this.updateFrame(client, html_tag);
}
updateFrame(client: ClientEntry, html_tag: JQuery<HTMLElement>) {
html_tag.empty();
let properties: any = {};
let version: string = client.properties.client_version;
if(!version) version = "";
properties["client_name"] = client.createChatTag()[0];
properties["client_onlinetime"] = formatDate(client.calculateOnlineTime());
properties["sound_volume"] = client.audioController.volume * 100;
properties["group_server"] = [];
for(let groupId of client.assignedServerGroupIds()) {
let group = client.channelTree.client.groups.serverGroup(groupId);
if(!group) continue;
let group_property = {};
group_property["group_id"] = groupId;
group_property["group_name"] = group.name;
properties["group_server"].push(group_property);
properties["group_" + groupId + "_icon"] = client.channelTree.client.fileManager.icons.generateTag(group.properties.iconid);
}
let group = client.channelTree.client.groups.channelGroup(client.assignedChannelGroup());
if(group) {
properties["group_channel"] = group.id;
properties["group_" + group.id + "_name"] = group.name;
properties["group_" + group.id + "_icon"] = client.channelTree.client.fileManager.icons.generateTag(group.properties.iconid);
}
for(let key in client.properties)
properties["property_" + key] = client.properties[key];
if(client.properties.client_teaforum_id > 0) {
properties["teaspeak_forum"] = $.spawn("a")
.attr("href", "//forum.teaspeak.de/index.php?members/" + client.properties.client_teaforum_id)
.attr("target", "_blank")
.text(client.properties.client_teaforum_id);
}
let rendered = $($("#tmpl_selected_client").render([properties]));
rendered.find("node").each((index, element) => { $(element).replaceWith(properties[$(element).attr("key")]); });
html_tag.append(rendered);
console.log(properties);
}
}

View File

@ -12,7 +12,6 @@ namespace Modals {
let tag = $("#tmpl_connect").contents().clone();
let updateFields = function () {
console.log("UPDATE!");
if(connectIdentity) tag.find(".connect_nickname").attr("placeholder", connectIdentity.name());
else tag.find(".connect_nickname").attr("");

38
js/ui/modal/ModalYesNo.ts Normal file
View File

@ -0,0 +1,38 @@
/// <reference path="../../utils/modal.ts" />
namespace Modals {
export function spawnYesNo(header: BodyCreator, body: BodyCreator, callback: (_: boolean) => any) {
let modal;
modal = createModal({
header: header,
body: body,
footer: () => {
let footer = $.spawn("div");
footer.addClass("modal-button-group");
footer.css("margin-top", "5px");
footer.css("margin-bottom", "5px");
footer.css("text-align", "right");
let button_yes = $.spawn("button");
button_yes.text("Yes");
button_yes.click(() => {
modal.close();
callback(true);
});
footer.append(button_yes);
let button_no = $.spawn("button");
button_no.text("No");
button_no.click(() => {
modal.close();
callback(false);
});
footer.append(button_no);
return footer;
},
width: 750
});
modal.open();
}
}

View File

@ -222,7 +222,7 @@ class ChannelTree {
(entry as ClientEntry).tag.addClass("selected");
else if(entry instanceof ServerEntry)
(entry as ServerEntry).htmlTag.addClass("selected");
this.client.selectInfo.currentSelected = entry;
this.client.selectInfo.setCurrentSelected(entry);
}
clientsByGroup(group: Group) : ClientEntry[] {

View File

@ -173,7 +173,6 @@ class VoiceRecorder {
if(this.microphoneStream) this.microphoneStream.disconnect();
this.microphoneStream = undefined;
/*
if(this.mediaStream) {
if(this.mediaStream.stop)
this.mediaStream.stop();
@ -182,7 +181,6 @@ class VoiceRecorder {
value.stop();
});
}
*/
this.mediaStream = undefined;
}
@ -195,11 +193,6 @@ class VoiceRecorder {
this.mediaStream = stream;
this.microphoneStream = this.audioContext.createMediaStreamSource(stream);
this.microphoneStream.connect(this.processor);
chat.serverChat().appendMessage("Mic channels " + this.microphoneStream.channelCount);
chat.serverChat().appendMessage("Mic channel mode " + this.microphoneStream.channelCountMode);
chat.serverChat().appendMessage("Max channel count " + this.audioContext.destination.maxChannelCount);
chat.serverChat().appendMessage("Sample rate " + this.audioContext.sampleRate);
chat.serverChat().appendMessage("Stream ID " + stream.id);
this.vadHandler.initialiseNewStream(oldStream, this.microphoneStream);
}
}

View File

@ -302,5 +302,66 @@
<a>Not playing any music</a>
</div>
</template>
<script id="tmpl_selected_client" type="text/x-jsrender">
<table>
<tr>
<td>Name:</td>
<td class="info_key"><node key="client_name"/></td>
</tr>
{{if property_client_description.length > 0}}
<tr>
<td>Description:</td>
<td class="info_key">{{>property_client_description}}</td>
</tr>
{{/if}}
<tr>
<td>Version:</td>
<td class="info_key"><a title="{{:property_client_version}}">{{*: data.property_client_version.split(" ")[0]; }}</a> on {{:property_client_platform}}</td>
</tr>
<tr>
<td>Online since:</td>
<td class="info_key update_onlinetime">{{:client_onlinetime}}</td>
</tr>
<tr>
<td>Volume:</td>
<td class="info_key">{{:sound_volume}}%</td>
</tr>
{{if client_teaforum_id > 0}}
<tr>
<td>TeaSpeak Account:</td>
<td class="info_key"><a href="//forum.teaspeak.de/index.php?members/{{:property_client_teaforum_id}}" target="_blank">{{:property_client_teaforum_name}}</a></td>
</tr>
{{/if}}
</table>
<!-- Server groups -->
<div style="display: flex; flex-direction: column;">
<div style="display:flex;margin-top:5px;align-items:center">
<div class="icon client-permission_server_groups"></div>
<div style="margin-left:3px;font-weight:bold">Server groups:</div>
</div>
{{for group_server}}
<div style="display: flex; margin-top: 1px; margin-left: 10px; align-items: center;">
<node key="group_{{:group_id}}_icon"/>
<div style="margin-left: 3px">{{:group_name}}</div>
</div>
{{/for}}
</div>
<!-- Channel group -->
<div style="display: flex; flex-direction: column; margin-bottom: 20px">
<div style="display:flex;margin-top:10px;align-items:center">
<div class="icon client-permission_channel"></div>
<div style="margin-left:3px;font-weight:bold">Channel group:</div>
</div>
<div style="display: flex; margin-top: 1px; margin-left: 10px; align-items: center;">
<node key="group_{{:group_channel}}_icon"/>
<div style="margin-left: 3px">{{*: data["group_" + data.group_channel + "_name"]}}</div>
</div>
</div>
</script>
</body>
</html>