Improved the server tab navigation bar

canary
WolverinDEV 2020-09-24 15:51:22 +02:00
parent 92dfd41e58
commit f2a7f37c74
29 changed files with 933 additions and 312 deletions

View File

@ -1,4 +1,13 @@
# Changelog:
* **24.09.20**
- Improved the server tab menu
- Better scroll handling
- Automatic update on server state changes
- Added an audio playback indicator
- Rendering the server icon
- Changing the favicon according to the clients status
- Aborting all replaying audio streams when client mutes himself
* **17.09.20**
- Added a settings registry and some minor bug fixing

View File

@ -27,7 +27,7 @@ var initial_css;
<% } else { %>
<title>TeaSpeak-Web</title>
<meta name='og:title' content='TeaSpeak-Web'>
<link rel='shortcut icon' href='img/favicon/teacup.png' type='image/x-icon'>
<link rel='shortcut icon' href='img/favicon/teacup.png' type='image/x-icon' id="favicon">
<%# <link rel="apple-touch-icon" sizes="194x194" href="/apple-touch-icon.png" type="image/png"> %>
<% } %>

View File

@ -5,163 +5,8 @@ html:root {
}
.container-connection-handlers {
$animation_length: .25s;
margin-top: 0;
height: 0;
transition: all $animation_length ease-in-out;
&.shown {
margin-top: -4px;
height: 24px;
transition: all $animation_length ease-in-out;
}
background-color: transparent;
@include user-select(none);
position: relative;
.connection-handlers {
height: 100%;
width: fit-content;
display: flex;
flex-direction: row;
justify-content: left;
overflow-x: auto;
overflow-y: visible;
max-width: 100%;
.connection-container {
padding-top: 4px;
position: relative;
flex-grow: 0;
flex-shrink: 0;
cursor: pointer;
display: inline-flex;
padding-left: 5px;
padding-right: 5px;
height: 24px;
overflow: hidden;
.server-icon {
align-self: center;
margin-right: 5px;
}
.server-name {
color: #a8a8a8;
align-self: center;
margin-right: 20px;
position: relative;
overflow: visible;
text-overflow: clip;
white-space: nowrap;
}
.button-close {
position: absolute;
right: 5px;
align-self: center;
&:hover {
background-color: #212121;
}
}
&.cutoff-name {
.server-name {
max-width: 10em;
margin-right: -5px; /* 5px padding which have to be overcommed */
&:before {
content: '';
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
background: linear-gradient(to right, transparent, #1e1e1e calc(100% - 20px));
}
}
}
&:hover {
background-color: #242425;
}
&.active {
background-color: #2d2f32;
border-bottom: 1px solid #0d9cfd;
}
}
&::-webkit-scrollbar {
display: none;
}
}
.container-scroll {
margin-top: 5px;
position: absolute;
top: 0;
right: 0;
bottom: 0;
display: none;
flex-direction: row;
&.enabled {
display: flex;
}
.button-scroll {
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
border: 1px solid;
@include hex-rgba(border-color, #2222223b);
border-radius: 2px;
background: #e7e7e7;
padding-left: 2px;
padding-right: 2px;
&:hover {
background: #eeeeee;
}
&.disabled {
background: #9e9e9e;
&:hover {
background: #9e9e9e;
}
}
}
}
&.scrollbar {
.connection-handlers {
width: calc(100% - 45px);
}
}
display: flex;
flex-direction: row;
justify-content: stretch;
}

View File

@ -12,31 +12,10 @@
<!-- navigation bar -->
<div class="container-control-bar">
<div id="control_bar" class="control_bar">
<!--
<div class="show-small button-dropdown dropdown-audio" title="{{tr 'Audio settings' /}}">
<div class="buttons">
<div class="button button-display">
<div class="icon_em client-music"></div>
</div>
<div class="dropdown-arrow">
<div class="arrow down"></div>
</div>
</div>
<div class="dropdown">
<div class="btn_mute_input" title="{{tr 'Mute/unmute microphone' /}}">
<div class="icon client-input_muted"></div>
<a>{{tr "Mute/unmute microphone" /}}</a>
</div>
<div class="btn_mute_output" title="{{tr 'Mute/unmute headphones' /}}">
<div class="icon client-output_muted"></div>
<a>{{tr "Mute/unmute headphones" /}}</a>
</div>
</div>
</div>
<div class="divider"></div>
-->
</div>
</div>
<div class="container-connection-handlers" id="connection-handler-list"></div>
<!--
<div class="container-connection-handlers scrollbar" id="connection-handlers">
<div class="connection-handlers"></div>
<div class="container-scroll">
@ -48,6 +27,7 @@
</div>
</div>
</div>
-->
<div class="container-app-main">
<div class="container-channel-chat">

View File

@ -1074,6 +1074,7 @@ export class ConnectionHandler {
this.event_registry.fire("notify_state_updated", { state: "speaker" });
if(!muted) this.sound.play(Sound.SOUND_ACTIVATED); /* play the sound *after* we're setting we've unmuted the sound */
this.update_voice_status();
this.serverConnection.getVoiceConnection().stopAllVoiceReplays();
}
toggleSpeakerMuted() { this.setSpeakerMuted(!this.isSpeakerMuted()); }

View File

@ -2,12 +2,11 @@ import {ConnectionHandler, DisconnectReason} from "./ConnectionHandler";
import {Settings, settings} from "./settings";
import {Registry} from "./events";
import * as top_menu from "./ui/frames/MenuBar";
import * as loader from "tc-loader";
import {Stage} from "tc-loader";
export let server_connections: ConnectionManager;
export function initializeServerConnections() {
if(server_connections) throw tr("Connection manager has already been initialized");
server_connections = new ConnectionManager($("#connection-handlers"));
}
export class ConnectionManager {
private readonly event_registry: Registry<ConnectionManagerEvents>;
private connection_handlers: ConnectionHandler[] = [];
@ -63,7 +62,7 @@ export class ConnectionManager {
this._tag.toggleClass("shown", this.connection_handlers.length > 1);
this._update_scroll();
this.event_registry.fire("notify_handler_created", { handler: handler });
this.event_registry.fire("notify_handler_created", { handler: handler, handlerId: handler.handlerId });
return handler;
}
@ -86,7 +85,7 @@ export class ConnectionManager {
if(handler === this.active_handler)
this.set_active_connection_(this.connection_handlers[0]);
this.event_registry.fire("notify_handler_deleted", { handler: handler });
this.event_registry.fire("notify_handler_deleted", { handler: handler, handlerId: handler.handlerId });
/* destroy all elements */
handler.destroy();
@ -118,8 +117,11 @@ export class ConnectionManager {
const old_handler = this.active_handler;
this.active_handler = handler;
this.event_registry.fire("notify_active_handler_changed", {
old_handler: old_handler,
new_handler: handler
oldHandler: old_handler,
newHandler: handler,
oldHandlerId: old_handler?.handlerId,
newHandlerId: handler?.handlerId
});
old_handler?.events().fire("notify_visibility_changed", { visible: false });
handler?.events().fire("notify_visibility_changed", { visible: true });
@ -173,18 +175,31 @@ export class ConnectionManager {
export interface ConnectionManagerEvents {
notify_handler_created: {
handlerId: string,
handler: ConnectionHandler
},
/* This will also trigger when a connection gets deleted. So if you're just interested to connect event handler to the active connection,
unregister them from the old handler and register them for the new handler every time */
notify_active_handler_changed: {
old_handler: ConnectionHandler | undefined,
new_handler: ConnectionHandler | undefined
oldHandler: ConnectionHandler | undefined,
newHandler: ConnectionHandler | undefined,
oldHandlerId: string | undefined,
newHandlerId: string | undefined
},
/* Will never fire on an active connection handler! */
notify_handler_deleted: {
handlerId: string,
handler: ConnectionHandler
}
}
}
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
name: "server manager init",
function: async () => {
server_connections = new ConnectionManager($("#connection-handlers"));
},
priority: 80
});

View File

@ -141,4 +141,10 @@ export class DummyVoiceConnection extends AbstractVoiceConnection {
getFailedMessage(): string {
return "";
}
isReplayingVoice(): boolean {
return false;
}
stopAllVoiceReplays() { }
}

View File

@ -32,6 +32,10 @@ export interface VoiceConnectionEvents {
},
"notify_whisper_destroyed": {
session: WhisperSession
},
"notify_voice_replay_state_change": {
replaying: boolean
}
}
@ -72,6 +76,9 @@ export abstract class AbstractVoiceConnection {
abstract getEncoderCodec() : number;
abstract setEncoderCodec(codec: number);
abstract stopAllVoiceReplays();
abstract isReplayingVoice() : boolean;
/* the whisper API */
abstract getWhisperSessions() : WhisperSession[];
abstract dropWhisperSession(session: WhisperSession);

View File

@ -114,7 +114,13 @@ export class Registry<Events extends { [key: string]: any } = { [key: string]: a
/* special helper methods for react components */
reactUse<T extends keyof Events>(event: T, handler: (event?: Events[T] & Event<Events, T>) => void, condition?: boolean) {
/**
* @param event
* @param handler
* @param condition
* @param reactEffectDependencies
*/
reactUse<T extends keyof Events>(event: T, handler: (event?: Events[T] & Event<Events, T>) => void, condition?: boolean, reactEffectDependencies?: any[]) {
if(typeof condition === "boolean" && !condition) {
useEffect(() => {});
return;
@ -123,7 +129,7 @@ export class Registry<Events extends { [key: string]: any } = { [key: string]: a
useEffect(() => {
handlers.push(handler);
return () => handlers.remove(handler);
});
}, reactEffectDependencies);
}
connectAll<EOther, T extends keyof Events & keyof EOther>(target: EventReceiver<Events>) {

View File

@ -46,7 +46,7 @@ export function load_default_states(event_registry: Registry<ClientGlobalControl
export function initialize(event_registry: Registry<ClientGlobalControlEvents>) {
let current_connection_handler: ConnectionHandler | undefined;
server_connections.events().on("notify_active_handler_changed", event => current_connection_handler = event.new_handler);
server_connections.events().on("notify_active_handler_changed", event => current_connection_handler = event.newHandler);
//initialize_sounds(event_registry);
event_registry.on("action_open_window", event => {

View File

@ -1,4 +1,5 @@
import * as loader from "tc-loader";
import {Stage} from "tc-loader";
import {settings, Settings} from "tc-shared/settings";
import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
@ -6,7 +7,7 @@ import * as bipc from "./ipc/BrowserIPC";
import * as sound from "./sound/Sounds";
import * as i18n from "./i18n/localize";
import {tra} from "./i18n/localize";
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {createInfoModal} from "tc-shared/ui/elements/Modal";
import * as stats from "./stats";
import * as fidentity from "./profiles/identities/TeaForumIdentity";
@ -42,10 +43,10 @@ import {ConnectRequestData} from "tc-shared/ipc/ConnectHandler";
import "./video-viewer/Controller";
import "./profiles/ConnectionProfile";
import "./update/UpdaterWeb";
import ContextMenuEvent = JQuery.ContextMenuEvent;
import {defaultConnectProfile, findConnectProfile} from "tc-shared/profiles/ConnectionProfile";
import {spawnGlobalSettingsEditor} from "tc-shared/ui/modal/global-settings-editor/Controller";
import {initializeServerConnections, server_connections} from "tc-shared/ConnectionManager";
import {server_connections} from "tc-shared/ConnectionManager";
import {initializeConnectionUIList} from "tc-shared/ui/frames/connection-handler-list/Controller";
import ContextMenuEvent = JQuery.ContextMenuEvent;
let preventWelcomeUI = false;
async function initialize() {
@ -61,20 +62,8 @@ async function initialize() {
}
async function initialize_app() {
try { //Initialize main template
const main = $("#tmpl_main").renderTag({
multi_session: !settings.static_global(Settings.KEY_DISABLE_MULTI_SESSION),
app_version: __build.version
}).dividerfy();
initializeConnectionUIList();
$("body").append(main);
} catch(error) {
log.error(LogCategory.GENERAL, error);
loader.critical_error(tr("Failed to setup main page!"));
return;
}
initializeServerConnections();
global_ev_handler.initialize(global_client_actions);
{
const bar = (
@ -83,6 +72,7 @@ async function initialize_app() {
ReactDOM.render(bar, $(".container-control-bar")[0]);
}
/*
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
name: "settings init",
@ -201,6 +191,16 @@ function main() {
const initial_handler = server_connections.spawn_server_connection();
initial_handler.acquireInputHardware().then(() => {});
server_connections.set_active_connection(initial_handler);
/* Just a test */
{
server_connections.spawn_server_connection();
server_connections.spawn_server_connection();
server_connections.spawn_server_connection();
server_connections.spawn_server_connection();
server_connections.spawn_server_connection();
}
/** Setup the XF forum identity **/
fidentity.update_forum();
keycontrol.initialize();
@ -476,7 +476,7 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
return;
}
},
priority: 100
priority: 110
});
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
@ -511,4 +511,23 @@ loader.register_task(loader.Stage.LOADED, {
}
},
priority: 20
});
loader.register_task(Stage.JAVASCRIPT_INITIALIZING,{
name: "app init",
function: async () => {
try { //Initialize main template
const main = $("#tmpl_main").renderTag({
multi_session: !settings.static_global(Settings.KEY_DISABLE_MULTI_SESSION),
app_version: __build.version
}).dividerfy();
$("body").append(main);
} catch(error) {
log.error(LogCategory.GENERAL, error);
loader.critical_error(tr("Failed to setup main page!"));
return;
}
},
priority: 100
});

View File

@ -264,6 +264,38 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
return this._properties;
}
getStatusIcon() : ClientIcon {
if (this.properties.client_type_exact == ClientType.CLIENT_QUERY) {
return ClientIcon.ServerQuery;
} else if (this.properties.client_away) {
return ClientIcon.Away;
} else if (!this.getVoiceClient() && !(this instanceof LocalClientEntry)) {
return ClientIcon.InputMutedLocal;
} else if (!this.properties.client_output_hardware) {
return ClientIcon.HardwareOutputMuted;
} else if (this.properties.client_output_muted) {
return ClientIcon.OutputMuted;
} else if (!this.properties.client_input_hardware) {
return ClientIcon.HardwareInputMuted;
} else if (this.properties.client_input_muted) {
return ClientIcon.InputMuted;
} else {
if (this.isSpeaking()) {
if (this.properties.client_is_channel_commander) {
return ClientIcon.PlayerCommanderOn;
} else {
return ClientIcon.PlayerOn;
}
} else {
if (this.properties.client_is_channel_commander) {
return ClientIcon.PlayerCommanderOff;
} else {
return ClientIcon.PlayerOff;
}
}
}
}
currentChannel() : ChannelEntry { return this._channel; }
clientNickName(){ return this.properties.client_nickname; }
clientUid(){ return this.properties.client_unique_identifier; }

View File

@ -0,0 +1,125 @@
import {Registry} from "tc-shared/events";
import {ConnectionListUIEvents, HandlerConnectionState} from "tc-shared/ui/frames/connection-handler-list/Definitions";
import * as React from "react";
import * as ReactDOM from "react-dom";
import {ConnectionHandlerList} from "tc-shared/ui/frames/connection-handler-list/Renderer";
import {server_connections} from "tc-shared/ConnectionManager";
import {LogCategory, logWarn} from "tc-shared/log";
import {ConnectionState} from "tc-shared/ConnectionHandler";
import {LocalIcon} from "tc-shared/file/Icons";
export function initializeConnectionUIList() {
const container = document.getElementById("connection-handler-list");
const events = new Registry<ConnectionListUIEvents>();
events.enableDebug("Handler-List");
initializeController(events);
ReactDOM.render(React.createElement(ConnectionHandlerList, { events: events }), container);
}
function initializeController(events: Registry<ConnectionListUIEvents>) {
let registeredHandlerEvents: {[key: string]:(() => void)[]} = {};
events.on("notify_destroy", () => {
Object.keys(registeredHandlerEvents).forEach(handlerId => registeredHandlerEvents[handlerId].forEach(callback => callback()));
registeredHandlerEvents = {};
});
events.on("query_handler_list", () => {
events.fire_async("notify_handler_list", { handlerIds: server_connections.all_connections().map(e => e.handlerId), activeHandlerId: server_connections.active_connection()?.handlerId });
});
events.on("notify_destroy", server_connections.events().on("notify_handler_created", event => {
let listeners = [];
const handlerId = event.handlerId;
listeners.push(event.handler.events().on("notify_connection_state_changed", () => events.fire_async("query_handler_status", { handlerId: handlerId })));
/* register to icon and name change updates */
listeners.push(event.handler.channelTree.server.events.on("notify_properties_updated", event => {
if("virtualserver_name" in event.updated_properties || "virtualserver_icon_id" in event.updated_properties) {
events.fire_async("query_handler_status", { handlerId: handlerId });
}
}));
/* register to voice playback change events */
listeners.push(event.handler.getServerConnection().getVoiceConnection().events.on("notify_voice_replay_state_change", () => {
events.fire_async("query_handler_status", { handlerId: handlerId });
}));
registeredHandlerEvents[event.handlerId] = listeners;
events.fire_async("query_handler_list");
}));
events.on("notify_destroy", server_connections.events().on("notify_handler_deleted", event => {
(registeredHandlerEvents[event.handlerId] || []).forEach(callback => callback());
delete registeredHandlerEvents[event.handlerId];
events.fire_async("query_handler_list");
}));
events.on("action_set_active_handler", event => {
const handler = server_connections.findConnection(event.handlerId);
if(!handler) {
logWarn(LogCategory.CLIENT, tr("Tried to activate an invalid server connection handler with id %s"), event.handlerId);
return;
}
server_connections.set_active_connection(handler);
});
events.on("notify_destroy", server_connections.events().on("notify_active_handler_changed", event => {
events.fire_async("notify_active_handler", { handlerId: event.newHandlerId });
}));
events.on("action_destroy_handler", event => {
const handler = server_connections.findConnection(event.handlerId);
if(!handler) {
logWarn(LogCategory.CLIENT, tr("Tried to destroy an invalid server connection handler with id %s"), event.handlerId);
return;
}
server_connections.destroy_server_connection(handler);
});
events.on("query_handler_status", event => {
const handler = server_connections.findConnection(event.handlerId);
if(!handler) {
logWarn(LogCategory.CLIENT, tr("Tried to query a status for an invalid server connection handler with id %s"), event.handlerId);
return;
}
let state: HandlerConnectionState;
switch (handler.connection_state) {
case ConnectionState.CONNECTED:
state = "connected";
break;
case ConnectionState.AUTHENTICATING:
case ConnectionState.CONNECTING:
case ConnectionState.INITIALISING:
state = "connecting";
break;
default:
state = "disconnected";
break;
}
let icon: LocalIcon | undefined;
let iconId = handler.channelTree.server.properties.virtualserver_icon_id;
if(iconId !== 0) {
icon = handler.fileManager.icons.load_icon(handler.channelTree.server.properties.virtualserver_icon_id);
}
events.fire_async("notify_handler_status", {
handlerId: event.handlerId,
status: {
handlerName: handler.channelTree.server.properties.virtualserver_name,
connectionState: state,
voiceReplaying: handler.getServerConnection().getVoiceConnection().isReplayingVoice(),
serverIcon: icon
}
});
});
}

View File

@ -0,0 +1,36 @@
import {LocalIcon} from "tc-shared/file/Icons";
export type HandlerConnectionState = "disconnected" | "connecting" | "connected";
export type HandlerStatus = {
connectionState: HandlerConnectionState,
handlerName: string,
voiceReplaying: boolean,
serverIcon: LocalIcon | undefined
}
export interface ConnectionListUIEvents {
action_set_active_handler: { handlerId: string },
action_destroy_handler: { handlerId: string },
action_scroll: { direction: "left" | "right" }
query_handler_status: { handlerId: string },
query_handler_list: {},
notify_handler_list: {
handlerIds: string[],
activeHandlerId: string | undefined
},
notify_active_handler: {
handlerId: string
},
notify_handler_status: {
handlerId: string,
status: HandlerStatus
},
notify_scroll_status: {
left: boolean,
right: boolean
},
notify_destroy: {}
}

View File

@ -0,0 +1,203 @@
@import "../../../../css/static/properties";
@import "../../../../css/static/mixin";
.container {
$animation_length: .25s;
max-width: 100%;
margin-top: 0;
height: 0;
transition: all $animation_length ease-in-out;
&.shown {
margin-top: -4px;
height: 24px;
transition: all $animation_length ease-in-out;
}
background-color: transparent;
@include user-select(none);
position: relative;
.handlerList {
height: 100%;
width: fit-content;
display: flex;
flex-direction: row;
justify-content: left;
overflow-x: auto;
overflow-y: visible;
max-width: 100%;
scroll-behavior: smooth;
.handler {
padding-top: 4px;
position: relative;
flex-grow: 0;
flex-shrink: 0;
cursor: pointer;
display: inline-flex;
padding-left: 5px;
padding-right: 5px;
height: 24px;
overflow: hidden;
border-bottom: 1px solid transparent;
@include transition(all ease-in-out $button_hover_animation_time);
.icon {
width: 16px;
height: 16px;
align-self: center;
margin-right: 5px;
}
.name {
color: #a8a8a8;
align-self: center;
margin-right: 20px;
position: relative;
overflow: visible;
text-overflow: clip;
white-space: nowrap;
}
.buttonClose {
width: 16px;
height: 16px;
position: absolute;
right: 5px;
align-self: center;
&:hover {
background-color: #212121;
}
}
&.cutoffName {
.name {
max-width: 15em;
margin-right: -5px; /* 5px padding which have to be overcommed */
&:before {
content: '';
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
background: linear-gradient(to right, transparent calc(100% - 50px), #1e1e1e calc(100% - 25px));
}
}
}
&:hover {
background-color: #242425;
&.cutoffName .name:before {
background: linear-gradient(to right, transparent calc(100% - 50px), #242425 calc(100% - 25px));
}
}
&.active {
border-bottom-color: #0d9cfd;
background-color: #2d2f32;
&.cutoffName .name:before {
background: linear-gradient(to right, transparent calc(100% - 50px), #2d2f32 calc(100% - 25px));
}
}
&.audioPlayback {
border-bottom-color: #68c1fd;
}
}
.scrollSpacer {
display: none;
width: calc(2em + 8px);
height: 1px;
flex-shrink: 0;
flex-grow: 0;
}
&::-webkit-scrollbar {
display: none;
}
}
.containerScroll {
margin-top: 5px;
position: absolute;
top: 0;
right: 0;
bottom: 0;
display: none;
flex-direction: row;
&.shown {
display: flex;
}
.buttonScroll {
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
border: 1px solid;
@include hex-rgba(border-color, #2222223b);
border-radius: 2px;
background: #e7e7e7;
padding-left: 2px;
padding-right: 2px;
&:hover {
background: #eeeeee;
}
&.disabled {
background: #9e9e9e;
&:hover {
background: #9e9e9e;
}
}
}
}
&.scrollShown {
/*
.connection-handlers {
width: calc(100% - 45px);
}
*/
.handlerList .scrollSpacer {
display: block;
}
}
}

View File

@ -0,0 +1,243 @@
import {Registry} from "tc-shared/events";
import {ConnectionListUIEvents, HandlerStatus} from "tc-shared/ui/frames/connection-handler-list/Definitions";
import * as React from "react";
import {useContext, useEffect, useRef, useState} from "react";
import {IconRenderer, LocalIconRenderer} from "tc-shared/ui/react-elements/Icon";
import {ClientIcon} from "svg-sprites/client-icons";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import ResizeObserver from 'resize-observer-polyfill';
import {LogCategory, logWarn} from "tc-shared/log";
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
const cssStyle = require("./Renderer.scss");
const Events = React.createContext<Registry<ConnectionListUIEvents>>(undefined);
const ConnectionHandler = (props: { handlerId: string, active: boolean }) => {
const events = useContext(Events);
const [ status, setStatus ] = useState<HandlerStatus | "loading">(() => {
events.fire_async("query_handler_status", { handlerId: props.handlerId });
return "loading";
});
events.reactUse("notify_handler_status", event => {
if(event.handlerId !== props.handlerId) {
return;
}
setStatus(event.status);
});
let displayedName;
let cutoffName = false;
let voiceReplaying = false;
let icon = <IconRenderer icon={ClientIcon.ServerGreen} key={"default"} />;
if(status === "loading") {
displayedName = tr("loading status");
} else {
switch (status.connectionState) {
case "connected":
cutoffName = status.handlerName.length > 30;
voiceReplaying = status.voiceReplaying;
displayedName = <React.Fragment key={"connected"}>{status.handlerName}</React.Fragment>;
if(status.serverIcon) {
icon = <LocalIconRenderer icon={status.serverIcon} key={"server-icon"} />;
}
break;
case "connecting":
displayedName = <Translatable key={"connecting"}>Connecting to server <LoadingDots /></Translatable>;
break;
case "disconnected":
displayedName = <Translatable key={"not connected"}>Not connected</Translatable>;
break;
}
}
return (
<div className={cssStyle.handler + " " + (props.active ? cssStyle.active : "") + " " + (cutoffName ? cssStyle.cutoffName : "") + " " + (voiceReplaying ? cssStyle.audioPlayback : "")}
onClick={() => {
if(props.active) {
return;
}
events.fire("action_set_active_handler", { handlerId: props.handlerId });
}}
>
<div className={cssStyle.icon}>
{icon}
</div>
<div className={cssStyle.name} title={displayedName}>{displayedName}</div>
<div className={cssStyle.buttonClose} onClick={() => {
events.fire("action_destroy_handler", { handlerId: props.handlerId })
}}>
<IconRenderer icon={ClientIcon.TabCloseButton} />
</div>
</div>
);
}
const HandlerList = (props: { refContainer: React.Ref<HTMLDivElement>, refSpacer: React.Ref<HTMLDivElement> }) => {
const events = useContext(Events);
const [ handlers, setHandlers ] = useState<string[] | "loading">(() => {
events.fire("query_handler_list");
return "loading";
});
const [ activeHandler, setActiveHandler ] = useState<string>();
events.reactUse("notify_handler_list", event => {
setHandlers(event.handlerIds.slice());
setActiveHandler(event.activeHandlerId);
});
events.reactUse("notify_active_handler", event => setActiveHandler(event.handlerId));
return (
<div className={cssStyle.handlerList} ref={props.refContainer}>
{handlers === "loading" ? undefined :
handlers.map(handlerId => <ConnectionHandler handlerId={handlerId} key={handlerId} active={handlerId === activeHandler} />)
}
<div className={cssStyle.scrollSpacer} ref={props.refSpacer} />
</div>
)
}
const ScrollMenu = (props: { shown: boolean }) => {
const events = useContext(Events);
const [ scrollLeft, setScrollLeft ] = useState(false);
const [ scrollRight, setScrollRight ] = useState(false);
events.on("notify_scroll_status", event => {
setScrollLeft(event.left);
setScrollRight(event.right);
});
return (
<div className={cssStyle.containerScroll + " " + (props.shown ? cssStyle.shown : "")}>
<div className={cssStyle.buttonScroll + " " + (!scrollLeft ? cssStyle.disabled : "")} onClick={() => scrollLeft && events.fire_async("action_scroll", { direction: "left" })}>
<ClientIconRenderer icon={ClientIcon.ArrowLeft} />
</div>
<div className={cssStyle.buttonScroll + " " + (!scrollRight ? cssStyle.disabled : "")} onClick={() => scrollRight && events.fire_async("action_scroll", { direction: "right" })}>
<ClientIconRenderer icon={ClientIcon.ArrowRight} />
</div>
</div>
);
}
export const ConnectionHandlerList = (props: { events: Registry<ConnectionListUIEvents> }) => {
const [ shown, setShown ] = useState(false);
const observer = useRef<ResizeObserver>();
const refHandlerContainer = useRef<HTMLDivElement>();
const refContainer = useRef<HTMLDivElement>();
const refScrollSpacer = useRef<HTMLDivElement>();
const refScrollShown = useRef(false);
const [ scrollShown, setScrollShown ] = useState(false);
refScrollShown.current = scrollShown;
const updateScrollButtons = (scrollLeft: number) => {
const container = refHandlerContainer.current;
if(!container) { return; }
props.events.fire_async("notify_scroll_status", { right: Math.ceil(scrollLeft + container.clientWidth + 2) < container.scrollWidth, left: scrollLeft !== 0 });
}
useEffect(() => {
if(!refHandlerContainer.current) {
return;
}
if(observer.current) {
throw "useEffect called without a detachment...";
}
observer.current = new ResizeObserver(events => {
if(!refContainer.current || !refHandlerContainer.current) {
return;
}
if(events.length !== 1) {
logWarn(LogCategory.CLIENT, tr("Handler list resize observer received events for more than one element (%d elements)."), events.length);
return;
}
const width = events[0].target.scrollWidth;
const shouldScroll = width > refContainer.current.clientWidth;
let scrollShown = refScrollShown.current;
if(scrollShown && !shouldScroll) {
props.events.fire_async("notify_scroll_status", { left: false, right: false });
} else if(!scrollShown && shouldScroll) {
props.events.fire_async("notify_scroll_status", { left: false, right: true });
} else {
return;
}
setScrollShown(shouldScroll);
});
observer.current.observe(refHandlerContainer.current);
return () => {
observer.current?.disconnect();
observer.current = undefined;
}
}, []);
props.events.reactUse("notify_handler_list", event => setShown(event.handlerIds.length > 1));
props.events.reactUse("action_scroll", event => {
if(!scrollShown || !refHandlerContainer.current || !refScrollSpacer.current) {
return;
}
const container = refHandlerContainer.current;
const scrollContainer = refScrollSpacer.current;
const getChildAt = (width: number) => {
let currentChild: HTMLDivElement;
for(const childElement of container.children) {
const child = childElement as HTMLDivElement;
if((!currentChild || child.offsetLeft > currentChild.offsetLeft) && child.offsetLeft <= width) {
currentChild = child;
}
}
return currentChild;
}
let scrollLeft;
if(event.direction === "right") {
const currentLeft = container.scrollLeft;
const target = getChildAt(currentLeft + container.clientWidth + 5 - scrollContainer.clientWidth);
if(!target) {
/* well we're fucked up? :D */
scrollLeft = container.scrollLeft + 50;
} else {
scrollLeft = target.offsetLeft + target.clientWidth - container.clientWidth + scrollContainer.clientWidth;
}
} else if(event.direction === "left") {
const currentLeft = container.scrollLeft;
const target = getChildAt(currentLeft - 1);
if(!target) {
/* well we're fucked up? :D */
scrollLeft = container.scrollLeft - 50;
} else {
scrollLeft = target.offsetLeft;
}
} else {
return;
}
container.scrollLeft = scrollLeft;
updateScrollButtons(scrollLeft);
}, true, [scrollShown]);
return (
<Events.Provider value={props.events}>
<div className={cssStyle.container + " " + (shown ? cssStyle.shown : "") + " " + (scrollShown ? cssStyle.scrollShown : "")} ref={refContainer}>
<HandlerList refContainer={refHandlerContainer} refSpacer={refScrollSpacer} />
<ScrollMenu shown={scrollShown} />
</div>
</Events.Provider>
)
};

View File

@ -434,12 +434,12 @@ export class ControlBar extends React.Component<ControlBarProperties, {}> {
}
private handleActiveConnectionHandlerChanged(event: ConnectionManagerEvents["notify_active_handler_changed"]) {
if(event.old_handler)
this.unregisterConnectionHandlerEvents(event.old_handler);
if(event.oldHandler)
this.unregisterConnectionHandlerEvents(event.oldHandler);
this.connection = event.new_handler;
if(event.new_handler)
this.registerConnectionHandlerEvents(event.new_handler);
this.connection = event.newHandler;
if(event.newHandler)
this.registerConnectionHandlerEvents(event.newHandler);
this.event_registry.fire("set_connection_handler", { handler: this.connection });
this.event_registry.fire("update_state_all");

View File

@ -217,16 +217,17 @@ const OverrideVariableInfo = (props: { events: Registry<CssEditorEvents> }) => {
selectedVariable.overwriteValue = event.enabled;
setOverwriteEnabled(event.enabled);
if (event.enabled)
if (event.enabled) {
setOverwriteValue(event.value);
});
}
}, true, [selectedVariable]);
props.events.reactUse("action_change_override_value", event => {
if (event.variableName !== selectedVariable?.name)
return;
setOverwriteValue(event.value);
});
}, true, [selectedVariable]);
return (<>
<div className={cssStyle.detail}>

View File

@ -78,7 +78,7 @@ const ActivityBar = (props: { events: Registry<MicrophoneSettingsEvents>, device
setStatus({mode: "error", message: device.error + ""});
}
}
});
}, true, [status]);
let error;

View File

@ -1,26 +1,26 @@
import * as React from "react";
import {LocalIcon} from "tc-shared/file/Icons";
export interface IconProperties {
export const IconRenderer = (props: {
icon: string | LocalIcon;
title?: string;
}
export class IconRenderer extends React.Component<IconProperties, {}> {
render() {
if(!this.props.icon)
return <div className={"icon-container icon-empty"} title={this.props.title} />;
else if(typeof this.props.icon === "string")
return <div className={"icon " + this.props.icon} title={this.props.title} />;
else if(this.props.icon instanceof LocalIcon)
return <LocalIconRenderer icon={this.props.icon} title={this.props.title} />;
else throw "JQuery icons are not longer supported";
className?: string;
}) => {
if(!props.icon) {
return <div className={"icon-container icon-empty " + props.className} title={props.title} />;
} else if(typeof props.icon === "string") {
return <div className={"icon " + props.icon + " " + props.className} title={props.title} />;
} else if(props.icon instanceof LocalIcon) {
return <LocalIconRenderer icon={props.icon} title={props.title} className={props.className} />;
} else {
throw "JQuery icons are not longer supported";
}
}
export interface LoadedIconRenderer {
icon: LocalIcon;
title?: string;
className?: string;
}
export class LocalIconRenderer extends React.Component<LoadedIconRenderer, {}> {
@ -39,18 +39,18 @@ export class LocalIconRenderer extends React.Component<LoadedIconRenderer, {}> {
render() {
const icon = this.props.icon;
if(!icon || icon.status === "empty" || icon.status === "destroyed")
return <div key={"empty"} className={"icon-container icon-empty"} title={this.props.title} />;
return <div key={"empty"} className={"icon-container icon-empty " + this.props.className} title={this.props.title} />;
else if(icon.status === "loaded") {
if(icon.icon_id >= 0 && icon.icon_id <= 1000) {
if(icon.icon_id === 0)
return <div key={"loaded-empty"} className={"icon-container icon-empty"} title={this.props.title} />;
return <div key={"loaded"} className={"icon_em client-group_" + icon.icon_id} />;
}
return <div key={"icon"} className={"icon-container"}><img style={{ maxWidth: "100%", maxHeight: "100%" }} src={icon.loaded_url} alt={this.props.title || ("icon " + icon.icon_id)} /></div>;
return <div key={"icon"} className={"icon-container " + this.props.className}><img style={{ maxWidth: "100%", maxHeight: "100%" }} src={icon.loaded_url} alt={this.props.title || ("icon " + icon.icon_id)} /></div>;
} else if(icon.status === "loading")
return <div key={"loading"} className={"icon-container"} title={this.props.title}><div className={"icon_loading"} /></div>;
return <div key={"loading"} className={"icon-container " + this.props.className} title={this.props.title}><div className={"icon_loading"} /></div>;
else if(icon.status === "error")
return <div key={"error"} className={"icon client-warning"} title={icon.error_message || tr("Failed to load icon")} />;
return <div key={"error"} className={"icon client-warning " + this.props.className} title={icon.error_message || tr("Failed to load icon")} />;
}
componentDidMount(): void {

View File

@ -19,11 +19,14 @@ class TitleRenderer {
}
setInstance(instance: AbstractModal) {
if(this.modalInstance)
if(this.modalInstance) {
ReactDOM.unmountComponentAtNode(this.htmlContainer);
}
this.modalInstance = instance;
if(this.modalInstance)
if(this.modalInstance) {
ReactDOM.render(<>{this.modalInstance.title()}</>, this.htmlContainer);
}
}
}

View File

@ -20,85 +20,37 @@ import {LocalIconRenderer} from "tc-shared/ui/react-elements/Icon";
import * as DOMPurify from "dompurify";
import {ClientIcon} from "svg-sprites/client-icons";
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {useState} from "react";
const clientStyle = require("./Client.scss");
const viewStyle = require("./View.scss");
interface ClientIconProperties {
client: ClientEntryController;
}
const IconUpdateKeys: (keyof ClientProperties)[] = [
"client_away",
"client_input_hardware",
"client_output_hardware",
"client_output_muted",
"client_input_muted",
"client_is_channel_commander",
"client_talk_power"
];
@ReactEventHandler<ClientSpeakIcon>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ClientSpeakIcon extends ReactComponentBase<ClientIconProperties, {}> {
private static readonly IconUpdateKeys: (keyof ClientProperties)[] = [
"client_away",
"client_input_hardware",
"client_output_hardware",
"client_output_muted",
"client_input_muted",
"client_is_channel_commander",
"client_talk_power"
];
export const ClientStatusIndicator = (props: { client: ClientEntryController, renderer?: (icon: ClientIcon) => React.ReactElement }) => {
const [ revision, setRevision ] = useState(0);
render() {
const client = this.props.client;
const properties = client.properties;
let icon: ClientIcon;
if (properties.client_type_exact == ClientType.CLIENT_QUERY) {
icon = ClientIcon.ServerQuery;
} else {
if (properties.client_away) {
icon = ClientIcon.Away;
} else if (!client.getVoiceClient() && !(this instanceof LocalClientEntry)) {
icon = ClientIcon.InputMutedLocal;
} else if (!properties.client_output_hardware) {
icon = ClientIcon.HardwareOutputMuted;
} else if (properties.client_output_muted) {
icon = ClientIcon.OutputMuted;
} else if (!properties.client_input_hardware) {
icon = ClientIcon.HardwareInputMuted;
} else if (properties.client_input_muted) {
icon = ClientIcon.InputMuted;
} else {
if (client.isSpeaking()) {
if (properties.client_is_channel_commander) {
icon = ClientIcon.PlayerCommanderOn;
} else {
icon = ClientIcon.PlayerOn;
}
} else {
if (properties.client_is_channel_commander) {
icon = ClientIcon.PlayerCommanderOff;
} else {
icon = ClientIcon.PlayerOff;
}
}
}
}
return <ClientIconRenderer icon={icon}/>
}
@EventHandler<ClientEvents>("notify_properties_updated")
private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) {
for (const key of ClientSpeakIcon.IconUpdateKeys)
props.client.events.reactUse("notify_properties_updated", event => {
for (const key of IconUpdateKeys) {
if (key in event.updated_properties) {
this.forceUpdate();
setRevision(revision + 1);
return;
}
}
}
});
@EventHandler<ClientEvents>("notify_mute_state_change")
private handleMuteStateChange() {
this.forceUpdate();
}
props.client.events.reactUse("notify_mute_state_change", () => setRevision(revision + 1));
props.client.events.reactUse("notify_speak_state_change", () => setRevision(revision + 1));
@EventHandler<ClientEvents>("notify_speak_state_change")
private handleSpeakStateChange() {
this.forceUpdate();
}
return props.renderer ? props.renderer(props.client.getStatusIcon()) : <ClientIconRenderer icon={props.client.getStatusIcon()} />;
}
interface ClientServerGroupIconsProperties {
@ -390,7 +342,7 @@ export class ClientEntry extends TreeEntry<ClientEntryProperties, ClientEntrySta
onContextMenu={e => this.onContextMenu(e)}
>
<UnreadMarker entry={this.props.client}/>
<ClientSpeakIcon client={this.props.client}/>
<ClientStatusIndicator client={this.props.client}/>
{this.state.rename ?
<ClientNameEdit key={"rename"} editFinished={name => this.onEditFinished(name)}
initialName={this.state.renameInitialName || this.props.client.properties.client_nickname}/> :

View File

@ -12,4 +12,7 @@ import "./hooks/AudioRecorder";
import "./UnloadHandler";
import "./ui/FaviconRenderer";
export = require("tc-shared/main");

View File

@ -0,0 +1,86 @@
import * as loader from "tc-loader";
import {Stage} from "tc-loader";
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
import * as React from "react";
import {useState} from "react";
import * as ReactDOM from "react-dom";
import {ClientStatusIndicator} from "tc-shared/ui/tree/Client";
import {server_connections} from "tc-shared/ConnectionManager";
import {
ClientIcon,
spriteEntries as kClientSpriteEntries,
spriteUrl as kClientSpriteUrl
} from "svg-sprites/client-icons";
let iconImage: HTMLImageElement;
async function initializeFaviconRenderer() {
iconImage = new Image();
iconImage.src = kClientSpriteUrl;
await new Promise((resolve, reject) => {
iconImage.onload = resolve;
iconImage.onerror = () => reject("failed to load client icon sprite");
});
let container = document.createElement("span");
ReactDOM.render(ReactDOM.createPortal(<FaviconRenderer />, document.head), container, () => {
document.getElementById("favicon").remove();
});
//container.remove();
}
function clientIconToDataUrl(icon: ClientIcon) : string | undefined {
const sprite = kClientSpriteEntries.find(e => e.className === icon);
if(!sprite) {
return;
}
const canvas = document.createElement("canvas");
canvas.width = sprite.width;
canvas.height = sprite.height;
const context = canvas.getContext("2d");
context.drawImage(iconImage, sprite.xOffset, sprite.yOffset, sprite.width, sprite.height, 0, 0, sprite.width, sprite.height);
return canvas.toDataURL();
}
const FaviconRenderer = () => {
const [ handler, setHandler ] = useState<ConnectionHandler>(server_connections.active_connection());
server_connections.events().reactUse("notify_active_handler_changed", event => setHandler(event.newHandler));
return handler ? <HandlerFaviconRenderer connection={handler} key={"handler-" + handler.handlerId} /> : <DefaultFaviconRenderer key={"default"} />;
};
const DefaultFaviconRenderer = () => <link key={"normal"} rel={"shortcut icon"} href={"img/favicon/teacup.png"} type={"image/x-icon"} />;
const ClientIconFaviconRenderer = (props: { icon: ClientIcon }) => {
const url = clientIconToDataUrl(props.icon);
if(!url) {
return <DefaultFaviconRenderer key={"broken"} />;
} else {
return <link key={"status"} rel={"shortcut icon"} href={url} type={"image/x-icon"}/>;
}
};
const HandlerFaviconRenderer = (props: { connection: ConnectionHandler }) => {
const [ showClientStatus, setShowClientStatus ] = useState(props.connection.connection_state === ConnectionState.CONNECTED);
props.connection.events().reactUse("notify_connection_state_changed", event => setShowClientStatus(event.new_state === ConnectionState.CONNECTED));
if(showClientStatus) {
return <ClientStatusIndicator
client={props.connection.getClient()}
renderer={icon => <ClientIconFaviconRenderer icon={icon} key={icon} />}
key={"server"}
/>;
} else {
return <DefaultFaviconRenderer key={"default"} />;
}
}
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
name: "favicon renderer",
function: initializeFaviconRenderer,
priority: 10
});

View File

@ -25,6 +25,7 @@ import {
} from "tc-shared/voice/VoiceWhisper";
import {VoiceClient} from "tc-shared/voice/VoiceClient";
import {WebWhisperSession} from "tc-backend/web/voice/VoiceWhisper";
import {VoicePlayerState} from "tc-shared/voice/VoicePlayer";
export enum VoiceEncodeType {
JS_ENCODE,
@ -68,6 +69,10 @@ export class VoiceConnection extends AbstractVoiceConnection {
private lastConnectAttempt: number = 0;
private currentlyReplayingVoice: boolean = false;
private readonly voiceClientStateChangedEventListener;
private readonly whisperSessionStateChangedEventListener;
constructor(connection: ServerConnection) {
super(connection);
@ -80,6 +85,9 @@ export class VoiceConnection extends AbstractVoiceConnection {
this.connection.events.on("notify_connection_state_changed",
this.serverConnectionStateListener = this.handleServerConnectionStateChanged.bind(this));
this.voiceClientStateChangedEventListener = this.handleVoiceClientStateChange.bind(this);
this.whisperSessionStateChangedEventListener = this.handleWhisperSessionStateChange.bind(this);
}
getConnectionState(): VoiceConnectionStatus {
@ -341,6 +349,7 @@ export class VoiceConnection extends AbstractVoiceConnection {
if(!(client instanceof VoiceClientController))
throw "Invalid client type";
client.events.off("notify_state_changed", this.voiceClientStateChangedEventListener);
delete this.voiceClients[client.getClientId()];
client.destroy();
}
@ -352,6 +361,7 @@ export class VoiceConnection extends AbstractVoiceConnection {
const client = new VoiceClientController(clientId);
this.voiceClients[clientId] = client;
client.events.on("notify_state_changed", this.voiceClientStateChangedEventListener);
return client;
}
@ -371,6 +381,41 @@ export class VoiceConnection extends AbstractVoiceConnection {
this.encoderCodec = codec;
}
stopAllVoiceReplays() {
this.availableVoiceClients().forEach(e => e.abortReplay());
/* TODO: Whisper sessions as well */
}
isReplayingVoice(): boolean {
return this.currentlyReplayingVoice;
}
private handleVoiceClientStateChange(/* event: VoicePlayerEvents["notify_state_changed"] */) {
this.updateVoiceReplaying();
}
private handleWhisperSessionStateChange() {
this.updateVoiceReplaying();
}
private updateVoiceReplaying() {
let replaying = false;
if(this.connectionState === VoiceConnectionStatus.Connected) {
let index = this.availableVoiceClients().findIndex(client => client.getState() === VoicePlayerState.PLAYING || client.getState() === VoicePlayerState.BUFFERING);
replaying = index !== -1;
if(!replaying) {
index = this.getWhisperSessions().findIndex(session => session.getSessionState() === WhisperSessionState.PLAYING);
replaying = index !== -1;
}
}
if(this.currentlyReplayingVoice !== replaying) {
this.currentlyReplayingVoice = replaying;
this.events.fire_async("notify_voice_replay_state_change", { replaying: replaying });
}
}
protected handleWhisperPacket(packet: VoiceWhisperPacket) {
const clientId = packet.clientId;
@ -378,6 +423,7 @@ export class VoiceConnection extends AbstractVoiceConnection {
if(typeof session !== "object") {
logDebug(LogCategory.VOICE, tr("Received new whisper from %d (%s)"), packet.clientId, packet.clientNickname);
session = (this.whisperSessions[clientId] = new WebWhisperSession(packet));
session.events.on("notify_state_changed", this.whisperSessionStateChangedEventListener);
this.whisperSessionInitializer(session).then(result => {
session.initializeFromData(result).then(() => {
if(this.whisperSessions[clientId] !== session) {
@ -413,6 +459,7 @@ export class VoiceConnection extends AbstractVoiceConnection {
throw tr("Session isn't an instance of the web whisper system");
}
session.events.off("notify_state_changed", this.whisperSessionStateChangedEventListener);
delete this.whisperSessions[session.getClientId()];
session.destroy();
}

View File

@ -69,7 +69,7 @@ export class WebVoicePlayer implements VoicePlayer {
}
getState(): VoicePlayerState {
return undefined;
return this.playerState;
}
getVolume(): number {
@ -118,6 +118,7 @@ export class WebVoicePlayer implements VoicePlayer {
destroy() {
this.audioClient?.destroy();
this.audioClient = undefined;
this.events.destroy();
}
private initializeAudio() : Promise<void> {
@ -252,6 +253,7 @@ export class WebVoicePlayer implements VoicePlayer {
} else if(this.playerState === VoicePlayerState.PLAYING) {
logDebug(LogCategory.VOICE, tr("Voice player has a buffer underflow. Changing state to buffering."));
this.setPlayerState(VoicePlayerState.BUFFERING);
this.resetBufferTimeout(true);
}
}

View File

@ -67,7 +67,7 @@ export const config = async (target: "web" | "client"): Promise<Configuration> =
devtool: isDevelopment ? "inline-source-map" : undefined,
mode: isDevelopment ? "development" : "production",
plugins: [
new CleanWebpackPlugin(),
//new CleanWebpackPlugin(),
new MiniCssExtractPlugin({
filename: isDevelopment ? '[name].css' : '[name].[hash].css',
chunkFilename: isDevelopment ? '[id].css' : '[id].[hash].css'