Introduced Video to the web client. A lot of changes are still pending
This commit is contained in:
parent
cde346a628
commit
658b44ed1d
90 changed files with 4766 additions and 439 deletions
|
@ -1,4 +1,9 @@
|
||||||
# Changelog:
|
# Changelog:
|
||||||
|
* **07.11.20**
|
||||||
|
- Added video broadcasting to the web client
|
||||||
|
- Added various new user interfaces related to video broadcasting
|
||||||
|
- Reworked the whole media transmission system (now using native audio en/decoding)
|
||||||
|
|
||||||
* **05.10.20**
|
* **05.10.20**
|
||||||
- Reworked the top menu bar (now properly updates)
|
- Reworked the top menu bar (now properly updates)
|
||||||
- Recoded the top menu bar renderer for the web client
|
- Recoded the top menu bar renderer for the web client
|
||||||
|
|
11
package-lock.json
generated
11
package-lock.json
generated
|
@ -1535,6 +1535,12 @@
|
||||||
"integrity": "sha512-fsFfCxJt0C4DvAxdMR9JcnVY6FfAQrH8ia7NT0MStVbsgR73+a7XYFRhNqRHg2/FC2Sxfbg3ekuiFuY8eMOvMQ==",
|
"integrity": "sha512-fsFfCxJt0C4DvAxdMR9JcnVY6FfAQrH8ia7NT0MStVbsgR73+a7XYFRhNqRHg2/FC2Sxfbg3ekuiFuY8eMOvMQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@types/sdp-transform": {
|
||||||
|
"version": "2.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/sdp-transform/-/sdp-transform-2.4.4.tgz",
|
||||||
|
"integrity": "sha512-cTpXVbNaN1YL++3mYArwlaujujeVe8ty66rbZPdFWx94fxM7jFjs5X621esca40yRe45htNdXROMIr2cgI+xGg==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@types/sha256": {
|
"@types/sha256": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/sha256/-/sha256-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/sha256/-/sha256-0.2.0.tgz",
|
||||||
|
@ -11643,6 +11649,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz",
|
||||||
"integrity": "sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw=="
|
"integrity": "sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw=="
|
||||||
},
|
},
|
||||||
|
"sdp-transform": {
|
||||||
|
"version": "2.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.14.0.tgz",
|
||||||
|
"integrity": "sha512-8ZYOau/o9PzRhY0aMuRzvmiM6/YVQR8yjnBScvZHSdBnywK5oZzAJK+412ZKkDq29naBmR3bRw8MFu0C01Gehg=="
|
||||||
|
},
|
||||||
"semver": {
|
"semver": {
|
||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
|
||||||
|
|
|
@ -37,6 +37,7 @@
|
||||||
"@types/react-color": "^3.0.4",
|
"@types/react-color": "^3.0.4",
|
||||||
"@types/react-dom": "^16.9.5",
|
"@types/react-dom": "^16.9.5",
|
||||||
"@types/remarkable": "^1.7.4",
|
"@types/remarkable": "^1.7.4",
|
||||||
|
"@types/sdp-transform": "^2.4.4",
|
||||||
"@types/sha256": "^0.2.0",
|
"@types/sha256": "^0.2.0",
|
||||||
"@types/twemoji": "^12.1.1",
|
"@types/twemoji": "^12.1.1",
|
||||||
"@types/websocket": "0.0.40",
|
"@types/websocket": "0.0.40",
|
||||||
|
@ -105,6 +106,7 @@
|
||||||
"react-player": "^2.5.0",
|
"react-player": "^2.5.0",
|
||||||
"remarkable": "^2.0.1",
|
"remarkable": "^2.0.1",
|
||||||
"resize-observer-polyfill": "^1.5.1",
|
"resize-observer-polyfill": "^1.5.1",
|
||||||
|
"sdp-transform": "^2.14.0",
|
||||||
"simplebar-react": "^2.2.0",
|
"simplebar-react": "^2.2.0",
|
||||||
"twemoji": "^13.0.0",
|
"twemoji": "^13.0.0",
|
||||||
"webcrypto-liner": "^1.2.3",
|
"webcrypto-liner": "^1.2.3",
|
||||||
|
|
|
@ -15,8 +15,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="container-connection-handlers" id="connection-handler-list"></div>
|
<div class="container-connection-handlers" id="connection-handler-list"></div>
|
||||||
|
|
||||||
<div class="container-app-main">
|
<div class="container-app-main">
|
||||||
|
<div class="container-channel-video" id="channel-video"></div>
|
||||||
<div class="container-channel-chat">
|
<div class="container-channel-chat">
|
||||||
<!-- Channel tree -->
|
<!-- Channel tree -->
|
||||||
<div class="container-channel-tree">
|
<div class="container-channel-tree">
|
||||||
|
|
6
shared/img/client-icons/double_arrow.svg
Normal file
6
shared/img/client-icons/double_arrow.svg
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<svg version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="matrix(.016149 0 0 .016149 -.16723 .74664)">
|
||||||
|
<path d="m535.92 431.52-398.04-398.04a25 25 0 00-35.355 0l-84.845 84.845a25 25 90 000 35.355l277.84 277.84a24.996 24.996 90.005 01-.003 35.353l-277.84 277.75a24.996 24.996 90.005 00-.0028 35.353l84.845 84.845a25.003 25.003.0033054 0035.357.002l398.04-397.95a24.997 24.997 90.003 00.002-35.353z" style="fill:#646568"/>
|
||||||
|
<path transform="translate(113.18)" d="m447.22 33.478-84.845 84.845a25 25 90 000 35.355l277.84 277.84a24.996 24.996 90.005 01-.003 35.353l-277.84 277.75a24.996 24.996 90.005 00-.003 35.353l84.845 84.845a25.003 25.003.0033054 0035.357.002l398.04-397.95a24.997 24.997 90.003 00.002-35.353l-398.04-398.04a25 25 0 00-35.355 0z" style="fill:#646568"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 817 B |
|
@ -10,7 +10,7 @@ import {createErrorModal, createInfoModal, createInputModal, Modal} from "./ui/e
|
||||||
import {hashPassword} from "./utils/helpers";
|
import {hashPassword} from "./utils/helpers";
|
||||||
import {HandshakeHandler} from "./connection/HandshakeHandler";
|
import {HandshakeHandler} from "./connection/HandshakeHandler";
|
||||||
import * as htmltags from "./ui/htmltags";
|
import * as htmltags from "./ui/htmltags";
|
||||||
import {FilterMode, InputStartResult, InputState} from "./voice/RecorderBase";
|
import {FilterMode, InputState, MediaStreamRequestResult} from "./voice/RecorderBase";
|
||||||
import {CommandResult} from "./connection/ServerConnectionDeclaration";
|
import {CommandResult} from "./connection/ServerConnectionDeclaration";
|
||||||
import {defaultRecorder, RecorderProfile} from "./voice/RecorderProfile";
|
import {defaultRecorder, RecorderProfile} from "./voice/RecorderProfile";
|
||||||
import {Frame} from "./ui/frames/chat_frame";
|
import {Frame} from "./ui/frames/chat_frame";
|
||||||
|
@ -37,6 +37,7 @@ import {ServerFeature, ServerFeatures} from "./connection/ServerFeatures";
|
||||||
import {ChannelTree} from "./tree/ChannelTree";
|
import {ChannelTree} from "./tree/ChannelTree";
|
||||||
import {LocalClientEntry} from "./tree/Client";
|
import {LocalClientEntry} from "./tree/Client";
|
||||||
import {ServerAddress} from "./tree/Server";
|
import {ServerAddress} from "./tree/Server";
|
||||||
|
import {ChannelVideoFrame} from "tc-shared/ui/frames/video/Controller";
|
||||||
|
|
||||||
export enum InputHardwareState {
|
export enum InputHardwareState {
|
||||||
MISSING,
|
MISSING,
|
||||||
|
@ -142,6 +143,7 @@ export class ConnectionHandler {
|
||||||
groups: GroupManager;
|
groups: GroupManager;
|
||||||
|
|
||||||
side_bar: Frame;
|
side_bar: Frame;
|
||||||
|
video_frame: ChannelVideoFrame;
|
||||||
|
|
||||||
settings: ServerSettings;
|
settings: ServerSettings;
|
||||||
sound: SoundManager;
|
sound: SoundManager;
|
||||||
|
@ -153,7 +155,7 @@ export class ConnectionHandler {
|
||||||
serverFeatures: ServerFeatures;
|
serverFeatures: ServerFeatures;
|
||||||
|
|
||||||
private _clientId: number = 0;
|
private _clientId: number = 0;
|
||||||
private _local_client: LocalClientEntry;
|
private localClient: LocalClientEntry;
|
||||||
|
|
||||||
private _reconnect_timer: number;
|
private _reconnect_timer: number;
|
||||||
private _reconnect_attempt: boolean = false;
|
private _reconnect_attempt: boolean = false;
|
||||||
|
@ -192,7 +194,12 @@ export class ConnectionHandler {
|
||||||
this.setInputHardwareState(this.getVoiceRecorder() ? InputHardwareState.VALID : InputHardwareState.MISSING);
|
this.setInputHardwareState(this.getVoiceRecorder() ? InputHardwareState.VALID : InputHardwareState.MISSING);
|
||||||
this.update_voice_status();
|
this.update_voice_status();
|
||||||
});
|
});
|
||||||
this.serverConnection.getVoiceConnection().events.on("notify_connection_status_changed", () => this.update_voice_status());
|
this.serverConnection.getVoiceConnection().events.on("notify_connection_status_changed", event => {
|
||||||
|
this.update_voice_status();
|
||||||
|
if(event.newStatus === VoiceConnectionStatus.Failed) {
|
||||||
|
createErrorModal(tr("Voice connection failed"), tra("Failed to establish a voice connection:\n{}", this.serverConnection.getVoiceConnection().getFailedMessage() || tr("Lookup the console for more detail"))).open();
|
||||||
|
}
|
||||||
|
});
|
||||||
this.serverConnection.getVoiceConnection().setWhisperSessionInitializer(this.initializeWhisperSession.bind(this));
|
this.serverConnection.getVoiceConnection().setWhisperSessionInitializer(this.initializeWhisperSession.bind(this));
|
||||||
|
|
||||||
this.serverFeatures = new ServerFeatures(this);
|
this.serverFeatures = new ServerFeatures(this);
|
||||||
|
@ -203,13 +210,15 @@ export class ConnectionHandler {
|
||||||
this.permissions = new PermissionManager(this);
|
this.permissions = new PermissionManager(this);
|
||||||
|
|
||||||
this.pluginCmdRegistry = new PluginCmdRegistry(this);
|
this.pluginCmdRegistry = new PluginCmdRegistry(this);
|
||||||
|
this.video_frame = new ChannelVideoFrame(this);
|
||||||
|
|
||||||
this.log = new ServerEventLog(this);
|
this.log = new ServerEventLog(this);
|
||||||
this.side_bar = new Frame(this);
|
this.side_bar = new Frame(this);
|
||||||
this.sound = new SoundManager(this);
|
this.sound = new SoundManager(this);
|
||||||
this.hostbanner = new Hostbanner(this);
|
this.hostbanner = new Hostbanner(this);
|
||||||
|
|
||||||
this._local_client = new LocalClientEntry(this);
|
this.localClient = new LocalClientEntry(this);
|
||||||
|
this.localClient.channelTree = this.channelTree;
|
||||||
|
|
||||||
this.event_registry.register_handler(this);
|
this.event_registry.register_handler(this);
|
||||||
this.events().fire("notify_handler_initialized");
|
this.events().fire("notify_handler_initialized");
|
||||||
|
@ -333,15 +342,15 @@ export class ConnectionHandler {
|
||||||
this.log.log(EventType.DISCONNECTED, {});
|
this.log.log(EventType.DISCONNECTED, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
getClient() : LocalClientEntry { return this._local_client; }
|
getClient() : LocalClientEntry { return this.localClient; }
|
||||||
getClientId() { return this._clientId; }
|
getClientId() { return this._clientId; }
|
||||||
|
|
||||||
initializeLocalClient(clientId: number, acceptedName: string) {
|
initializeLocalClient(clientId: number, acceptedName: string) {
|
||||||
this._clientId = clientId;
|
this._clientId = clientId;
|
||||||
this._local_client["_clientId"] = clientId;
|
this.localClient["_clientId"] = clientId;
|
||||||
|
|
||||||
this.channelTree.registerClient(this._local_client);
|
this.channelTree.registerClient(this.localClient);
|
||||||
this._local_client.updateVariables( { key: "client_nickname", value: acceptedName });
|
this.localClient.updateVariables( { key: "client_nickname", value: acceptedName });
|
||||||
}
|
}
|
||||||
|
|
||||||
getServerConnection() : AbstractServerConnection { return this.serverConnection; }
|
getServerConnection() : AbstractServerConnection { return this.serverConnection; }
|
||||||
|
@ -631,7 +640,7 @@ export class ConnectionHandler {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.channelTree.unregisterClient(this._local_client); /* if we dont unregister our client here the client will be destroyed */
|
this.channelTree.unregisterClient(this.localClient); /* if we dont unregister our client here the client will be destroyed */
|
||||||
this.channelTree.reset();
|
this.channelTree.reset();
|
||||||
if(this.serverConnection)
|
if(this.serverConnection)
|
||||||
this.serverConnection.disconnect();
|
this.serverConnection.disconnect();
|
||||||
|
@ -679,7 +688,7 @@ export class ConnectionHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateVoiceStatus() {
|
private updateVoiceStatus() {
|
||||||
if(!this._local_client) {
|
if(!this.localClient) {
|
||||||
/* we've been destroyed */
|
/* we've been destroyed */
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -832,7 +841,7 @@ export class ConnectionHandler {
|
||||||
if(input.currentState() === InputState.PAUSED && this.connection_state === ConnectionState.CONNECTED) {
|
if(input.currentState() === InputState.PAUSED && this.connection_state === ConnectionState.CONNECTED) {
|
||||||
try {
|
try {
|
||||||
const result = await input.start();
|
const result = await input.start();
|
||||||
if(result !== InputStartResult.EOK) {
|
if(result !== true) {
|
||||||
throw result;
|
throw result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -844,13 +853,13 @@ export class ConnectionHandler {
|
||||||
this.update_voice_status();
|
this.update_voice_status();
|
||||||
|
|
||||||
let errorMessage;
|
let errorMessage;
|
||||||
if(error === InputStartResult.ENOTSUPPORTED) {
|
if(error === MediaStreamRequestResult.ENOTSUPPORTED) {
|
||||||
errorMessage = tr("Your browser does not support voice recording");
|
errorMessage = tr("Your browser does not support voice recording");
|
||||||
} else if(error === InputStartResult.EBUSY) {
|
} else if(error === MediaStreamRequestResult.EBUSY) {
|
||||||
errorMessage = tr("The input device is busy");
|
errorMessage = tr("The input device is busy");
|
||||||
} else if(error === InputStartResult.EDEVICEUNKNOWN) {
|
} else if(error === MediaStreamRequestResult.EDEVICEUNKNOWN) {
|
||||||
errorMessage = tr("Invalid input device");
|
errorMessage = tr("Invalid input device");
|
||||||
} else if(error === InputStartResult.ENOTALLOWED) {
|
} else if(error === MediaStreamRequestResult.ENOTALLOWED) {
|
||||||
errorMessage = tr("No permissions");
|
errorMessage = tr("No permissions");
|
||||||
} else if(error instanceof Error) {
|
} else if(error instanceof Error) {
|
||||||
errorMessage = error.message;
|
errorMessage = error.message;
|
||||||
|
@ -993,15 +1002,22 @@ export class ConnectionHandler {
|
||||||
this.pluginCmdRegistry?.destroy();
|
this.pluginCmdRegistry?.destroy();
|
||||||
this.pluginCmdRegistry = undefined;
|
this.pluginCmdRegistry = undefined;
|
||||||
|
|
||||||
if(this._local_client) {
|
if(this.localClient) {
|
||||||
const voiceHandle = this._local_client.getVoiceClient();
|
const voiceHandle = this.localClient.getVoiceClient();
|
||||||
if(voiceHandle) {
|
if(voiceHandle) {
|
||||||
this._local_client.setVoiceClient(undefined);
|
logWarn(LogCategory.GENERAL, tr("Local voice client has received a voice handle. This should never happen!"));
|
||||||
|
this.localClient.setVoiceClient(undefined);
|
||||||
this.serverConnection.getVoiceConnection().unregisterVoiceClient(voiceHandle);
|
this.serverConnection.getVoiceConnection().unregisterVoiceClient(voiceHandle);
|
||||||
}
|
}
|
||||||
this._local_client.destroy();
|
const videoHandle = this.localClient.getVideoClient();
|
||||||
|
if(videoHandle) {
|
||||||
|
logWarn(LogCategory.GENERAL, tr("Local voice client has received a video handle. This should never happen!"));
|
||||||
|
this.localClient.setVoiceClient(undefined);
|
||||||
|
this.serverConnection.getVideoConnection().unregisterVideoClient(videoHandle);
|
||||||
}
|
}
|
||||||
this._local_client = undefined;
|
this.localClient.destroy();
|
||||||
|
}
|
||||||
|
this.localClient = undefined;
|
||||||
|
|
||||||
this.channelTree?.destroy();
|
this.channelTree?.destroy();
|
||||||
this.channelTree = undefined;
|
this.channelTree = undefined;
|
||||||
|
@ -1033,7 +1049,7 @@ export class ConnectionHandler {
|
||||||
this.serverConnection = undefined;
|
this.serverConnection = undefined;
|
||||||
|
|
||||||
this.sound = undefined;
|
this.sound = undefined;
|
||||||
this._local_client = undefined;
|
this.localClient = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* state changing methods */
|
/* state changing methods */
|
||||||
|
|
|
@ -5,6 +5,22 @@ import {Stage} from "tc-loader";
|
||||||
|
|
||||||
export let server_connections: ConnectionManager;
|
export let server_connections: ConnectionManager;
|
||||||
|
|
||||||
|
class ReplaceableContainer {
|
||||||
|
placeholder: HTMLDivElement;
|
||||||
|
container: HTMLDivElement;
|
||||||
|
|
||||||
|
constructor(container: HTMLDivElement, placeholder?: HTMLDivElement) {
|
||||||
|
this.container = container;
|
||||||
|
this.placeholder = placeholder || document.createElement("div");
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceWith(target: HTMLDivElement | undefined) {
|
||||||
|
target = target || this.placeholder;
|
||||||
|
this.container.replaceWith(target);
|
||||||
|
this.container = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ConnectionManager {
|
export class ConnectionManager {
|
||||||
private readonly event_registry: Registry<ConnectionManagerEvents>;
|
private readonly event_registry: Registry<ConnectionManagerEvents>;
|
||||||
private connection_handlers: ConnectionHandler[] = [];
|
private connection_handlers: ConnectionHandler[] = [];
|
||||||
|
@ -14,11 +30,13 @@ export class ConnectionManager {
|
||||||
private _container_channel_tree: JQuery;
|
private _container_channel_tree: JQuery;
|
||||||
private _container_hostbanner: JQuery;
|
private _container_hostbanner: JQuery;
|
||||||
private _container_chat: JQuery;
|
private _container_chat: JQuery;
|
||||||
|
private containerChannelVideo: ReplaceableContainer;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.event_registry = new Registry<ConnectionManagerEvents>();
|
this.event_registry = new Registry<ConnectionManagerEvents>();
|
||||||
this.event_registry.enableDebug("connection-manager");
|
this.event_registry.enableDebug("connection-manager");
|
||||||
|
|
||||||
|
this.containerChannelVideo = new ReplaceableContainer(document.getElementById("channel-video") as HTMLDivElement);
|
||||||
this._container_log_server = $("#server-log");
|
this._container_log_server = $("#server-log");
|
||||||
this._container_channel_tree = $("#channelTree");
|
this._container_channel_tree = $("#channelTree");
|
||||||
this._container_hostbanner = $("#hostbanner");
|
this._container_hostbanner = $("#hostbanner");
|
||||||
|
@ -88,6 +106,7 @@ export class ConnectionManager {
|
||||||
this._container_chat.children().detach();
|
this._container_chat.children().detach();
|
||||||
this._container_log_server.children().detach();
|
this._container_log_server.children().detach();
|
||||||
this._container_hostbanner.children().detach();
|
this._container_hostbanner.children().detach();
|
||||||
|
this.containerChannelVideo.replaceWith(handler?.video_frame.getContainer());
|
||||||
|
|
||||||
if(handler) {
|
if(handler) {
|
||||||
this._container_hostbanner.append(handler.hostbanner.html_tag);
|
this._container_hostbanner.append(handler.hostbanner.html_tag);
|
||||||
|
|
|
@ -357,7 +357,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
|
||||||
|
|
||||||
handleCommandChannelListFinished() {
|
handleCommandChannelListFinished() {
|
||||||
this.connection.client.channelTree.channelsInitialized = true;
|
this.connection.client.channelTree.channelsInitialized = true;
|
||||||
this.connection.client.channelTree.events.fire_async("notify_channel_list_received");
|
this.connection.client.channelTree.events.fire_react("notify_channel_list_received");
|
||||||
|
|
||||||
if(this.batch_update_finished_timeout) {
|
if(this.batch_update_finished_timeout) {
|
||||||
clearTimeout(this.batch_update_finished_timeout);
|
clearTimeout(this.batch_update_finished_timeout);
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {ConnectionHandler, ConnectionState} from "../ConnectionHandler";
|
||||||
import {AbstractCommandHandlerBoss} from "../connection/AbstractCommandHandler";
|
import {AbstractCommandHandlerBoss} from "../connection/AbstractCommandHandler";
|
||||||
import {Registry} from "../events";
|
import {Registry} from "../events";
|
||||||
import {AbstractVoiceConnection} from "../connection/VoiceConnection";
|
import {AbstractVoiceConnection} from "../connection/VoiceConnection";
|
||||||
|
import {VideoConnection} from "tc-shared/connection/VideoConnection";
|
||||||
|
|
||||||
export interface CommandOptions {
|
export interface CommandOptions {
|
||||||
flagset?: string[]; /* default: [] */
|
flagset?: string[]; /* default: [] */
|
||||||
|
@ -48,6 +49,7 @@ export abstract class AbstractServerConnection {
|
||||||
abstract disconnect(reason?: string) : Promise<void>;
|
abstract disconnect(reason?: string) : Promise<void>;
|
||||||
|
|
||||||
abstract getVoiceConnection() : AbstractVoiceConnection;
|
abstract getVoiceConnection() : AbstractVoiceConnection;
|
||||||
|
abstract getVideoConnection() : VideoConnection;
|
||||||
|
|
||||||
abstract command_handler_boss() : AbstractCommandHandlerBoss;
|
abstract command_handler_boss() : AbstractCommandHandlerBoss;
|
||||||
abstract send_command(command: string, data?: any | any[], options?: CommandOptions) : Promise<CommandResult>;
|
abstract send_command(command: string, data?: any | any[], options?: CommandOptions) : Promise<CommandResult>;
|
||||||
|
|
61
shared/js/connection/VideoConnection.ts
Normal file
61
shared/js/connection/VideoConnection.ts
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import {VideoSource} from "tc-shared/video/VideoSource";
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
|
||||||
|
export type VideoBroadcastType = "camera" | "screen";
|
||||||
|
|
||||||
|
export interface VideoConnectionEvent {
|
||||||
|
notify_status_changed: { oldState: VideoConnectionStatus, newState: VideoConnectionStatus },
|
||||||
|
notify_local_broadcast_state_changed: { broadcastType: VideoBroadcastType, oldState: VideoBroadcastState, newState: VideoBroadcastState },
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum VideoConnectionStatus {
|
||||||
|
/** We're currently not connected to the target server */
|
||||||
|
Disconnected,
|
||||||
|
/** We're trying to connect to the target server */
|
||||||
|
Connecting,
|
||||||
|
/** We're connected */
|
||||||
|
Connected,
|
||||||
|
/** The connection has failed for the current server connection */
|
||||||
|
Failed,
|
||||||
|
/** Video connection is not supported (the server dosn't support it) */
|
||||||
|
Unsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum VideoBroadcastState {
|
||||||
|
Initializing,
|
||||||
|
Running,
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoClientEvents {
|
||||||
|
notify_broadcast_state_changed: { broadcastType: VideoBroadcastType, oldState: VideoBroadcastState, newState: VideoBroadcastState }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoClient {
|
||||||
|
getClientId() : number;
|
||||||
|
getEvents() : Registry<VideoClientEvents>;
|
||||||
|
|
||||||
|
getVideoState(broadcastType: VideoBroadcastType) : VideoBroadcastState;
|
||||||
|
getVideoStream(broadcastType: VideoBroadcastType) : MediaStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoConnection {
|
||||||
|
getEvents() : Registry<VideoConnectionEvent>;
|
||||||
|
|
||||||
|
getStatus() : VideoConnectionStatus;
|
||||||
|
|
||||||
|
isBroadcasting(type: VideoBroadcastType);
|
||||||
|
getBroadcastingSource(type: VideoBroadcastType) : VideoSource | undefined;
|
||||||
|
getBroadcastingState(type: VideoBroadcastType) : VideoBroadcastState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param type
|
||||||
|
* @param source The source of the broadcast (No ownership will be taken. The voice connection must ref the source by itself!)
|
||||||
|
*/
|
||||||
|
startBroadcasting(type: VideoBroadcastType, source: VideoSource) : Promise<void>;
|
||||||
|
stopBroadcasting(type: VideoBroadcastType);
|
||||||
|
|
||||||
|
registerVideoClient(clientId: number);
|
||||||
|
registeredVideoClients() : VideoClient[];
|
||||||
|
unregisterVideoClient(client: VideoClient);
|
||||||
|
}
|
|
@ -4,7 +4,6 @@ import {guid} from "./crypto/uid";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {useEffect} from "react";
|
import {useEffect} from "react";
|
||||||
import {unstable_batchedUpdates} from "react-dom";
|
import {unstable_batchedUpdates} from "react-dom";
|
||||||
import {ext} from "twemoji";
|
|
||||||
|
|
||||||
export interface Event<Events, T = keyof Events> {
|
export interface Event<Events, T = keyof Events> {
|
||||||
readonly type: T;
|
readonly type: T;
|
||||||
|
@ -25,7 +24,23 @@ export class SingletonEvent implements Event<SingletonEvents, "singletone-instan
|
||||||
|
|
||||||
export interface EventReceiver<Events extends { [key: string]: any } = { [key: string]: any }> {
|
export interface EventReceiver<Events extends { [key: string]: any } = { [key: string]: any }> {
|
||||||
fire<T extends keyof Events>(event_type: T, data?: Events[T], overrideTypeKey?: boolean);
|
fire<T extends keyof Events>(event_type: T, data?: Events[T], overrideTypeKey?: boolean);
|
||||||
fire_async<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void);
|
|
||||||
|
/**
|
||||||
|
* Fire an event later by using setTimeout(..)
|
||||||
|
* @param event_type The target event to be fired
|
||||||
|
* @param data The payload of the event
|
||||||
|
* @param callback The callback will be called after the event has been successfully dispatched
|
||||||
|
*/
|
||||||
|
fire_later<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire an event, which will be delayed until the next animation frame.
|
||||||
|
* This ensures that all react components have been successfully mounted/unmounted.
|
||||||
|
* @param event_type The target event to be fired
|
||||||
|
* @param data The payload of the event
|
||||||
|
* @param callback The callback will be called after the event has been successfully dispatched
|
||||||
|
*/
|
||||||
|
fire_react<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void);
|
||||||
}
|
}
|
||||||
|
|
||||||
const event_annotation_key = guid();
|
const event_annotation_key = guid();
|
||||||
|
@ -41,8 +56,11 @@ export class Registry<Events extends { [key: string]: any } = { [key: string]: a
|
||||||
private debugPrefix = undefined;
|
private debugPrefix = undefined;
|
||||||
private warnUnhandledEvents = false;
|
private warnUnhandledEvents = false;
|
||||||
|
|
||||||
private pendingCallbacks: { type: any, data: any }[] = [];
|
private pendingAsyncCallbacks: { type: any, data: any, callback: () => void }[];
|
||||||
private pendingCallbacksTimeout: number = 0;
|
private pendingAsyncCallbacksTimeout: number = 0;
|
||||||
|
|
||||||
|
private pendingReactCallbacks: { type: any, data: any, callback: () => void }[];
|
||||||
|
private pendingReactCallbacksFrame: number = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.registryUuid = "evreg_data_" + guid();
|
this.registryUuid = "evreg_data_" + guid();
|
||||||
|
@ -131,9 +149,12 @@ export class Registry<Events extends { [key: string]: any } = { [key: string]: a
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const handlers = this.handler[event as any] || (this.handler[event as any] = []);
|
const handlers = this.handler[event as any] || (this.handler[event as any] = []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handlers.push(handler);
|
handlers.push(handler);
|
||||||
return () => handlers.remove(handler);
|
return () => {
|
||||||
|
handlers.remove(handler);
|
||||||
|
};
|
||||||
}, reactEffectDependencies);
|
}, reactEffectDependencies);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -204,25 +225,65 @@ export class Registry<Events extends { [key: string]: any } = { [key: string]: a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fire_async<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void) {
|
fire_later<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void) {
|
||||||
if(!this.pendingCallbacksTimeout) {
|
if(!this.pendingAsyncCallbacksTimeout) {
|
||||||
this.pendingCallbacksTimeout = setTimeout(() => this.invokeAsyncCallbacks());
|
this.pendingAsyncCallbacksTimeout = setTimeout(() => this.invokeAsyncCallbacks());
|
||||||
this.pendingCallbacks = [];
|
this.pendingAsyncCallbacks = [];
|
||||||
}
|
}
|
||||||
this.pendingCallbacks.push({ type: event_type, data: data });
|
this.pendingAsyncCallbacks.push({ type: event_type, data: data, callback: callback });
|
||||||
|
}
|
||||||
|
|
||||||
|
fire_react<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void) {
|
||||||
|
if(!this.pendingReactCallbacks) {
|
||||||
|
this.pendingReactCallbacksFrame = requestAnimationFrame(() => this.invokeReactCallbacks());
|
||||||
|
this.pendingReactCallbacks = [];
|
||||||
|
}
|
||||||
|
this.pendingReactCallbacks.push({ type: event_type, data: data, callback: callback });
|
||||||
}
|
}
|
||||||
|
|
||||||
private invokeAsyncCallbacks() {
|
private invokeAsyncCallbacks() {
|
||||||
const callbacks = this.pendingCallbacks;
|
const callbacks = this.pendingAsyncCallbacks;
|
||||||
this.pendingCallbacksTimeout = 0;
|
this.pendingAsyncCallbacksTimeout = 0;
|
||||||
this.pendingCallbacks = undefined;
|
this.pendingAsyncCallbacks = undefined;
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
while(index < callbacks.length) {
|
||||||
|
this.fire(callbacks[index].type, callbacks[index].data);
|
||||||
|
try {
|
||||||
|
if(callbacks[index].callback) {
|
||||||
|
callbacks[index].callback();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
/* TODO: Improve error logging? */
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private invokeReactCallbacks() {
|
||||||
|
const callbacks = this.pendingReactCallbacks;
|
||||||
|
this.pendingReactCallbacksFrame = 0;
|
||||||
|
this.pendingReactCallbacks = undefined;
|
||||||
|
|
||||||
|
/* run this after the requestAnimationFrame has been finished */
|
||||||
|
setTimeout(() => {
|
||||||
|
/* batch all react updates */
|
||||||
unstable_batchedUpdates(() => {
|
unstable_batchedUpdates(() => {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
while(index < callbacks.length) {
|
while(index < callbacks.length) {
|
||||||
this.fire(callbacks[index].type, callbacks[index].data);
|
this.fire(callbacks[index].type, callbacks[index].data);
|
||||||
|
try {
|
||||||
|
if(callbacks[index].callback) {
|
||||||
|
callbacks[index].callback();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
/* TODO: Improve error logging? */
|
||||||
|
}
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,8 @@ import {spawnGlobalSettingsEditor} from "tc-shared/ui/modal/global-settings-edit
|
||||||
import {spawnModalCssVariableEditor} from "tc-shared/ui/modal/css-editor/Controller";
|
import {spawnModalCssVariableEditor} from "tc-shared/ui/modal/css-editor/Controller";
|
||||||
import {server_connections} from "tc-shared/ConnectionManager";
|
import {server_connections} from "tc-shared/ConnectionManager";
|
||||||
import {spawnAbout} from "tc-shared/ui/modal/ModalAbout";
|
import {spawnAbout} from "tc-shared/ui/modal/ModalAbout";
|
||||||
|
import {spawnVideoSourceSelectModal} from "tc-shared/ui/modal/video-source/Controller";
|
||||||
|
import {LogCategory, logError} from "tc-shared/log";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
function initialize_sounds(event_registry: Registry<ClientGlobalControlEvents>) {
|
function initialize_sounds(event_registry: Registry<ClientGlobalControlEvents>) {
|
||||||
|
@ -173,4 +175,31 @@ export function initialize(event_registry: Registry<ClientGlobalControlEvents>)
|
||||||
event_registry.on("action_open_window_permissions", event => {
|
event_registry.on("action_open_window_permissions", event => {
|
||||||
spawnPermissionEditorModal(event.connection ? event.connection : server_connections.active_connection(), event.defaultTab);
|
spawnPermissionEditorModal(event.connection ? event.connection : server_connections.active_connection(), event.defaultTab);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
event_registry.on("action_toggle_video_broadcasting", event => {
|
||||||
|
if(event.enabled) {
|
||||||
|
if(event.broadcastType === "camera") {
|
||||||
|
spawnVideoSourceSelectModal().then(source => {
|
||||||
|
if(!source) { return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
event.connection.getServerConnection().getVideoConnection().startBroadcasting("camera", source)
|
||||||
|
.catch(error => {
|
||||||
|
logError(LogCategory.VIDEO, tr("Failed to start video broadcasting: %o"), error);
|
||||||
|
if(typeof error !== "string") {
|
||||||
|
error = tr("lookup the console for detail");
|
||||||
|
}
|
||||||
|
createErrorModal(tr("Failed to start video broadcasting"), tra("Failed to start video broadcasting:\n{}", error)).open();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
/* TODO! */
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
event.connection.getServerConnection().getVideoConnection().stopBroadcasting(event.broadcastType);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import {ConnectionHandler} from "../ConnectionHandler";
|
import {ConnectionHandler} from "../ConnectionHandler";
|
||||||
import {Registry} from "../events";
|
import {Registry} from "../events";
|
||||||
|
import {VideoBroadcastType} from "tc-shared/connection/VideoConnection";
|
||||||
|
|
||||||
export type PermissionEditorTab = "groups-server" | "groups-channel" | "channel" | "client" | "client-channel";
|
export type PermissionEditorTab = "groups-server" | "groups-channel" | "channel" | "client" | "client-channel";
|
||||||
export interface ClientGlobalControlEvents {
|
export interface ClientGlobalControlEvents {
|
||||||
|
@ -26,7 +27,8 @@ export interface ClientGlobalControlEvents {
|
||||||
} | {
|
} | {
|
||||||
videoUrl: string,
|
videoUrl: string,
|
||||||
handlerId: string
|
handlerId: string
|
||||||
}
|
},
|
||||||
|
action_toggle_video_broadcasting: { connection: ConnectionHandler, enabled: boolean, broadcastType: VideoBroadcastType }
|
||||||
|
|
||||||
/* some more specific window openings */
|
/* some more specific window openings */
|
||||||
action_open_window_connect: {
|
action_open_window_connect: {
|
||||||
|
|
|
@ -684,7 +684,7 @@ export class FileManager {
|
||||||
|
|
||||||
const cancelListener = () => {
|
const cancelListener = () => {
|
||||||
unregisterTransfer();
|
unregisterTransfer();
|
||||||
transfer.events.fire_async("notify_transfer_canceled", {}, resolve);
|
transfer.events.fire_later("notify_transfer_canceled", {}, resolve);
|
||||||
};
|
};
|
||||||
|
|
||||||
transfer.events.on("notify_state_updated", stateListener);
|
transfer.events.on("notify_state_updated", stateListener);
|
||||||
|
|
|
@ -368,7 +368,7 @@ export class FileTransfer {
|
||||||
|
|
||||||
updateProgress(progress: TransferProgress) {
|
updateProgress(progress: TransferProgress) {
|
||||||
this.progress_ = progress;
|
this.progress_ = progress;
|
||||||
this.events.fire_async("notify_progress", { progress: progress });
|
this.events.fire_later("notify_progress", { progress: progress });
|
||||||
}
|
}
|
||||||
|
|
||||||
awaitFinished() : Promise<void> {
|
awaitFinished() : Promise<void> {
|
||||||
|
|
|
@ -20,7 +20,8 @@ export enum LogCategory {
|
||||||
DNS,
|
DNS,
|
||||||
FILE_TRANSFER,
|
FILE_TRANSFER,
|
||||||
EVENT_REGISTRY,
|
EVENT_REGISTRY,
|
||||||
WEBRTC
|
WEBRTC,
|
||||||
|
VIDEO
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum LogType {
|
export enum LogType {
|
||||||
|
@ -51,6 +52,7 @@ let category_mapping = new Map<number, string>([
|
||||||
[LogCategory.FILE_TRANSFER, "File transfer "],
|
[LogCategory.FILE_TRANSFER, "File transfer "],
|
||||||
[LogCategory.EVENT_REGISTRY, "Event registry"],
|
[LogCategory.EVENT_REGISTRY, "Event registry"],
|
||||||
[LogCategory.WEBRTC, "WebRTC "],
|
[LogCategory.WEBRTC, "WebRTC "],
|
||||||
|
[LogCategory.VIDEO, "Video "],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export let enabled_mapping = new Map<number, boolean>([
|
export let enabled_mapping = new Map<number, boolean>([
|
||||||
|
@ -73,6 +75,7 @@ export let enabled_mapping = new Map<number, boolean>([
|
||||||
[LogCategory.FILE_TRANSFER, true],
|
[LogCategory.FILE_TRANSFER, true],
|
||||||
[LogCategory.EVENT_REGISTRY, true],
|
[LogCategory.EVENT_REGISTRY, true],
|
||||||
[LogCategory.WEBRTC, true],
|
[LogCategory.WEBRTC, true],
|
||||||
|
[LogCategory.VIDEO, true],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
//Values will be overridden by initialize()
|
//Values will be overridden by initialize()
|
||||||
|
|
|
@ -52,6 +52,8 @@ import {Registry} from "tc-shared/events";
|
||||||
import {ControlBarEvents} from "tc-shared/ui/frames/control-bar/Definitions";
|
import {ControlBarEvents} from "tc-shared/ui/frames/control-bar/Definitions";
|
||||||
import {ControlBar2} from "tc-shared/ui/frames/control-bar/Renderer";
|
import {ControlBar2} from "tc-shared/ui/frames/control-bar/Renderer";
|
||||||
import {initializeControlBarController} from "tc-shared/ui/frames/control-bar/Controller";
|
import {initializeControlBarController} from "tc-shared/ui/frames/control-bar/Controller";
|
||||||
|
import {spawnVideoSourceSelectModal} from "tc-shared/ui/modal/video-source/Controller";
|
||||||
|
import {getVideoDriver} from "tc-shared/video/VideoSource";
|
||||||
|
|
||||||
let preventWelcomeUI = false;
|
let preventWelcomeUI = false;
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
|
@ -245,7 +247,17 @@ function main() {
|
||||||
|
|
||||||
/* schedule it a bit later then the main because the main function is still within the loader */
|
/* schedule it a bit later then the main because the main function is still within the loader */
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
(window as any).spawnVideo = async () => {
|
||||||
|
const source = await spawnVideoSourceSelectModal();
|
||||||
|
if(!source) { return; }
|
||||||
|
|
||||||
|
await server_connections.active_connection().getServerConnection().getVideoConnection().startBroadcasting("camera", source);
|
||||||
|
};
|
||||||
|
|
||||||
|
(window as any).videoDriver = getVideoDriver();
|
||||||
const connection = server_connections.active_connection();
|
const connection = server_connections.active_connection();
|
||||||
|
|
||||||
|
//(window as any).spawnVideo();
|
||||||
/*
|
/*
|
||||||
Modals.createChannelModal(connection, undefined, undefined, connection.permissions, (cb, perms) => {
|
Modals.createChannelModal(connection, undefined, undefined, connection.permissions, (cb, perms) => {
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
/* setup jsrenderer */
|
/* setup jsrenderer */
|
||||||
import "jsrender";
|
import "jsrender";
|
||||||
|
|
||||||
if(__build.target === "web") {
|
if(__build.target === "web") {
|
||||||
(window as any).$ = require("jquery");
|
(window as any).$ = require("jquery");
|
||||||
(window as any).jQuery = $;
|
(window as any).jQuery = $;
|
||||||
|
@ -64,6 +65,30 @@ declare global {
|
||||||
mozGetUserMedia(constraints: MediaStreamConstraints, successCallback: NavigatorUserMediaSuccessCallback, errorCallback: NavigatorUserMediaErrorCallback): void;
|
mozGetUserMedia(constraints: MediaStreamConstraints, successCallback: NavigatorUserMediaSuccessCallback, errorCallback: NavigatorUserMediaErrorCallback): void;
|
||||||
webkitGetUserMedia(constraints: MediaStreamConstraints, successCallback: NavigatorUserMediaSuccessCallback, errorCallback: NavigatorUserMediaErrorCallback): void;
|
webkitGetUserMedia(constraints: MediaStreamConstraints, successCallback: NavigatorUserMediaSuccessCallback, errorCallback: NavigatorUserMediaErrorCallback): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ObjectConstructor {
|
||||||
|
isSimilar(a: any, b: any): boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!Object.isSimilar) {
|
||||||
|
Object.isSimilar = function (a, b) {
|
||||||
|
const aType = typeof a;
|
||||||
|
const bType = typeof b;
|
||||||
|
if (aType !== bType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aType === "object") {
|
||||||
|
const aKeys = Object.keys(a);
|
||||||
|
const bKeys = Object.keys(b);
|
||||||
|
if(aKeys.length != bKeys.length) { return false; }
|
||||||
|
if(aKeys.findIndex(key => bKeys.indexOf(key) !== -1) !== -1) { return false; }
|
||||||
|
return aKeys.findIndex(key => !Object.isSimilar(a[key], b[key])) === -1;
|
||||||
|
} else {
|
||||||
|
return a === b;
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!JSON.map_to) {
|
if(!JSON.map_to) {
|
||||||
|
|
|
@ -505,6 +505,13 @@ export class Settings extends StaticSettings {
|
||||||
valueType: "boolean",
|
valueType: "boolean",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static readonly KEY_STOP_VIDEO_ON_SWITCH: ValuedSettingsKey<boolean> = {
|
||||||
|
key: 'stop_video_on_channel_switch',
|
||||||
|
defaultValue: true,
|
||||||
|
description: 'Stop video broadcasting on channel switch',
|
||||||
|
valueType: "boolean",
|
||||||
|
};
|
||||||
|
|
||||||
static readonly FN_LOG_ENABLED: (category: string) => SettingsKey<boolean> = category => {
|
static readonly FN_LOG_ENABLED: (category: string) => SettingsKey<boolean> = category => {
|
||||||
return {
|
return {
|
||||||
key: "log." + category.toLowerCase() + ".enabled",
|
key: "log." + category.toLowerCase() + ".enabled",
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import * as contextmenu from "tc-shared/ui/elements/ContextMenu";
|
import * as contextmenu from "tc-shared/ui/elements/ContextMenu";
|
||||||
import {MenuEntryType} from "tc-shared/ui/elements/ContextMenu";
|
import {MenuEntryType} from "tc-shared/ui/elements/ContextMenu";
|
||||||
import * as log from "tc-shared/log";
|
import * as log from "tc-shared/log";
|
||||||
import {LogCategory, logWarn} from "tc-shared/log";
|
import {LogCategory, logError, logWarn} from "tc-shared/log";
|
||||||
import {Settings, settings} from "tc-shared/settings";
|
|
||||||
import {PermissionType} from "tc-shared/permission/PermissionType";
|
import {PermissionType} from "tc-shared/permission/PermissionType";
|
||||||
import {SpecialKey} from "tc-shared/PPTListener";
|
import {SpecialKey} from "tc-shared/PPTListener";
|
||||||
import {Sound} from "tc-shared/sound/Sounds";
|
import {Sound} from "tc-shared/sound/Sounds";
|
||||||
|
@ -384,8 +383,8 @@ export class ChannelTree {
|
||||||
this.reset();
|
this.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
tag_tree() : JQuery {
|
tag_tree() : HTMLDivElement {
|
||||||
return this.tagContainer;
|
return this.tagContainer[0] as HTMLDivElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
channelsOrdered() : ChannelEntry[] {
|
channelsOrdered() : ChannelEntry[] {
|
||||||
|
@ -598,34 +597,51 @@ export class ChannelTree {
|
||||||
logWarn(LogCategory.CHANNEL, tr("Deleting client %s from channel tree which hasn't a channel."), client.clientId());
|
logWarn(LogCategory.CHANNEL, tr("Deleting client %s from channel tree which hasn't a channel."), client.clientId());
|
||||||
}
|
}
|
||||||
|
|
||||||
const voice_connection = this.client.serverConnection.getVoiceConnection();
|
const voiceConnection = this.client.serverConnection.getVoiceConnection();
|
||||||
if(client.getVoiceClient()) {
|
if(client.getVoiceClient()) {
|
||||||
const voiceClient = client.getVoiceClient();
|
voiceConnection.unregisterVoiceClient(client.getVoiceClient());
|
||||||
client.setVoiceClient(undefined);
|
client.setVoiceClient(undefined);
|
||||||
|
|
||||||
if(!voice_connection) {
|
|
||||||
log.warn(LogCategory.VOICE, tr("Deleting client with a voice handle, but we haven't a voice connection!"));
|
|
||||||
} else {
|
|
||||||
voice_connection.unregisterVoiceClient(voiceClient);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const videoConnection = this.client.serverConnection.getVideoConnection();
|
||||||
|
if(client.getVideoClient()) {
|
||||||
|
videoConnection.unregisterVideoClient(client.getVideoClient());
|
||||||
|
client.setVideoClient(undefined);
|
||||||
}
|
}
|
||||||
client.destroy();
|
client.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
registerClient(client: ClientEntry) {
|
registerClient(client: ClientEntry) {
|
||||||
this.clients.push(client);
|
this.clients.push(client);
|
||||||
|
|
||||||
|
if(client instanceof LocalClientEntry) {
|
||||||
|
if(client.channelTree !== this) {
|
||||||
|
throw tr("client channel tree missmatch");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
client.channelTree = this;
|
client.channelTree = this;
|
||||||
|
|
||||||
const voiceConnection = this.client.serverConnection.getVoiceConnection();
|
const voiceConnection = this.client.serverConnection.getVoiceConnection();
|
||||||
if(voiceConnection) {
|
try {
|
||||||
client.setVoiceClient(voiceConnection.registerVoiceClient(client.clientId()));
|
client.setVoiceClient(voiceConnection.registerVoiceClient(client.clientId()));
|
||||||
|
} catch (error) {
|
||||||
|
logError(LogCategory.AUDIO, tr("Failed to register a voice client for %d: %o"), client.clientId(), error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoConnection = this.client.serverConnection.getVideoConnection();
|
||||||
|
try {
|
||||||
|
client.setVideoClient(videoConnection.registerVideoClient(client.clientId()));
|
||||||
|
} catch (error) {
|
||||||
|
logError(LogCategory.VIDEO, tr("Failed to register a video client for %d: %o"), client.clientId(), error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
unregisterClient(client: ClientEntry) {
|
unregisterClient(client: ClientEntry) {
|
||||||
if(!this.clients.remove(client))
|
if(!this.clients.remove(client)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
insertClient(client: ClientEntry, channel: ChannelEntry, reason: { reason: ViewReasonId, isServerJoin: boolean }) : ClientEntry {
|
insertClient(client: ClientEntry, channel: ChannelEntry, reason: { reason: ViewReasonId, isServerJoin: boolean }) : ClientEntry {
|
||||||
batch_updates(BatchUpdateType.CHANNEL_TREE);
|
batch_updates(BatchUpdateType.CHANNEL_TREE);
|
||||||
|
@ -964,12 +980,18 @@ export class ChannelTree {
|
||||||
try {
|
try {
|
||||||
this.selection.reset();
|
this.selection.reset();
|
||||||
|
|
||||||
const voice_connection = this.client.serverConnection ? this.client.serverConnection.getVoiceConnection() : undefined;
|
const voiceConnection = this.client.serverConnection ? this.client.serverConnection.getVoiceConnection() : undefined;
|
||||||
|
const videoConnection = this.client.serverConnection ? this.client.serverConnection.getVideoConnection() : undefined;
|
||||||
for(const client of this.clients) {
|
for(const client of this.clients) {
|
||||||
if(client.getVoiceClient() && voice_connection) {
|
if(client.getVoiceClient() && videoConnection) {
|
||||||
voice_connection.unregisterVoiceClient(client.getVoiceClient());
|
voiceConnection.unregisterVoiceClient(client.getVoiceClient());
|
||||||
client.setVoiceClient(undefined);
|
client.setVoiceClient(undefined);
|
||||||
}
|
}
|
||||||
|
if(client.getVideoClient()) {
|
||||||
|
videoConnection.unregisterVideoClient(client.getVideoClient());
|
||||||
|
client.setVideoClient(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
client.destroy();
|
client.destroy();
|
||||||
}
|
}
|
||||||
this.clients = [];
|
this.clients = [];
|
||||||
|
|
|
@ -29,6 +29,7 @@ import {ClientIcon} from "svg-sprites/client-icons";
|
||||||
import {VoiceClient} from "../voice/VoiceClient";
|
import {VoiceClient} from "../voice/VoiceClient";
|
||||||
import {VoicePlayerEvents, VoicePlayerState} from "../voice/VoicePlayer";
|
import {VoicePlayerEvents, VoicePlayerState} from "../voice/VoicePlayer";
|
||||||
import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions";
|
import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions";
|
||||||
|
import {VideoClient} from "tc-shared/connection/VideoConnection";
|
||||||
|
|
||||||
export enum ClientType {
|
export enum ClientType {
|
||||||
CLIENT_VOICE,
|
CLIENT_VOICE,
|
||||||
|
@ -151,6 +152,8 @@ export interface ClientEvents extends ChannelTreeEntryEvents {
|
||||||
notify_audio_level_changed: { newValue: number },
|
notify_audio_level_changed: { newValue: number },
|
||||||
notify_status_icon_changed: { newIcon: ClientIcon },
|
notify_status_icon_changed: { newIcon: ClientIcon },
|
||||||
|
|
||||||
|
notify_video_handle_changed: { oldHandle: VideoClient | undefined, newHandle: VideoClient | undefined },
|
||||||
|
|
||||||
music_status_update: {
|
music_status_update: {
|
||||||
player_buffered_index: number,
|
player_buffered_index: number,
|
||||||
player_replay_index: number
|
player_replay_index: number
|
||||||
|
@ -193,6 +196,8 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
|
||||||
protected voiceMuted: boolean;
|
protected voiceMuted: boolean;
|
||||||
private readonly voiceCallbackStateChanged;
|
private readonly voiceCallbackStateChanged;
|
||||||
|
|
||||||
|
protected videoHandle: VideoClient;
|
||||||
|
|
||||||
private promiseClientInfo: Promise<void>;
|
private promiseClientInfo: Promise<void>;
|
||||||
private promiseClientInfoTimestamp: number;
|
private promiseClientInfoTimestamp: number;
|
||||||
|
|
||||||
|
@ -213,11 +218,11 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
|
||||||
|
|
||||||
this.voiceCallbackStateChanged = this.handleVoiceStateChange.bind(this);
|
this.voiceCallbackStateChanged = this.handleVoiceStateChange.bind(this);
|
||||||
|
|
||||||
this.events.on(["notify_speak_state_change", "notify_mute_state_change"], () => this.events.fire_async("notify_status_icon_changed", { newIcon: this.getStatusIcon() }));
|
this.events.on(["notify_speak_state_change", "notify_mute_state_change"], () => this.events.fire_later("notify_status_icon_changed", { newIcon: this.getStatusIcon() }));
|
||||||
this.events.on("notify_properties_updated", event => {
|
this.events.on("notify_properties_updated", event => {
|
||||||
for (const key of StatusIconUpdateKeys) {
|
for (const key of StatusIconUpdateKeys) {
|
||||||
if (key in event.updated_properties) {
|
if (key in event.updated_properties) {
|
||||||
this.events.fire_async("notify_status_icon_changed", { newIcon: this.getStatusIcon() })
|
this.events.fire_later("notify_status_icon_changed", { newIcon: this.getStatusIcon() })
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -229,6 +234,10 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
|
||||||
log.error(LogCategory.AUDIO, tr("Destroying client with an active audio handle. This could cause memory leaks!"));
|
log.error(LogCategory.AUDIO, tr("Destroying client with an active audio handle. This could cause memory leaks!"));
|
||||||
this.setVoiceClient(undefined);
|
this.setVoiceClient(undefined);
|
||||||
}
|
}
|
||||||
|
if(this.videoHandle) {
|
||||||
|
log.error(LogCategory.AUDIO, tr("Destroying client with an active video handle. This could cause memory leaks!"));
|
||||||
|
this.setVideoClient(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
this._channel = undefined;
|
this._channel = undefined;
|
||||||
this.events.destroy();
|
this.events.destroy();
|
||||||
|
@ -249,6 +258,16 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setVideoClient(handle: VideoClient) {
|
||||||
|
if(this.videoHandle === handle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldHandle = this.videoHandle;
|
||||||
|
this.videoHandle = handle;
|
||||||
|
this.events.fire("notify_video_handle_changed", { oldHandle: oldHandle, newHandle: handle });
|
||||||
|
}
|
||||||
|
|
||||||
private handleVoiceStateChange(event: VoicePlayerEvents["notify_state_changed"]) {
|
private handleVoiceStateChange(event: VoicePlayerEvents["notify_state_changed"]) {
|
||||||
switch (event.newState) {
|
switch (event.newState) {
|
||||||
case VoicePlayerState.PLAYING:
|
case VoicePlayerState.PLAYING:
|
||||||
|
@ -274,6 +293,10 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
|
||||||
return this.voiceHandle;
|
return this.voiceHandle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getVideoClient() : VideoClient {
|
||||||
|
return this.videoHandle;
|
||||||
|
}
|
||||||
|
|
||||||
get properties() : ClientProperties {
|
get properties() : ClientProperties {
|
||||||
return this._properties;
|
return this._properties;
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,38 +25,38 @@ function initializeController(events: Registry<ConnectionListUIEvents>) {
|
||||||
});
|
});
|
||||||
|
|
||||||
events.on("query_handler_list", () => {
|
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.fire_react("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 => {
|
events.on("notify_destroy", server_connections.events().on("notify_handler_created", event => {
|
||||||
let listeners = [];
|
let listeners = [];
|
||||||
|
|
||||||
const handlerId = event.handlerId;
|
const handlerId = event.handlerId;
|
||||||
listeners.push(event.handler.events().on("notify_connection_state_changed", () => events.fire_async("query_handler_status", { handlerId: handlerId })));
|
listeners.push(event.handler.events().on("notify_connection_state_changed", () => events.fire_react("query_handler_status", { handlerId: handlerId })));
|
||||||
|
|
||||||
/* register to icon and name change updates */
|
/* register to icon and name change updates */
|
||||||
listeners.push(event.handler.channelTree.server.events.on("notify_properties_updated", event => {
|
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) {
|
if("virtualserver_name" in event.updated_properties || "virtualserver_icon_id" in event.updated_properties) {
|
||||||
events.fire_async("query_handler_status", { handlerId: handlerId });
|
events.fire_react("query_handler_status", { handlerId: handlerId });
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/* register to voice playback change events */
|
/* register to voice playback change events */
|
||||||
listeners.push(event.handler.getServerConnection().getVoiceConnection().events.on("notify_voice_replay_state_change", () => {
|
listeners.push(event.handler.getServerConnection().getVoiceConnection().events.on("notify_voice_replay_state_change", () => {
|
||||||
events.fire_async("query_handler_status", { handlerId: handlerId });
|
events.fire_react("query_handler_status", { handlerId: handlerId });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
registeredHandlerEvents[event.handlerId] = listeners;
|
registeredHandlerEvents[event.handlerId] = listeners;
|
||||||
|
|
||||||
events.fire_async("query_handler_list");
|
events.fire_react("query_handler_list");
|
||||||
}));
|
}));
|
||||||
events.on("notify_destroy", server_connections.events().on("notify_handler_deleted", event => {
|
events.on("notify_destroy", server_connections.events().on("notify_handler_deleted", event => {
|
||||||
(registeredHandlerEvents[event.handlerId] || []).forEach(callback => callback());
|
(registeredHandlerEvents[event.handlerId] || []).forEach(callback => callback());
|
||||||
delete registeredHandlerEvents[event.handlerId];
|
delete registeredHandlerEvents[event.handlerId];
|
||||||
|
|
||||||
events.fire_async("query_handler_list");
|
events.fire_react("query_handler_list");
|
||||||
}));
|
}));
|
||||||
|
|
||||||
events.on("notify_destroy", server_connections.events().on("notify_handler_order_changed", () => events.fire_async("query_handler_list")));
|
events.on("notify_destroy", server_connections.events().on("notify_handler_order_changed", () => events.fire_react("query_handler_list")));
|
||||||
events.on("action_swap_handler", event => {
|
events.on("action_swap_handler", event => {
|
||||||
const handlerA = server_connections.findConnection(event.handlerIdOne);
|
const handlerA = server_connections.findConnection(event.handlerIdOne);
|
||||||
const handlerB = server_connections.findConnection(event.handlerIdTwo);
|
const handlerB = server_connections.findConnection(event.handlerIdTwo);
|
||||||
|
@ -80,7 +80,7 @@ function initializeController(events: Registry<ConnectionListUIEvents>) {
|
||||||
server_connections.set_active_connection(handler);
|
server_connections.set_active_connection(handler);
|
||||||
});
|
});
|
||||||
events.on("notify_destroy", server_connections.events().on("notify_active_handler_changed", event => {
|
events.on("notify_destroy", server_connections.events().on("notify_active_handler_changed", event => {
|
||||||
events.fire_async("notify_active_handler", { handlerId: event.newHandlerId });
|
events.fire_react("notify_active_handler", { handlerId: event.newHandlerId });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
events.on("action_destroy_handler", event => {
|
events.on("action_destroy_handler", event => {
|
||||||
|
@ -118,7 +118,7 @@ function initializeController(events: Registry<ConnectionListUIEvents>) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
events.fire_async("notify_handler_status", {
|
events.fire_react("notify_handler_status", {
|
||||||
handlerId: event.handlerId,
|
handlerId: event.handlerId,
|
||||||
status: {
|
status: {
|
||||||
handlerName: handler.channelTree.server.properties.virtualserver_name,
|
handlerName: handler.channelTree.server.properties.virtualserver_name,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
|
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
|
||||||
import {DropdownContainer} from "./DropDown";
|
import {DropdownContainer} from "./DropDown";
|
||||||
|
import {ClientIcon} from "svg-sprites/client-icons";
|
||||||
const cssStyle = require("./Button.scss");
|
const cssStyle = require("./Button.scss");
|
||||||
|
|
||||||
export interface ButtonState {
|
export interface ButtonState {
|
||||||
|
@ -16,8 +17,8 @@ export interface ButtonProperties {
|
||||||
|
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
|
|
||||||
iconNormal: string;
|
iconNormal: string | ClientIcon;
|
||||||
iconSwitched?: string;
|
iconSwitched?: string | ClientIcon;
|
||||||
|
|
||||||
onToggle?: (state: boolean) => boolean | void;
|
onToggle?: (state: boolean) => boolean | void;
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,8 @@ import {
|
||||||
Bookmark,
|
Bookmark,
|
||||||
ControlBarEvents,
|
ControlBarEvents,
|
||||||
ControlBarMode,
|
ControlBarMode,
|
||||||
HostButtonInfo
|
HostButtonInfo,
|
||||||
|
VideoCamaraState
|
||||||
} from "tc-shared/ui/frames/control-bar/Definitions";
|
} from "tc-shared/ui/frames/control-bar/Definitions";
|
||||||
import {server_connections} from "tc-shared/ConnectionManager";
|
import {server_connections} from "tc-shared/ConnectionManager";
|
||||||
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
|
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
|
||||||
|
@ -11,7 +12,8 @@ import {Settings, settings} from "tc-shared/settings";
|
||||||
import {global_client_actions} from "tc-shared/events/GlobalEvents";
|
import {global_client_actions} from "tc-shared/events/GlobalEvents";
|
||||||
import {
|
import {
|
||||||
add_server_to_bookmarks,
|
add_server_to_bookmarks,
|
||||||
Bookmark as ServerBookmark, bookmarkEvents,
|
Bookmark as ServerBookmark,
|
||||||
|
bookmarkEvents,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
bookmarks_flat,
|
bookmarks_flat,
|
||||||
BookmarkType,
|
BookmarkType,
|
||||||
|
@ -19,7 +21,8 @@ import {
|
||||||
DirectoryBookmark
|
DirectoryBookmark
|
||||||
} from "tc-shared/bookmarks";
|
} from "tc-shared/bookmarks";
|
||||||
import {LogCategory, logWarn} from "tc-shared/log";
|
import {LogCategory, logWarn} from "tc-shared/log";
|
||||||
import {createInputModal} from "tc-shared/ui/elements/Modal";
|
import {createErrorModal, createInputModal} from "tc-shared/ui/elements/Modal";
|
||||||
|
import {VideoBroadcastState, VideoConnectionStatus} from "tc-shared/connection/VideoConnection";
|
||||||
|
|
||||||
class InfoController {
|
class InfoController {
|
||||||
private readonly mode: ControlBarMode;
|
private readonly mode: ControlBarMode;
|
||||||
|
@ -46,11 +49,13 @@ class InfoController {
|
||||||
this.registerGlobalHandlerEvents(event.handler);
|
this.registerGlobalHandlerEvents(event.handler);
|
||||||
this.sendConnectionState();
|
this.sendConnectionState();
|
||||||
this.sendAwayState();
|
this.sendAwayState();
|
||||||
|
this.sendCamaraState();
|
||||||
}));
|
}));
|
||||||
events.push(server_connections.events().on("notify_handler_deleted", event => {
|
events.push(server_connections.events().on("notify_handler_deleted", event => {
|
||||||
this.unregisterGlobalHandlerEvents(event.handler);
|
this.unregisterGlobalHandlerEvents(event.handler);
|
||||||
this.sendConnectionState();
|
this.sendConnectionState();
|
||||||
this.sendAwayState();
|
this.sendAwayState();
|
||||||
|
this.sendCamaraState();
|
||||||
}));
|
}));
|
||||||
events.push(bookmarkEvents.on("notify_bookmarks_updated", () => this.sendBookmarks()));
|
events.push(bookmarkEvents.on("notify_bookmarks_updated", () => this.sendBookmarks()));
|
||||||
|
|
||||||
|
@ -92,6 +97,7 @@ class InfoController {
|
||||||
events.push(handler.events().on("notify_connection_state_changed", event => {
|
events.push(handler.events().on("notify_connection_state_changed", event => {
|
||||||
if(event.old_state === ConnectionState.CONNECTED || event.new_state === ConnectionState.CONNECTED) {
|
if(event.old_state === ConnectionState.CONNECTED || event.new_state === ConnectionState.CONNECTED) {
|
||||||
this.sendHostButton();
|
this.sendHostButton();
|
||||||
|
this.sendCamaraState();
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -114,6 +120,11 @@ class InfoController {
|
||||||
this.sendSubscribeState();
|
this.sendSubscribeState();
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const videoConnection = handler.getServerConnection().getVideoConnection();
|
||||||
|
events.push(videoConnection.getEvents().on(["notify_local_broadcast_state_changed", "notify_status_changed"], () => {
|
||||||
|
this.sendCamaraState();
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private unregisterCurrentHandlerEvents() {
|
private unregisterCurrentHandlerEvents() {
|
||||||
|
@ -138,6 +149,7 @@ class InfoController {
|
||||||
this.sendSubscribeState();
|
this.sendSubscribeState();
|
||||||
this.sendQueryState();
|
this.sendQueryState();
|
||||||
this.sendHostButton();
|
this.sendHostButton();
|
||||||
|
this.sendCamaraState();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendConnectionState() {
|
public sendConnectionState() {
|
||||||
|
@ -145,7 +157,7 @@ class InfoController {
|
||||||
const locallyConnected = this.currentHandler?.connected;
|
const locallyConnected = this.currentHandler?.connected;
|
||||||
const multisession = !settings.static_global(Settings.KEY_DISABLE_MULTI_SESSION);
|
const multisession = !settings.static_global(Settings.KEY_DISABLE_MULTI_SESSION);
|
||||||
|
|
||||||
this.events.fire_async("notify_connection_state", {
|
this.events.fire_react("notify_connection_state", {
|
||||||
state: {
|
state: {
|
||||||
currentlyConnected: locallyConnected,
|
currentlyConnected: locallyConnected,
|
||||||
generallyConnected: globallyConnected,
|
generallyConnected: globallyConnected,
|
||||||
|
@ -171,7 +183,7 @@ class InfoController {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.events.fire_async("notify_bookmarks", {
|
this.events.fire_react("notify_bookmarks", {
|
||||||
marks: bookmarks().content.map(buildInfo)
|
marks: bookmarks().content.map(buildInfo)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -180,7 +192,7 @@ class InfoController {
|
||||||
const globalAwayCount = server_connections.all_connections().filter(handler => handler.isAway()).length;
|
const globalAwayCount = server_connections.all_connections().filter(handler => handler.isAway()).length;
|
||||||
const awayLocally = !!this.currentHandler?.isAway();
|
const awayLocally = !!this.currentHandler?.isAway();
|
||||||
|
|
||||||
this.events.fire_async("notify_away_state", {
|
this.events.fire_react("notify_away_state", {
|
||||||
state: {
|
state: {
|
||||||
globallyAway: globalAwayCount === server_connections.all_connections().length ? "full" : globalAwayCount > 0 ? "partial" : "none",
|
globallyAway: globalAwayCount === server_connections.all_connections().length ? "full" : globalAwayCount > 0 ? "partial" : "none",
|
||||||
locallyAway: awayLocally
|
locallyAway: awayLocally
|
||||||
|
@ -189,25 +201,25 @@ class InfoController {
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendMicrophoneState() {
|
public sendMicrophoneState() {
|
||||||
this.events.fire_async("notify_microphone_state", {
|
this.events.fire_react("notify_microphone_state", {
|
||||||
state: this.currentHandler?.isMicrophoneDisabled() ? "disabled" : this.currentHandler?.isMicrophoneMuted() ? "muted" : "enabled"
|
state: this.currentHandler?.isMicrophoneDisabled() ? "disabled" : this.currentHandler?.isMicrophoneMuted() ? "muted" : "enabled"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendSpeakerState() {
|
public sendSpeakerState() {
|
||||||
this.events.fire_async("notify_speaker_state", {
|
this.events.fire_react("notify_speaker_state", {
|
||||||
enabled: !this.currentHandler?.isSpeakerMuted()
|
enabled: !this.currentHandler?.isSpeakerMuted()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendSubscribeState() {
|
public sendSubscribeState() {
|
||||||
this.events.fire_async("notify_subscribe_state", {
|
this.events.fire_react("notify_subscribe_state", {
|
||||||
subscribe: !!this.currentHandler?.isSubscribeToAllChannels()
|
subscribe: !!this.currentHandler?.isSubscribeToAllChannels()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendQueryState() {
|
public sendQueryState() {
|
||||||
this.events.fire_async("notify_query_state", {
|
this.events.fire_react("notify_query_state", {
|
||||||
shown: !!this.currentHandler?.areQueriesShown()
|
shown: !!this.currentHandler?.areQueriesShown()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -224,10 +236,32 @@ class InfoController {
|
||||||
} : undefined;
|
} : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.events.fire_async("notify_host_button", {
|
this.events.fire_react("notify_host_button", {
|
||||||
button: info
|
button: info
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sendCamaraState() {
|
||||||
|
let status: VideoCamaraState;
|
||||||
|
if(this.currentHandler?.connected) {
|
||||||
|
const videoConnection = this.currentHandler.getServerConnection().getVideoConnection();
|
||||||
|
if(videoConnection.getStatus() === VideoConnectionStatus.Connected) {
|
||||||
|
if(videoConnection.getBroadcastingState("camera") === VideoBroadcastState.Running) {
|
||||||
|
status = "enabled";
|
||||||
|
} else {
|
||||||
|
status = "disabled";
|
||||||
|
}
|
||||||
|
} else if(videoConnection.getStatus() === VideoConnectionStatus.Unsupported) {
|
||||||
|
status = "unsupported";
|
||||||
|
} else {
|
||||||
|
status = "unavailable";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status = "disconnected";
|
||||||
|
}
|
||||||
|
|
||||||
|
this.events.fire_react("notify_camara_state", { state: status });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initializePopoutControlBarController(events: Registry<ControlBarEvents>, handler: ConnectionHandler) {
|
export function initializePopoutControlBarController(events: Registry<ControlBarEvents>, handler: ConnectionHandler) {
|
||||||
|
@ -245,7 +279,7 @@ export function initializeControlBarController(events: Registry<ControlBarEvents
|
||||||
|
|
||||||
events.on("notify_destroy", () => infoHandler.destroy());
|
events.on("notify_destroy", () => infoHandler.destroy());
|
||||||
|
|
||||||
events.on("query_mode", () => events.fire_async("notify_mode", { mode: infoHandler.getMode() }));
|
events.on("query_mode", () => events.fire_react("notify_mode", { mode: infoHandler.getMode() }));
|
||||||
events.on("query_connection_state", () => infoHandler.sendConnectionState());
|
events.on("query_connection_state", () => infoHandler.sendConnectionState());
|
||||||
events.on("query_bookmarks", () => infoHandler.sendBookmarks());
|
events.on("query_bookmarks", () => infoHandler.sendBookmarks());
|
||||||
events.on("query_away_state", () => infoHandler.sendAwayState());
|
events.on("query_away_state", () => infoHandler.sendAwayState());
|
||||||
|
@ -253,6 +287,7 @@ export function initializeControlBarController(events: Registry<ControlBarEvents
|
||||||
events.on("query_speaker_state", () => infoHandler.sendSpeakerState());
|
events.on("query_speaker_state", () => infoHandler.sendSpeakerState());
|
||||||
events.on("query_subscribe_state", () => infoHandler.sendSubscribeState());
|
events.on("query_subscribe_state", () => infoHandler.sendSubscribeState());
|
||||||
events.on("query_host_button", () => infoHandler.sendHostButton());
|
events.on("query_host_button", () => infoHandler.sendHostButton());
|
||||||
|
events.on("query_camara_state", () => infoHandler.sendCamaraState());
|
||||||
|
|
||||||
events.on("action_connection_connect", event => global_client_actions.fire("action_open_window_connect", { newTab: event.newTab }));
|
events.on("action_connection_connect", event => global_client_actions.fire("action_open_window_connect", { newTab: event.newTab }));
|
||||||
events.on("action_connection_disconnect", event => {
|
events.on("action_connection_disconnect", event => {
|
||||||
|
@ -335,6 +370,13 @@ export function initializeControlBarController(events: Registry<ControlBarEvents
|
||||||
events.on("action_query_manage", () => {
|
events.on("action_query_manage", () => {
|
||||||
global_client_actions.fire("action_open_window", { window: "query-manage" });
|
global_client_actions.fire("action_open_window", { window: "query-manage" });
|
||||||
});
|
});
|
||||||
|
events.on("action_toggle_video", event => {
|
||||||
|
if(infoHandler.getCurrentHandler()) {
|
||||||
|
global_client_actions.fire("action_toggle_video_broadcasting", { connection: infoHandler.getCurrentHandler(), broadcastType: event.broadcastType, enabled: event.enable });
|
||||||
|
} else {
|
||||||
|
createErrorModal(tr("Missing connection handler"), tr("Cannot start video broadcasting with a missing connection handler")).open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return infoHandler;
|
return infoHandler;
|
||||||
}
|
}
|
|
@ -1,10 +1,12 @@
|
||||||
import {RemoteIconInfo} from "tc-shared/file/Icons";
|
import {RemoteIconInfo} from "tc-shared/file/Icons";
|
||||||
|
import {VideoBroadcastType} from "tc-shared/connection/VideoConnection";
|
||||||
|
|
||||||
export type ControlBarMode = "main" | "channel-popout";
|
export type ControlBarMode = "main" | "channel-popout";
|
||||||
export type ConnectionState = { currentlyConnected: boolean, generallyConnected: boolean, multisession: boolean };
|
export type ConnectionState = { currentlyConnected: boolean, generallyConnected: boolean, multisession: boolean };
|
||||||
export type Bookmark = { uniqueId: string, label: string, icon: RemoteIconInfo | undefined, children?: Bookmark[] };
|
export type Bookmark = { uniqueId: string, label: string, icon: RemoteIconInfo | undefined, children?: Bookmark[] };
|
||||||
export type AwayState = { locallyAway: boolean, globallyAway: "partial" | "full" | "none" };
|
export type AwayState = { locallyAway: boolean, globallyAway: "partial" | "full" | "none" };
|
||||||
export type MicrophoneState = "enabled" | "disabled" | "muted";
|
export type MicrophoneState = "enabled" | "disabled" | "muted";
|
||||||
|
export type VideoCamaraState = "enabled" | "disabled" | "unavailable" | "unsupported" | "disconnected";
|
||||||
export type HostButtonInfo = { title?: string, target?: string, url: string };
|
export type HostButtonInfo = { title?: string, target?: string, url: string };
|
||||||
|
|
||||||
export interface ControlBarEvents {
|
export interface ControlBarEvents {
|
||||||
|
@ -19,6 +21,7 @@ export interface ControlBarEvents {
|
||||||
action_toggle_subscribe: { subscribe: boolean },
|
action_toggle_subscribe: { subscribe: boolean },
|
||||||
action_toggle_query: { show: boolean },
|
action_toggle_query: { show: boolean },
|
||||||
action_query_manage: {},
|
action_query_manage: {},
|
||||||
|
action_toggle_video: { broadcastType: VideoBroadcastType, enable: boolean }
|
||||||
|
|
||||||
query_mode: {},
|
query_mode: {},
|
||||||
query_connection_state: {},
|
query_connection_state: {},
|
||||||
|
@ -29,6 +32,7 @@ export interface ControlBarEvents {
|
||||||
query_subscribe_state: {},
|
query_subscribe_state: {},
|
||||||
query_query_state: {},
|
query_query_state: {},
|
||||||
query_host_button: {},
|
query_host_button: {},
|
||||||
|
query_camara_state: {},
|
||||||
|
|
||||||
notify_mode: { mode: ControlBarMode }
|
notify_mode: { mode: ControlBarMode }
|
||||||
notify_connection_state: { state: ConnectionState },
|
notify_connection_state: { state: ConnectionState },
|
||||||
|
@ -39,6 +43,7 @@ export interface ControlBarEvents {
|
||||||
notify_subscribe_state: { subscribe: boolean },
|
notify_subscribe_state: { subscribe: boolean },
|
||||||
notify_query_state: { shown: boolean },
|
notify_query_state: { shown: boolean },
|
||||||
notify_host_button: { button: HostButtonInfo | undefined },
|
notify_host_button: { button: HostButtonInfo | undefined },
|
||||||
|
notify_camara_state: { state: VideoCamaraState },
|
||||||
|
|
||||||
notify_destroy: {}
|
notify_destroy: {}
|
||||||
}
|
}
|
|
@ -2,9 +2,12 @@ import {Registry} from "tc-shared/events";
|
||||||
import {
|
import {
|
||||||
AwayState,
|
AwayState,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
ControlBarEvents,
|
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
ControlBarMode, HostButtonInfo, MicrophoneState
|
ControlBarEvents,
|
||||||
|
ControlBarMode,
|
||||||
|
HostButtonInfo,
|
||||||
|
MicrophoneState,
|
||||||
|
VideoCamaraState
|
||||||
} from "tc-shared/ui/frames/control-bar/Definitions";
|
} from "tc-shared/ui/frames/control-bar/Definitions";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {useContext, useRef, useState} from "react";
|
import {useContext, useRef, useState} from "react";
|
||||||
|
@ -13,6 +16,7 @@ import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||||
import {Button} from "tc-shared/ui/frames/control-bar/Button";
|
import {Button} from "tc-shared/ui/frames/control-bar/Button";
|
||||||
import {spawnContextMenu} from "tc-shared/ui/ContextMenu";
|
import {spawnContextMenu} from "tc-shared/ui/ContextMenu";
|
||||||
import {ClientIcon} from "svg-sprites/client-icons";
|
import {ClientIcon} from "svg-sprites/client-icons";
|
||||||
|
import {createErrorModal} from "tc-shared/ui/elements/Modal";
|
||||||
|
|
||||||
const cssStyle = require("./Renderer.scss");
|
const cssStyle = require("./Renderer.scss");
|
||||||
const cssButtonStyle = require("./Button.scss");
|
const cssButtonStyle = require("./Button.scss");
|
||||||
|
@ -200,6 +204,39 @@ const AwayButton = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const VideoButton = () => {
|
||||||
|
const events = useContext(Events);
|
||||||
|
|
||||||
|
const [ state, setState ] = useState<VideoCamaraState>(() => {
|
||||||
|
events.fire("query_camara_state");
|
||||||
|
return "unsupported";
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on("notify_camara_state", event => setState(event.state));
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case "unsupported":
|
||||||
|
return <Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.VideoMuted} tooltip={tr("Video broadcasting not supported")} key={"unsupported"}
|
||||||
|
onToggle={() => createErrorModal(tr("Video broadcasting unsupported"), tr("Video broadcasting isn't supported by the target server.")).open()}
|
||||||
|
/>;
|
||||||
|
|
||||||
|
case "unavailable":
|
||||||
|
return <Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.VideoMuted} tooltip={tr("Video broadcasting not available")} key={"unavailable"}
|
||||||
|
onToggle={() => createErrorModal(tr("Video broadcasting unavailable"), tr("Video broadcasting isn't available right now.")).open()} />;
|
||||||
|
|
||||||
|
case "disconnected":
|
||||||
|
case "disabled":
|
||||||
|
return <Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.VideoMuted}
|
||||||
|
onToggle={() => events.fire("action_toggle_video", { enable: true, broadcastType: "camera" })}
|
||||||
|
tooltip={tr("Start video broadcasting")} key={"enable"}/>;
|
||||||
|
|
||||||
|
case "enabled":
|
||||||
|
return <Button switched={false} colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.VideoMuted}
|
||||||
|
onToggle={() => events.fire("action_toggle_video", { enable: false, broadcastType: "camera" })}
|
||||||
|
tooltip={tr("Stop video broadcasting")} key={"disable"}/>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const MicrophoneButton = () => {
|
const MicrophoneButton = () => {
|
||||||
const events = useContext(Events);
|
const events = useContext(Events);
|
||||||
|
|
||||||
|
@ -211,13 +248,13 @@ const MicrophoneButton = () => {
|
||||||
events.on("notify_microphone_state", event => setState(event.state));
|
events.on("notify_microphone_state", event => setState(event.state));
|
||||||
|
|
||||||
if(state === "muted") {
|
if(state === "muted") {
|
||||||
return <Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={"client-input_muted"} tooltip={tr("Unmute microphone")}
|
return <Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.InputMuted} tooltip={tr("Unmute microphone")}
|
||||||
onToggle={() => events.fire("action_toggle_microphone", { enabled: true })} key={"muted"} />;
|
onToggle={() => events.fire("action_toggle_microphone", { enabled: true })} key={"muted"} />;
|
||||||
} else if(state === "enabled") {
|
} else if(state === "enabled") {
|
||||||
return <Button colorTheme={"red"} autoSwitch={false} iconNormal={"client-input_muted"} tooltip={tr("Mute microphone")}
|
return <Button colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.InputMuted} tooltip={tr("Mute microphone")}
|
||||||
onToggle={() => events.fire("action_toggle_microphone", { enabled: false })} key={"enabled"} />;
|
onToggle={() => events.fire("action_toggle_microphone", { enabled: false })} key={"enabled"} />;
|
||||||
} else {
|
} else {
|
||||||
return <Button autoSwitch={false} iconNormal={"client-activate_microphone"} tooltip={tr("Enable your microphone on this server")}
|
return <Button autoSwitch={false} iconNormal={ClientIcon.ActivateMicrophone} tooltip={tr("Enable your microphone on this server")}
|
||||||
onToggle={() => events.fire("action_toggle_microphone", { enabled: true })} key={"disabled"} />;
|
onToggle={() => events.fire("action_toggle_microphone", { enabled: true })} key={"disabled"} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -351,6 +388,7 @@ export const ControlBar2 = (props: { events: Registry<ControlBarEvents>, classNa
|
||||||
items.push(<div className={cssStyle.divider + " " + cssStyle.hideSmallPopout} key={"divider-1"} />);
|
items.push(<div className={cssStyle.divider + " " + cssStyle.hideSmallPopout} key={"divider-1"} />);
|
||||||
}
|
}
|
||||||
items.push(<AwayButton key={"away"} />);
|
items.push(<AwayButton key={"away"} />);
|
||||||
|
items.push(<VideoButton key={"video"} />);
|
||||||
items.push(<MicrophoneButton key={"microphone"} />);
|
items.push(<MicrophoneButton key={"microphone"} />);
|
||||||
items.push(<SpeakerButton key={"speaker"} />);
|
items.push(<SpeakerButton key={"speaker"} />);
|
||||||
items.push(<div className={cssStyle.divider + " " + cssStyle.hideSmallPopout} key={"divider-2"} />);
|
items.push(<div className={cssStyle.divider + " " + cssStyle.hideSmallPopout} key={"divider-2"} />);
|
||||||
|
|
|
@ -37,7 +37,7 @@ const LogEntryRenderer = React.memo((props: { entry: LogMessage, handlerId: stri
|
||||||
|
|
||||||
export const ServerLogRenderer = (props: { events: Registry<ServerLogUIEvents>, handlerId: string }) => {
|
export const ServerLogRenderer = (props: { events: Registry<ServerLogUIEvents>, handlerId: string }) => {
|
||||||
const [ logs, setLogs ] = useState<LogMessage[] | "loading">(() => {
|
const [ logs, setLogs ] = useState<LogMessage[] | "loading">(() => {
|
||||||
props.events.fire_async("query_log");
|
props.events.fire_react("query_log");
|
||||||
return "loading";
|
return "loading";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -58,13 +58,6 @@ export const ServerLogRenderer = (props: { events: Registry<ServerLogUIEvents>,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(__build.mode === "debug") {
|
|
||||||
const index = logs.findIndex(e => e.uniqueId === event.event.uniqueId);
|
|
||||||
if(index !== -1) {
|
|
||||||
debugger;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logs.push(event.event);
|
logs.push(event.event);
|
||||||
logs.splice(0, Math.max(0, logs.length - 100));
|
logs.splice(0, Math.max(0, logs.length - 100));
|
||||||
logs.sort((a, b) => a.timestamp - b.timestamp);
|
logs.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
|
@ -29,7 +29,7 @@ export class ServerEventLog {
|
||||||
this.htmlTag.classList.add(cssStyle.htmlTag);
|
this.htmlTag.classList.add(cssStyle.htmlTag);
|
||||||
|
|
||||||
this.uiEvents.on("query_log", () => {
|
this.uiEvents.on("query_log", () => {
|
||||||
this.uiEvents.fire_async("notify_log", { log: this.eventLog });
|
this.uiEvents.fire_react("notify_log", { log: this.eventLog.slice() });
|
||||||
});
|
});
|
||||||
|
|
||||||
ReactDOM.render(<ServerLogRenderer events={this.uiEvents} handlerId={this.connection.handlerId} />, this.htmlTag);
|
ReactDOM.render(<ServerLogRenderer events={this.uiEvents} handlerId={this.connection.handlerId} />, this.htmlTag);
|
||||||
|
@ -54,7 +54,7 @@ export class ServerEventLog {
|
||||||
while(this.eventLog.length > this.maxHistoryLength)
|
while(this.eventLog.length > this.maxHistoryLength)
|
||||||
this.eventLog.pop_front();
|
this.eventLog.pop_front();
|
||||||
|
|
||||||
this.uiEvents.fire_async("notify_log_add", { event: event });
|
this.uiEvents.fire_react("notify_log_add", { event: event });
|
||||||
}
|
}
|
||||||
|
|
||||||
if(isNotificationEnabled(type as any)) {
|
if(isNotificationEnabled(type as any)) {
|
||||||
|
|
|
@ -69,7 +69,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* let all other events run before */
|
/* let all other events run before */
|
||||||
this.events.fire_async("notify_chat_event", {
|
this.events.fire_react("notify_chat_event", {
|
||||||
chatId: this.chatId,
|
chatId: this.chatId,
|
||||||
triggerUnread: triggerUnread,
|
triggerUnread: triggerUnread,
|
||||||
event: event
|
event: event
|
||||||
|
@ -112,7 +112,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
|
||||||
switch (this.mode) {
|
switch (this.mode) {
|
||||||
case "normal":
|
case "normal":
|
||||||
if(this.conversationPrivate && !this.canClientAccessChat()) {
|
if(this.conversationPrivate && !this.canClientAccessChat()) {
|
||||||
this.events.fire_async("notify_conversation_state", {
|
this.events.fire_react("notify_conversation_state", {
|
||||||
chatId: this.chatId,
|
chatId: this.chatId,
|
||||||
state: "private",
|
state: "private",
|
||||||
crossChannelChatSupported: this.crossChannelChatSupported
|
crossChannelChatSupported: this.crossChannelChatSupported
|
||||||
|
@ -120,7 +120,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.events.fire_async("notify_conversation_state", {
|
this.events.fire_react("notify_conversation_state", {
|
||||||
chatId: this.chatId,
|
chatId: this.chatId,
|
||||||
state: "normal",
|
state: "normal",
|
||||||
|
|
||||||
|
@ -140,14 +140,14 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
|
||||||
|
|
||||||
case "loading":
|
case "loading":
|
||||||
case "unloaded":
|
case "unloaded":
|
||||||
this.events.fire_async("notify_conversation_state", {
|
this.events.fire_react("notify_conversation_state", {
|
||||||
chatId: this.chatId,
|
chatId: this.chatId,
|
||||||
state: "loading"
|
state: "loading"
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
this.events.fire_async("notify_conversation_state", {
|
this.events.fire_react("notify_conversation_state", {
|
||||||
chatId: this.chatId,
|
chatId: this.chatId,
|
||||||
state: "error",
|
state: "error",
|
||||||
errorMessage: this.errorMessage
|
errorMessage: this.errorMessage
|
||||||
|
@ -155,7 +155,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "no-permissions":
|
case "no-permissions":
|
||||||
this.events.fire_async("notify_conversation_state", {
|
this.events.fire_react("notify_conversation_state", {
|
||||||
chatId: this.chatId,
|
chatId: this.chatId,
|
||||||
state: "no-permissions",
|
state: "no-permissions",
|
||||||
failedPermission: this.failedPermission
|
failedPermission: this.failedPermission
|
||||||
|
@ -225,7 +225,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.unreadTimestamp = timestamp;
|
this.unreadTimestamp = timestamp;
|
||||||
this.events.fire_async("notify_unread_timestamp_changed", { chatId: this.chatId, timestamp: timestamp });
|
this.events.fire_react("notify_unread_timestamp_changed", { chatId: this.chatId, timestamp: timestamp });
|
||||||
}
|
}
|
||||||
|
|
||||||
public jumpToPresent() {
|
public jumpToPresent() {
|
||||||
|
@ -244,7 +244,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
|
||||||
|
|
||||||
switch (result.status) {
|
switch (result.status) {
|
||||||
case "success":
|
case "success":
|
||||||
this.events.fire_async("notify_conversation_history", {
|
this.events.fire_react("notify_conversation_history", {
|
||||||
chatId: this.chatId,
|
chatId: this.chatId,
|
||||||
state: "success",
|
state: "success",
|
||||||
|
|
||||||
|
@ -256,7 +256,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "private":
|
case "private":
|
||||||
this.events.fire_async("notify_conversation_history", {
|
this.events.fire_react("notify_conversation_history", {
|
||||||
chatId: this.chatId,
|
chatId: this.chatId,
|
||||||
state: "error",
|
state: "error",
|
||||||
errorMessage: this.historyErrorMessage = tr("chat is private"),
|
errorMessage: this.historyErrorMessage = tr("chat is private"),
|
||||||
|
@ -265,7 +265,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "no-permission":
|
case "no-permission":
|
||||||
this.events.fire_async("notify_conversation_history", {
|
this.events.fire_react("notify_conversation_history", {
|
||||||
chatId: this.chatId,
|
chatId: this.chatId,
|
||||||
state: "error",
|
state: "error",
|
||||||
errorMessage: this.historyErrorMessage = tra("failed on {}", result.failedPermission || tr("unknown permission")),
|
errorMessage: this.historyErrorMessage = tra("failed on {}", result.failedPermission || tr("unknown permission")),
|
||||||
|
@ -274,7 +274,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
this.events.fire_async("notify_conversation_history", {
|
this.events.fire_react("notify_conversation_history", {
|
||||||
chatId: this.chatId,
|
chatId: this.chatId,
|
||||||
state: "error",
|
state: "error",
|
||||||
errorMessage: this.historyErrorMessage = result.errorMessage,
|
errorMessage: this.historyErrorMessage = result.errorMessage,
|
||||||
|
@ -325,7 +325,7 @@ export abstract class AbstractChatManager<Events extends ConversationUIEvents> {
|
||||||
protected handleQueryConversationState(event: ConversationUIEvents["query_conversation_state"]) {
|
protected handleQueryConversationState(event: ConversationUIEvents["query_conversation_state"]) {
|
||||||
const conversation = this.findChat(event.chatId);
|
const conversation = this.findChat(event.chatId);
|
||||||
if(!conversation) {
|
if(!conversation) {
|
||||||
this.uiEvents.fire_async("notify_conversation_state", {
|
this.uiEvents.fire_react("notify_conversation_state", {
|
||||||
state: "error",
|
state: "error",
|
||||||
errorMessage: tr("Unknown conversation"),
|
errorMessage: tr("Unknown conversation"),
|
||||||
|
|
||||||
|
@ -345,7 +345,7 @@ export abstract class AbstractChatManager<Events extends ConversationUIEvents> {
|
||||||
protected handleQueryHistory(event: ConversationUIEvents["query_conversation_history"]) {
|
protected handleQueryHistory(event: ConversationUIEvents["query_conversation_history"]) {
|
||||||
const conversation = this.findChat(event.chatId);
|
const conversation = this.findChat(event.chatId);
|
||||||
if(!conversation) {
|
if(!conversation) {
|
||||||
this.uiEvents.fire_async("notify_conversation_history", {
|
this.uiEvents.fire_react("notify_conversation_history", {
|
||||||
state: "error",
|
state: "error",
|
||||||
errorMessage: tr("Unknown conversation"),
|
errorMessage: tr("Unknown conversation"),
|
||||||
retryTimestamp: Date.now() + 10 * 1000,
|
retryTimestamp: Date.now() + 10 * 1000,
|
||||||
|
|
|
@ -324,6 +324,6 @@ export class ChatBox extends React.Component<ChatBoxProperties, ChatBoxState> {
|
||||||
|
|
||||||
componentDidUpdate(prevProps: Readonly<ChatBoxProperties>, prevState: Readonly<ChatBoxState>, snapshot?: any): void {
|
componentDidUpdate(prevProps: Readonly<ChatBoxProperties>, prevState: Readonly<ChatBoxState>, snapshot?: any): void {
|
||||||
if(prevState.enabled !== this.state.enabled)
|
if(prevState.enabled !== this.state.enabled)
|
||||||
this.events.fire_async("action_set_enabled", { enabled: this.state.enabled });
|
this.events.fire_react("action_set_enabled", { enabled: this.state.enabled });
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -468,7 +468,7 @@ export class ConversationManager extends AbstractChatManager<ConversationUIEvent
|
||||||
|
|
||||||
@EventHandler<ConversationUIEvents>("query_selected_chat")
|
@EventHandler<ConversationUIEvents>("query_selected_chat")
|
||||||
private handleQuerySelectedChat() {
|
private handleQuerySelectedChat() {
|
||||||
this.uiEvents.fire_async("notify_selected_chat", { chatId: isNaN(this.selectedConversation_) ? "unselected" : this.selectedConversation_ + ""})
|
this.uiEvents.fire_react("notify_selected_chat", { chatId: isNaN(this.selectedConversation_) ? "unselected" : this.selectedConversation_ + ""})
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler<ConversationUIEvents>("notify_selected_chat")
|
@EventHandler<ConversationUIEvents>("notify_selected_chat")
|
||||||
|
|
|
@ -517,7 +517,7 @@ class ConversationMessages extends React.PureComponent<ConversationMessagesPrope
|
||||||
/* only load history when we're in an upwards scroll move */
|
/* only load history when we're in an upwards scroll move */
|
||||||
if(this.scrollOffset === "bottom" || this.scrollOffset > top) {
|
if(this.scrollOffset === "bottom" || this.scrollOffset > top) {
|
||||||
this.scrollHistoryAutoLoadThrottle = Date.now() + 500; /* don't spam events */
|
this.scrollHistoryAutoLoadThrottle = Date.now() + 500; /* don't spam events */
|
||||||
this.props.events.fire_async("query_conversation_history", { chatId: this.currentChatId, timestamp: firstMessageTimestamp });
|
this.props.events.fire_react("query_conversation_history", { chatId: this.currentChatId, timestamp: firstMessageTimestamp });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -162,7 +162,7 @@ export class PrivateConversation extends AbstractChat<PrivateConversationUIEvent
|
||||||
error: "error",
|
error: "error",
|
||||||
errorMessage: tr("target client is offline/invisible")
|
errorMessage: tr("target client is offline/invisible")
|
||||||
});
|
});
|
||||||
this.events.fire_async("notify_chat_event", {
|
this.events.fire_react("notify_chat_event", {
|
||||||
chatId: this.chatId,
|
chatId: this.chatId,
|
||||||
triggerUnread: false,
|
triggerUnread: false,
|
||||||
event: this.presentEvents.last()
|
event: this.presentEvents.last()
|
||||||
|
@ -406,7 +406,7 @@ export class PrivateConversationManager extends AbstractChatManager<PrivateConve
|
||||||
|
|
||||||
this.activeConversation = conversation;
|
this.activeConversation = conversation;
|
||||||
/* fire this after all other events have been processed, maybe reportConversationList has been called before */
|
/* fire this after all other events have been processed, maybe reportConversationList has been called before */
|
||||||
this.uiEvents.fire_async("notify_selected_chat", { chatId: this.activeConversation ? this.activeConversation.clientUniqueId : "unselected" });
|
this.uiEvents.fire_react("notify_selected_chat", { chatId: this.activeConversation ? this.activeConversation.clientUniqueId : "unselected" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler<PrivateConversationUIEvents>("action_select_chat")
|
@EventHandler<PrivateConversationUIEvents>("action_select_chat")
|
||||||
|
@ -439,7 +439,7 @@ export class PrivateConversationManager extends AbstractChatManager<PrivateConve
|
||||||
}
|
}
|
||||||
|
|
||||||
private reportConversationList() {
|
private reportConversationList() {
|
||||||
this.uiEvents.fire_async("notify_private_conversations", {
|
this.uiEvents.fire_react("notify_private_conversations", {
|
||||||
conversations: this.conversations.map(conversation => conversation.generateUIInfo()),
|
conversations: this.conversations.map(conversation => conversation.generateUIInfo()),
|
||||||
selected: this.activeConversation?.clientUniqueId || "unselected"
|
selected: this.activeConversation?.clientUniqueId || "unselected"
|
||||||
});
|
});
|
||||||
|
|
471
shared/js/ui/frames/video/Controller.ts
Normal file
471
shared/js/ui/frames/video/Controller.ts
Normal file
|
@ -0,0 +1,471 @@
|
||||||
|
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ReactDOM from "react-dom";
|
||||||
|
import {ChannelVideoRenderer} from "tc-shared/ui/frames/video/Renderer";
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
import {ChannelVideoEvents, kLocalVideoId} from "tc-shared/ui/frames/video/Definitions";
|
||||||
|
import {VideoBroadcastState, VideoBroadcastType, VideoConnection} from "tc-shared/connection/VideoConnection";
|
||||||
|
import {ClientEntry, ClientType, LocalClientEntry, MusicClientEntry} from "tc-shared/tree/Client";
|
||||||
|
import {LogCategory, logWarn} from "tc-shared/log";
|
||||||
|
|
||||||
|
const cssStyle = require("./Renderer.scss");
|
||||||
|
|
||||||
|
let videoIdIndex = 0;
|
||||||
|
interface ClientVideoController {
|
||||||
|
destroy();
|
||||||
|
notifyVideoInfo();
|
||||||
|
notifyVideo();
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteClientVideoController implements ClientVideoController {
|
||||||
|
readonly videoId: string;
|
||||||
|
readonly client: ClientEntry;
|
||||||
|
callbackBroadcastStateChanged: (broadcasting: boolean) => void;
|
||||||
|
|
||||||
|
protected readonly events: Registry<ChannelVideoEvents>;
|
||||||
|
protected eventListener: (() => void)[];
|
||||||
|
protected eventListenerVideoClient: (() => void)[];
|
||||||
|
|
||||||
|
private currentBroadcastState: boolean;
|
||||||
|
|
||||||
|
constructor(client: ClientEntry, eventRegistry: Registry<ChannelVideoEvents>, videoId?: string) {
|
||||||
|
this.client = client;
|
||||||
|
this.events = eventRegistry;
|
||||||
|
this.videoId = videoId || ("client-video-" + (++videoIdIndex));
|
||||||
|
this.currentBroadcastState = false;
|
||||||
|
|
||||||
|
const events = this.eventListener = [];
|
||||||
|
events.push(client.events.on("notify_properties_updated", event => {
|
||||||
|
if("client_nickname" in event.updated_properties) {
|
||||||
|
this.notifyVideoInfo();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
events.push(client.events.on("notify_status_icon_changed", event => {
|
||||||
|
this.events.fire_react("notify_video_info_status", { videoId: this.videoId, statusIcon: event.newIcon });
|
||||||
|
}));
|
||||||
|
|
||||||
|
events.push(client.events.on("notify_video_handle_changed", () => this.updateVideoClient()));
|
||||||
|
|
||||||
|
this.updateVideoClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateVideoClient() {
|
||||||
|
this.eventListenerVideoClient?.forEach(callback => callback());
|
||||||
|
const events = this.eventListenerVideoClient = [];
|
||||||
|
|
||||||
|
const videoClient = this.client.getVideoClient();
|
||||||
|
if(videoClient) {
|
||||||
|
events.push(videoClient.getEvents().on("notify_broadcast_state_changed", () => this.notifyVideo()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.eventListenerVideoClient?.forEach(callback => callback());
|
||||||
|
this.eventListenerVideoClient = undefined;
|
||||||
|
|
||||||
|
this.eventListener?.forEach(callback => callback());
|
||||||
|
this.eventListener = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
isBroadcasting() {
|
||||||
|
const videoClient = this.client.getVideoClient();
|
||||||
|
return videoClient && (videoClient.getVideoState("camera") !== VideoBroadcastState.Stopped || videoClient.getVideoState("screen") !== VideoBroadcastState.Stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyVideoInfo() {
|
||||||
|
this.events.fire_react("notify_video_info", {
|
||||||
|
videoId: this.videoId,
|
||||||
|
info: {
|
||||||
|
clientId: this.client.clientId(),
|
||||||
|
clientUniqueId: this.client.properties.client_unique_identifier,
|
||||||
|
clientName: this.client.clientNickName(),
|
||||||
|
statusIcon: this.client.getStatusIcon()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyVideo() {
|
||||||
|
let broadcasting = false;
|
||||||
|
if(this.isVideoActive()) {
|
||||||
|
let streams = [];
|
||||||
|
let initializing = false;
|
||||||
|
|
||||||
|
const stateCamera = this.getBroadcastState("camera");
|
||||||
|
if(stateCamera === VideoBroadcastState.Running) {
|
||||||
|
streams.push(this.getBroadcastStream("camera"));
|
||||||
|
} else if(stateCamera === VideoBroadcastState.Initializing) {
|
||||||
|
initializing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateScreen = this.getBroadcastState("screen");
|
||||||
|
if(stateScreen === VideoBroadcastState.Running) {
|
||||||
|
streams.push(this.getBroadcastStream("screen"));
|
||||||
|
} else if(stateScreen === VideoBroadcastState.Initializing) {
|
||||||
|
initializing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(streams.length > 0) {
|
||||||
|
broadcasting = true;
|
||||||
|
this.events.fire_react("notify_video", {
|
||||||
|
videoId: this.videoId,
|
||||||
|
status: {
|
||||||
|
status: "connected",
|
||||||
|
desktopStream: streams[1],
|
||||||
|
cameraStream: streams[0]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if(initializing) {
|
||||||
|
broadcasting = true;
|
||||||
|
this.events.fire_react("notify_video", {
|
||||||
|
videoId: this.videoId,
|
||||||
|
status: { status: "initializing" }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.events.fire_react("notify_video", {
|
||||||
|
videoId: this.videoId,
|
||||||
|
status: {
|
||||||
|
status: "connected",
|
||||||
|
cameraStream: undefined,
|
||||||
|
desktopStream: undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.events.fire_react("notify_video", {
|
||||||
|
videoId: this.videoId,
|
||||||
|
status: { status: "no-video" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if(broadcasting !== this.currentBroadcastState) {
|
||||||
|
this.currentBroadcastState = broadcasting;
|
||||||
|
if(this.callbackBroadcastStateChanged) {
|
||||||
|
this.callbackBroadcastStateChanged(broadcasting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isVideoActive() : boolean {
|
||||||
|
return typeof this.client.getVideoClient() !== "undefined";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getBroadcastState(target: VideoBroadcastType) : VideoBroadcastState {
|
||||||
|
const videoClient = this.client.getVideoClient();
|
||||||
|
return videoClient ? videoClient.getVideoState(target) : VideoBroadcastState.Stopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getBroadcastStream(target: VideoBroadcastType) : MediaStream | undefined {
|
||||||
|
const videoClient = this.client.getVideoClient();
|
||||||
|
return videoClient ? videoClient.getVideoStream(target) : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalVideoController extends RemoteClientVideoController {
|
||||||
|
constructor(client: ClientEntry, eventRegistry: Registry<ChannelVideoEvents>) {
|
||||||
|
super(client, eventRegistry, kLocalVideoId);
|
||||||
|
|
||||||
|
const videoConnection = client.channelTree.client.serverConnection.getVideoConnection();
|
||||||
|
this.eventListener.push(videoConnection.getEvents().on("notify_local_broadcast_state_changed", () => this.notifyVideo()));
|
||||||
|
}
|
||||||
|
|
||||||
|
isBroadcasting() {
|
||||||
|
const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection();
|
||||||
|
return videoConnection.isBroadcasting("camera") || videoConnection.isBroadcasting("screen");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isVideoActive(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getBroadcastState(target: VideoBroadcastType): VideoBroadcastState {
|
||||||
|
const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection();
|
||||||
|
return videoConnection.getBroadcastingState(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getBroadcastStream(target: VideoBroadcastType) : MediaStream | undefined {
|
||||||
|
const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection();
|
||||||
|
return videoConnection.getBroadcastingSource(target)?.getStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChannelVideoController {
|
||||||
|
callbackVisibilityChanged: (visible: boolean) => void;
|
||||||
|
|
||||||
|
private readonly connection: ConnectionHandler;
|
||||||
|
private readonly videoConnection: VideoConnection;
|
||||||
|
private readonly events: Registry<ChannelVideoEvents>;
|
||||||
|
private eventListener: (() => void)[];
|
||||||
|
|
||||||
|
private expended: boolean;
|
||||||
|
private currentlyVisible: boolean;
|
||||||
|
|
||||||
|
private currentChannelId: number;
|
||||||
|
private localVideoController: LocalVideoController;
|
||||||
|
private clientVideos: {[key: number]: RemoteClientVideoController} = {};
|
||||||
|
|
||||||
|
constructor(events: Registry<ChannelVideoEvents>, connection: ConnectionHandler) {
|
||||||
|
this.events = events;
|
||||||
|
this.connection = connection;
|
||||||
|
this.videoConnection = this.connection.serverConnection.getVideoConnection();
|
||||||
|
this.connection.events().one("notify_handler_initialized", () => {
|
||||||
|
this.localVideoController = new LocalVideoController(connection.getClient(), this.events);
|
||||||
|
this.localVideoController.callbackBroadcastStateChanged = () => this.notifyVideoList();
|
||||||
|
});
|
||||||
|
this.currentlyVisible = false;
|
||||||
|
this.expended = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isExpended() : boolean { return this.expended; }
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.eventListener?.forEach(callback => callback());
|
||||||
|
this.eventListener = undefined;
|
||||||
|
|
||||||
|
if(this.localVideoController) {
|
||||||
|
this.localVideoController.callbackBroadcastStateChanged = undefined;
|
||||||
|
this.localVideoController.destroy();
|
||||||
|
this.localVideoController = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resetClientVideos();
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize() {
|
||||||
|
const events = this.eventListener = [];
|
||||||
|
this.events.on("action_toggle_expended", event => {
|
||||||
|
if(event.expended === this.expended) { return; }
|
||||||
|
this.expended = event.expended;
|
||||||
|
this.events.fire_react("notify_expended", { expended: this.expended });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.events.on("query_expended", () => this.events.fire_react("notify_expended", { expended: this.expended }));
|
||||||
|
this.events.on("query_videos", () => this.notifyVideoList());
|
||||||
|
|
||||||
|
this.events.on("query_video_info", event => {
|
||||||
|
const controller = this.findVideoById(event.videoId);
|
||||||
|
if(!controller) {
|
||||||
|
logWarn(LogCategory.VIDEO, tr("Tried to query video info for a non existing video id (%s)."), event.videoId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.notifyVideoInfo();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.events.on("query_video", event => {
|
||||||
|
const controller = this.findVideoById(event.videoId);
|
||||||
|
if(!controller) {
|
||||||
|
logWarn(LogCategory.VIDEO, tr("Tried to query video for a non existing video id (%s)."), event.videoId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.notifyVideo();
|
||||||
|
});
|
||||||
|
|
||||||
|
const channelTree = this.connection.channelTree;
|
||||||
|
events.push(channelTree.events.on("notify_tree_reset", () => {
|
||||||
|
this.resetClientVideos();
|
||||||
|
this.currentChannelId = undefined;
|
||||||
|
this.notifyVideoList();
|
||||||
|
}));
|
||||||
|
|
||||||
|
events.push(channelTree.events.on("notify_client_moved", event => {
|
||||||
|
if(ChannelVideoController.shouldIgnoreClient(event.client)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(event.client instanceof LocalClientEntry) {
|
||||||
|
this.updateLocalChannel(event.client);
|
||||||
|
} else {
|
||||||
|
if(event.oldChannel.channelId === this.currentChannelId) {
|
||||||
|
if(this.destroyClientVideo(event.client.clientId())) {
|
||||||
|
this.notifyVideoList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(event.newChannel.channelId === this.currentChannelId) {
|
||||||
|
this.createClientVideo(event.client);
|
||||||
|
this.notifyVideoList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
events.push(channelTree.events.on("notify_client_leave_view", event => {
|
||||||
|
if(ChannelVideoController.shouldIgnoreClient(event.client)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this.destroyClientVideo(event.client.clientId())) {
|
||||||
|
this.notifyVideoList();
|
||||||
|
}
|
||||||
|
if(event.client instanceof LocalClientEntry) {
|
||||||
|
this.resetClientVideos();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
events.push(channelTree.events.on("notify_client_enter_view", event => {
|
||||||
|
if(ChannelVideoController.shouldIgnoreClient(event.client)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(event.targetChannel.channelId === this.currentChannelId) {
|
||||||
|
this.createClientVideo(event.client);
|
||||||
|
this.notifyVideoList();
|
||||||
|
}
|
||||||
|
if(event.client instanceof LocalClientEntry) {
|
||||||
|
this.updateLocalChannel(event.client);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
events.push(channelTree.events.on("notify_channel_client_order_changed", event => {
|
||||||
|
if(event.channel.channelId == this.currentChannelId) {
|
||||||
|
this.notifyVideoList();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static shouldIgnoreClient(client: ClientEntry) {
|
||||||
|
return (client instanceof MusicClientEntry || client.properties.client_type_exact === ClientType.CLIENT_QUERY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateLocalChannel(localClient: ClientEntry) {
|
||||||
|
this.resetClientVideos();
|
||||||
|
if(localClient.currentChannel()) {
|
||||||
|
this.currentChannelId = localClient.currentChannel().channelId;
|
||||||
|
localClient.currentChannel().channelClientsOrdered().forEach(client => {
|
||||||
|
if(client instanceof LocalClientEntry || ChannelVideoController.shouldIgnoreClient(client)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.createClientVideo(client);
|
||||||
|
});
|
||||||
|
this.notifyVideoList();
|
||||||
|
} else {
|
||||||
|
this.currentChannelId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findVideoById(videoId: string) : ClientVideoController | undefined {
|
||||||
|
if(this.localVideoController?.videoId === videoId) {
|
||||||
|
return this.localVideoController;
|
||||||
|
}
|
||||||
|
return Object.values(this.clientVideos).find(e => e.videoId === videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetClientVideos() {
|
||||||
|
for(const clientId of Object.keys(this.clientVideos)) {
|
||||||
|
this.destroyClientVideo(parseInt(clientId));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifyVideoList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroyClientVideo(clientId: number) : boolean {
|
||||||
|
if(this.clientVideos[clientId]) {
|
||||||
|
const video = this.clientVideos[clientId];
|
||||||
|
video.callbackBroadcastStateChanged = undefined;
|
||||||
|
video.destroy();
|
||||||
|
delete this.clientVideos[clientId];
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createClientVideo(client: ClientEntry) {
|
||||||
|
this.destroyClientVideo(client.clientId());
|
||||||
|
|
||||||
|
const controller = new RemoteClientVideoController(client, this.events);
|
||||||
|
/* update our video list and the visibility */
|
||||||
|
controller.callbackBroadcastStateChanged = () => this.notifyVideoList();
|
||||||
|
this.clientVideos[client.clientId()] = controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyVideoList() {
|
||||||
|
const videoIds = [];
|
||||||
|
|
||||||
|
let videoCount = 0;
|
||||||
|
if(this.localVideoController) {
|
||||||
|
videoIds.push(this.localVideoController.videoId);
|
||||||
|
if(this.localVideoController.isBroadcasting()) { videoCount++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = this.connection.channelTree.findChannel(this.currentChannelId);
|
||||||
|
if(channel) {
|
||||||
|
const clients = channel.channelClientsOrdered();
|
||||||
|
for(const client of clients) {
|
||||||
|
if(!this.clientVideos[client.clientId()]) {
|
||||||
|
/* should not be possible (Is only possible for the local client) */
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = this.clientVideos[client.clientId()];
|
||||||
|
if(controller.isBroadcasting()) {
|
||||||
|
videoCount++;
|
||||||
|
} else {
|
||||||
|
/* TODO: Filter if video is active */
|
||||||
|
}
|
||||||
|
videoIds.push(controller.videoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateVisibility(videoCount !== 0);
|
||||||
|
|
||||||
|
this.events.fire_react("notify_videos", {
|
||||||
|
videoIds: videoIds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateVisibility(target: boolean) {
|
||||||
|
if(this.currentlyVisible === target) { return; }
|
||||||
|
|
||||||
|
this.currentlyVisible = target;
|
||||||
|
if(this.callbackVisibilityChanged) {
|
||||||
|
this.callbackVisibilityChanged(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChannelVideoFrame {
|
||||||
|
private readonly handle: ConnectionHandler;
|
||||||
|
private readonly events: Registry<ChannelVideoEvents>;
|
||||||
|
private container: HTMLDivElement;
|
||||||
|
private controller: ChannelVideoController;
|
||||||
|
|
||||||
|
constructor(handle: ConnectionHandler) {
|
||||||
|
this.handle = handle;
|
||||||
|
this.events = new Registry<ChannelVideoEvents>();
|
||||||
|
this.controller = new ChannelVideoController(this.events, handle);
|
||||||
|
this.controller.initialize();
|
||||||
|
|
||||||
|
this.container = document.createElement("div");
|
||||||
|
this.container.classList.add(cssStyle.container, cssStyle.hidden);
|
||||||
|
|
||||||
|
ReactDOM.render(React.createElement(ChannelVideoRenderer, { handlerId: handle.handlerId, events: this.events }), this.container);
|
||||||
|
|
||||||
|
this.events.on("notify_expended", event => this.container.classList.toggle(cssStyle.expended, event.expended));
|
||||||
|
this.controller.callbackVisibilityChanged = flag => {
|
||||||
|
this.container.classList.toggle(cssStyle.hidden, !flag);
|
||||||
|
if(!flag) {
|
||||||
|
this.events.fire("action_toggle_expended", { expended: false })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.controller?.destroy();
|
||||||
|
this.controller = undefined;
|
||||||
|
|
||||||
|
if(this.container) {
|
||||||
|
this.container.remove();
|
||||||
|
ReactDOM.unmountComponentAtNode(this.container);
|
||||||
|
|
||||||
|
this.container = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.events.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
getContainer() : HTMLDivElement {
|
||||||
|
return this.container;
|
||||||
|
}
|
||||||
|
}
|
49
shared/js/ui/frames/video/Definitions.ts
Normal file
49
shared/js/ui/frames/video/Definitions.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import {ClientIcon} from "svg-sprites/client-icons";
|
||||||
|
|
||||||
|
export const kLocalVideoId = "__local__video__";
|
||||||
|
|
||||||
|
export type ChannelVideoInfo = { clientName: string, clientUniqueId: string, clientId: number, statusIcon: ClientIcon };
|
||||||
|
|
||||||
|
export type ChannelVideo ={
|
||||||
|
status: "initializing",
|
||||||
|
} | {
|
||||||
|
status: "connected",
|
||||||
|
cameraStream: MediaStream | undefined,
|
||||||
|
desktopStream: MediaStream | undefined,
|
||||||
|
} | {
|
||||||
|
status: "error",
|
||||||
|
message: string
|
||||||
|
} | {
|
||||||
|
status: "no-video"
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ChannelVideoEvents {
|
||||||
|
action_toggle_expended: { expended: boolean },
|
||||||
|
action_video_scroll: { direction: "left" | "right" },
|
||||||
|
|
||||||
|
query_expended: {},
|
||||||
|
query_videos: {},
|
||||||
|
query_video: { videoId: string },
|
||||||
|
query_video_info: { videoId: string },
|
||||||
|
|
||||||
|
notify_expended: { expended: boolean },
|
||||||
|
notify_videos: {
|
||||||
|
videoIds: string[]
|
||||||
|
},
|
||||||
|
notify_video: {
|
||||||
|
videoId: string,
|
||||||
|
status: ChannelVideo
|
||||||
|
},
|
||||||
|
notify_video_info: {
|
||||||
|
videoId: string,
|
||||||
|
info: ChannelVideoInfo
|
||||||
|
},
|
||||||
|
notify_video_info_status: {
|
||||||
|
videoId: string,
|
||||||
|
statusIcon: ClientIcon
|
||||||
|
},
|
||||||
|
notify_video_arrows: {
|
||||||
|
left: boolean,
|
||||||
|
right: boolean
|
||||||
|
}
|
||||||
|
}
|
266
shared/js/ui/frames/video/Renderer.scss
Normal file
266
shared/js/ui/frames/video/Renderer.scss
Normal file
|
@ -0,0 +1,266 @@
|
||||||
|
@import "../../../../css/static/properties";
|
||||||
|
@import "../../../../css/static/mixin";
|
||||||
|
|
||||||
|
/* Using a general video format of 16:9 */
|
||||||
|
|
||||||
|
$small_height: 10em;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
@include user-select(none);
|
||||||
|
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
height: $small_height;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
margin-bottom: 5px;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
@include transition(all .3s ease-in-out);
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
height: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.expended {
|
||||||
|
.panel {
|
||||||
|
height: calc(100% - 1.5em); /* the footer size (version etc) */
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expendArrow .icon {
|
||||||
|
@include transform(rotate(90deg)!important);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
height: $small_height;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
background-color: #353535;
|
||||||
|
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@include transition(all .3s ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expendArrow {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: .5em;
|
||||||
|
right: .5em;
|
||||||
|
|
||||||
|
padding: .2em;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: .25em;
|
||||||
|
|
||||||
|
@include transition(all $button_hover_animation_time ease-in-out);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #3c3d3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
align-self: center;
|
||||||
|
font-size: 2em;
|
||||||
|
|
||||||
|
@include transition(all .3s ease-in-out);
|
||||||
|
@include transform(rotate(180deg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoBar {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
height: $small_height;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
flex-shrink: 1;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
margin-left: .5em;
|
||||||
|
margin-right: .5em;
|
||||||
|
|
||||||
|
/* TODO: Min with of two video +4em for one of the arrows */
|
||||||
|
min-width: 6em;
|
||||||
|
|
||||||
|
.videos {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
width: 4em;
|
||||||
|
background: linear-gradient(90deg, #35353500 0%, #353535 50%);
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
@include transition(all $button_hover_animation_time ease-in-out);
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconContainer {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
align-self: flex-end;
|
||||||
|
padding: .1em;
|
||||||
|
|
||||||
|
border-radius: .25em;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #3c3d3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
align-self: center;
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
left: 0;
|
||||||
|
@include transform(rotate(180deg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoContainer {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
margin-top: .5em;
|
||||||
|
margin-bottom: .5em;
|
||||||
|
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
|
||||||
|
height: ($small_height - 1em);
|
||||||
|
width: ($small_height * 16 / 9);
|
||||||
|
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
box-shadow: inset 0 0 5px #00000040;
|
||||||
|
|
||||||
|
border-radius: .2em;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:not(:last-of-type) {
|
||||||
|
margin-right: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoPrimary {
|
||||||
|
|
||||||
|
}
|
||||||
|
.videoSecondary {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
font-size: 1.25em;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
/* TODO! */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
border-top-right-radius: .2em;
|
||||||
|
background-color: #35353580;
|
||||||
|
|
||||||
|
padding: .1em .3em;
|
||||||
|
|
||||||
|
max-width: 70%;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
align-self: center;
|
||||||
|
color: #999;
|
||||||
|
margin-left: .25em;
|
||||||
|
font-weight: normal!important;
|
||||||
|
|
||||||
|
@include text-dotdotdot();
|
||||||
|
|
||||||
|
&.local {
|
||||||
|
color: #147114;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
264
shared/js/ui/frames/video/Renderer.tsx
Normal file
264
shared/js/ui/frames/video/Renderer.tsx
Normal file
|
@ -0,0 +1,264 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import {useCallback, useContext, useEffect, useRef, useState} from "react";
|
||||||
|
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
|
||||||
|
import {ClientIcon} from "svg-sprites/client-icons";
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
import {ChannelVideo, ChannelVideoEvents, ChannelVideoInfo, kLocalVideoId} from "tc-shared/ui/frames/video/Definitions";
|
||||||
|
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||||
|
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
|
||||||
|
import {ClientTag} from "tc-shared/ui/tree/EntryTags";
|
||||||
|
import ResizeObserver from "resize-observer-polyfill";
|
||||||
|
|
||||||
|
const EventContext = React.createContext<Registry<ChannelVideoEvents>>(undefined);
|
||||||
|
const HandlerIdContext = React.createContext<string>(undefined);
|
||||||
|
|
||||||
|
const cssStyle = require("./Renderer.scss");
|
||||||
|
|
||||||
|
const ExpendArrow = () => {
|
||||||
|
const events = useContext(EventContext);
|
||||||
|
|
||||||
|
const [ expended, setExpended ] = useState(() => {
|
||||||
|
events.fire("query_expended");
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
events.reactUse("notify_expended", event => setExpended(event.expended), undefined, [ setExpended ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.expendArrow} onClick={() => events.fire("action_toggle_expended", { expended: !expended })}>
|
||||||
|
<ClientIconRenderer icon={ClientIcon.DoubleArrow} className={cssStyle.icon} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
const VideoInfo = React.memo((props: { videoId: string }) => {
|
||||||
|
const events = useContext(EventContext);
|
||||||
|
const handlerId = useContext(HandlerIdContext);
|
||||||
|
|
||||||
|
const localVideo = props.videoId === kLocalVideoId;
|
||||||
|
const nameClassList = cssStyle.name + " " + (localVideo ? cssStyle.local : "");
|
||||||
|
|
||||||
|
const [ info, setInfo ] = useState<"loading" | ChannelVideoInfo>(() => {
|
||||||
|
events.fire("query_video_info", { videoId: props.videoId });
|
||||||
|
return "loading";
|
||||||
|
});
|
||||||
|
|
||||||
|
const [ statusIcon, setStatusIcon ] = useState<ClientIcon>(ClientIcon.PlayerOff);
|
||||||
|
|
||||||
|
events.reactUse("notify_video_info", event => {
|
||||||
|
if(event.videoId === props.videoId) {
|
||||||
|
setInfo(event.info);
|
||||||
|
setStatusIcon(event.info.statusIcon);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
events.reactUse("notify_video_info_status", event => {
|
||||||
|
if(event.videoId === props.videoId) {
|
||||||
|
setStatusIcon(event.statusIcon);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let clientName;
|
||||||
|
if(info === "loading") {
|
||||||
|
clientName = <div className={nameClassList} key={"loading"}><Translatable>loading</Translatable> {props.videoId} <LoadingDots /></div>;
|
||||||
|
} else {
|
||||||
|
clientName = <ClientTag clientName={info.clientName} clientUniqueId={info.clientUniqueId} clientId={info.clientId} handlerId={handlerId} className={nameClassList} key={"loaded"} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.info}>
|
||||||
|
<ClientIconRenderer icon={statusIcon} className={cssStyle.icon} />
|
||||||
|
{clientName}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const VideoStreamReplay = React.memo((props: { stream: MediaStream | undefined, className: string }) => {
|
||||||
|
const refVideo = useRef<HTMLVideoElement>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if(props.stream) {
|
||||||
|
refVideo.current.style.opacity = "1";
|
||||||
|
refVideo.current.srcObject = props.stream;
|
||||||
|
} else {
|
||||||
|
refVideo.current.style.opacity = "0";
|
||||||
|
}
|
||||||
|
}, [ props.stream ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video ref={refVideo} autoPlay={true} className={cssStyle.video + " " + props.className} />
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const VideoPlayer = React.memo((props: { videoId: string }) => {
|
||||||
|
const events = useContext(EventContext);
|
||||||
|
const [ state, setState ] = useState<"loading" | ChannelVideo>(() => {
|
||||||
|
events.fire("query_video", { videoId: props.videoId });
|
||||||
|
return "loading";
|
||||||
|
});
|
||||||
|
events.reactUse("notify_video", event => {
|
||||||
|
if(event.videoId === props.videoId) {
|
||||||
|
setState(event.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if(state === "loading") {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.text} key={"info-loading"}>
|
||||||
|
<div><Translatable>loading</Translatable> <LoadingDots /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if(state.status === "initializing") {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.text} key={"info-initializing"}>
|
||||||
|
<div><Translatable>connecting</Translatable> <LoadingDots /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if(state.status === "error") {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.error + " " + cssStyle.text} key={"info-error"}>
|
||||||
|
<div>{state.message}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if(state.status === "connected") {
|
||||||
|
if(state.desktopStream && state.cameraStream) {
|
||||||
|
/* TODO: Select primary and secondary and display them */
|
||||||
|
return (
|
||||||
|
<VideoStreamReplay stream={state.desktopStream} key={"replay-multi"} className={cssStyle.videoPrimary} />
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const stream = state.desktopStream || state.cameraStream;
|
||||||
|
if(stream) {
|
||||||
|
return (
|
||||||
|
<VideoStreamReplay stream={stream} key={"replay-single"} className={cssStyle.videoPrimary} />
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.text} key={"no-video-stream"}>
|
||||||
|
<div><Translatable>No Video</Translatable></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if(state.status === "no-video") {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.text} key={"no-video"}>
|
||||||
|
<div><Translatable>No Video</Translatable></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const VideoContainer = React.memo((props: { videoId: string }) => (
|
||||||
|
<div className={cssStyle.videoContainer}>
|
||||||
|
<VideoPlayer videoId={props.videoId} />
|
||||||
|
<VideoInfo videoId={props.videoId} />
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
const VideoBarArrow = React.memo((props: { direction: "left" | "right", containerRef: React.RefObject<HTMLDivElement> }) => {
|
||||||
|
const events = useContext(EventContext);
|
||||||
|
const [ shown, setShown ] = useState(false);
|
||||||
|
events.reactUse("notify_video_arrows", event => setShown(event[props.direction]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.arrow + " " + cssStyle[props.direction] + " " + (shown ? "" : cssStyle.hidden)} ref={props.containerRef}>
|
||||||
|
<div className={cssStyle.iconContainer} onClick={() => events.fire("action_video_scroll", { direction: props.direction })}>
|
||||||
|
<ClientIconRenderer icon={ClientIcon.SimpleArrow} className={cssStyle.icon} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
|
||||||
|
const VideoBar = () => {
|
||||||
|
const events = useContext(EventContext);
|
||||||
|
const refVideos = useRef<HTMLDivElement>();
|
||||||
|
const refArrowRight = useRef<HTMLDivElement>();
|
||||||
|
const refArrowLeft = useRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
const [ videos, setVideos ] = useState<"loading" | string[]>(() => {
|
||||||
|
events.fire("query_videos");
|
||||||
|
return "loading";
|
||||||
|
});
|
||||||
|
events.reactUse("notify_videos", event => setVideos(event.videoIds));
|
||||||
|
|
||||||
|
const updateScrollButtons = useCallback(() => {
|
||||||
|
const container = refVideos.current;
|
||||||
|
if(!container) { return; }
|
||||||
|
|
||||||
|
const rightEndReached = container.scrollLeft + container.clientWidth + 1 >= container.scrollWidth;
|
||||||
|
const leftEndReached = container.scrollLeft <= .9;
|
||||||
|
events.fire("notify_video_arrows", { left: !leftEndReached, right: !rightEndReached });
|
||||||
|
}, [ refVideos ]);
|
||||||
|
|
||||||
|
events.reactUse("action_video_scroll", event => {
|
||||||
|
const container = refVideos.current;
|
||||||
|
const arrowLeft = refArrowLeft.current;
|
||||||
|
const arrowRight = refArrowRight.current;
|
||||||
|
if(container && arrowLeft && arrowRight) {
|
||||||
|
const children = [...container.children] as HTMLElement[];
|
||||||
|
if(event.direction === "left") {
|
||||||
|
const currentCutOff = container.scrollLeft;
|
||||||
|
const element = children.filter(element => element.offsetLeft >= currentCutOff)
|
||||||
|
.sort((a, b) => a.offsetLeft - b.offsetLeft)[0];
|
||||||
|
|
||||||
|
container.scrollLeft = (element.offsetLeft + element.clientWidth) - (container.clientWidth - arrowRight.clientWidth);
|
||||||
|
} else {
|
||||||
|
const currentCutOff = container.scrollLeft + container.clientWidth;
|
||||||
|
const element = children.filter(element => element.offsetLeft <= currentCutOff)
|
||||||
|
.sort((a, b) => a.offsetLeft - b.offsetLeft)
|
||||||
|
.last();
|
||||||
|
|
||||||
|
container.scrollLeft = element.offsetLeft - arrowLeft.clientWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateScrollButtons();
|
||||||
|
}, undefined, [ updateScrollButtons ]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateScrollButtons();
|
||||||
|
}, [ videos ]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const animationRequest = { current: 0 };
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if(animationRequest.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
animationRequest.current = requestAnimationFrame(() => {
|
||||||
|
animationRequest.current = 0;
|
||||||
|
updateScrollButtons();
|
||||||
|
})
|
||||||
|
});
|
||||||
|
observer.observe(refVideos.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [ refVideos ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.videoBar}>
|
||||||
|
<div className={cssStyle.videos} ref={refVideos}>
|
||||||
|
{videos === "loading" ? undefined :
|
||||||
|
videos.map(videoId => <VideoContainer videoId={videoId} key={videoId} />)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<VideoBarArrow direction={"left"} containerRef={refArrowLeft} />
|
||||||
|
<VideoBarArrow direction={"right"} containerRef={refArrowRight} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChannelVideoRenderer = (props: { handlerId: string, events: Registry<ChannelVideoEvents> }) => {
|
||||||
|
return (
|
||||||
|
<EventContext.Provider value={props.events}>
|
||||||
|
<HandlerIdContext.Provider value={props.handlerId}>
|
||||||
|
<div className={cssStyle.panel}>
|
||||||
|
<VideoBar />
|
||||||
|
<ExpendArrow />
|
||||||
|
</div>
|
||||||
|
</HandlerIdContext.Provider>
|
||||||
|
</EventContext.Provider>
|
||||||
|
)
|
||||||
|
};
|
|
@ -231,7 +231,7 @@ export function spawnClientVolumeChange(client: ClientEntry) {
|
||||||
const events = new Registry<VolumeChangeEvents>();
|
const events = new Registry<VolumeChangeEvents>();
|
||||||
|
|
||||||
events.on("query-volume", () => {
|
events.on("query-volume", () => {
|
||||||
events.fire_async("query-volume-response", {
|
events.fire_react("query-volume-response", {
|
||||||
volume: client.getAudioVolume()
|
volume: client.getAudioVolume()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -263,7 +263,7 @@ export function spawnMusicBotVolumeChange(client: MusicClientEntry, maxValue: nu
|
||||||
const events = new Registry<VolumeChangeEvents>();
|
const events = new Registry<VolumeChangeEvents>();
|
||||||
|
|
||||||
events.on("query-volume", () => {
|
events.on("query-volume", () => {
|
||||||
events.fire_async("query-volume-response", {
|
events.fire_react("query-volume-response", {
|
||||||
volume: client.properties.player_volume
|
volume: client.properties.player_volume
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
|
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
|
||||||
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
||||||
import {Registry} from "tc-shared/events";
|
import {Registry} from "tc-shared/events";
|
||||||
import {FlatInputField, FlatSelect} from "tc-shared/ui/react-elements/InputField";
|
import {FlatInputField, Select} from "tc-shared/ui/react-elements/InputField";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {useEffect, useRef, useState} from "react";
|
import {useEffect, useRef, useState} from "react";
|
||||||
import {GroupType} from "tc-shared/permission/GroupManager";
|
import {GroupType} from "tc-shared/permission/GroupManager";
|
||||||
|
@ -118,7 +118,7 @@ const GroupNameInput = (props: { events: Registry<GroupCreateModalEvents>, defau
|
||||||
const GroupTypeSelector = (props: { events: Registry<GroupCreateModalEvents> }) => {
|
const GroupTypeSelector = (props: { events: Registry<GroupCreateModalEvents> }) => {
|
||||||
const [ selectedType, setSelectedType ] = useState<"query" | "template" | "normal" | "loading">("loading");
|
const [ selectedType, setSelectedType ] = useState<"query" | "template" | "normal" | "loading">("loading");
|
||||||
const [ permissions, setPermissions ] = useState<"loading" | { createTemplate, createQuery }>("loading");
|
const [ permissions, setPermissions ] = useState<"loading" | { createTemplate, createQuery }>("loading");
|
||||||
const refSelect = useRef<FlatSelect>();
|
const refSelect = useRef<Select>();
|
||||||
|
|
||||||
props.events.reactUse("notify_client_permissions", event => {
|
props.events.reactUse("notify_client_permissions", event => {
|
||||||
setPermissions({
|
setPermissions({
|
||||||
|
@ -133,7 +133,7 @@ const GroupTypeSelector = (props: { events: Registry<GroupCreateModalEvents> })
|
||||||
props.events.reactUse("action_set_type", event => setSelectedType(event.target));
|
props.events.reactUse("action_set_type", event => setSelectedType(event.target));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatSelect
|
<Select
|
||||||
ref={refSelect}
|
ref={refSelect}
|
||||||
label={<Translatable>Target group type</Translatable>}
|
label={<Translatable>Target group type</Translatable>}
|
||||||
className={cssStyle.groupType}
|
className={cssStyle.groupType}
|
||||||
|
@ -153,7 +153,7 @@ const GroupTypeSelector = (props: { events: Registry<GroupCreateModalEvents> })
|
||||||
<option
|
<option
|
||||||
value={"normal"}
|
value={"normal"}
|
||||||
>{tr("Regular group")}</option>
|
>{tr("Regular group")}</option>
|
||||||
</FlatSelect>
|
</Select>
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -162,7 +162,7 @@ const SourceGroupSelector = (props: { events: Registry<GroupCreateModalEvents>,
|
||||||
const [ permissions, setPermissions ] = useState<"loading" | { createTemplate, createQuery }>("loading");
|
const [ permissions, setPermissions ] = useState<"loading" | { createTemplate, createQuery }>("loading");
|
||||||
const [ exitingGroups, setExitingGroups ] = useState<"loading" | GroupInfo[]>("loading");
|
const [ exitingGroups, setExitingGroups ] = useState<"loading" | GroupInfo[]>("loading");
|
||||||
|
|
||||||
const refSelect = useRef<FlatSelect>();
|
const refSelect = useRef<Select>();
|
||||||
|
|
||||||
props.events.reactUse("notify_client_permissions", event => setPermissions({
|
props.events.reactUse("notify_client_permissions", event => setPermissions({
|
||||||
createQuery: event.createQueryGroup,
|
createQuery: event.createQueryGroup,
|
||||||
|
@ -178,12 +178,12 @@ const SourceGroupSelector = (props: { events: Registry<GroupCreateModalEvents>,
|
||||||
|
|
||||||
const isLoading = exitingGroups === "loading" || permissions === "loading";
|
const isLoading = exitingGroups === "loading" || permissions === "loading";
|
||||||
if(!isLoading && selectedGroup === undefined)
|
if(!isLoading && selectedGroup === undefined)
|
||||||
props.events.fire_async("action_set_source", {
|
props.events.fire_react("action_set_source", {
|
||||||
group: (exitingGroups as GroupInfo[]).findIndex(e => e.id === props.defaultSource) === -1 ? 0 : props.defaultSource
|
group: (exitingGroups as GroupInfo[]).findIndex(e => e.id === props.defaultSource) === -1 ? 0 : props.defaultSource
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatSelect
|
<Select
|
||||||
ref={refSelect}
|
ref={refSelect}
|
||||||
label={<Translatable>Create group using this template</Translatable>}
|
label={<Translatable>Create group using this template</Translatable>}
|
||||||
className={cssStyle.groupSource}
|
className={cssStyle.groupSource}
|
||||||
|
@ -214,7 +214,7 @@ const SourceGroupSelector = (props: { events: Registry<GroupCreateModalEvents>,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</FlatSelect>
|
</Select>
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -249,8 +249,8 @@ class ModalGroupCreate extends InternalModal {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onInitialize() {
|
protected onInitialize() {
|
||||||
this.events.fire_async("query_available_groups");
|
this.events.fire_react("query_available_groups");
|
||||||
this.events.fire_async("query_client_permissions");
|
this.events.fire_react("query_client_permissions");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onDestroy() {
|
protected onDestroy() {
|
||||||
|
@ -305,7 +305,7 @@ function initializeGroupCreateController(connection: ConnectionHandler, events:
|
||||||
events.on("query_available_groups", event => {
|
events.on("query_available_groups", event => {
|
||||||
const groups = target === "server" ? connection.groups.serverGroups : connection.groups.channelGroups;
|
const groups = target === "server" ? connection.groups.serverGroups : connection.groups.channelGroups;
|
||||||
|
|
||||||
events.fire_async("query_available_groups_result", {
|
events.fire_react("query_available_groups_result", {
|
||||||
groups: groups.map(e => {
|
groups: groups.map(e => {
|
||||||
return {
|
return {
|
||||||
name: e.name,
|
name: e.name,
|
||||||
|
@ -316,7 +316,7 @@ function initializeGroupCreateController(connection: ConnectionHandler, events:
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const notifyClientPermissions = () => events.fire_async("notify_client_permissions", {
|
const notifyClientPermissions = () => events.fire_react("notify_client_permissions", {
|
||||||
createQueryGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_QUERYGROUP).granted(1),
|
createQueryGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_QUERYGROUP).granted(1),
|
||||||
createTemplateGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_TEMPLATES).granted(1)
|
createTemplateGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_TEMPLATES).granted(1)
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
|
||||||
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
||||||
import {Registry} from "tc-shared/events";
|
import {Registry} from "tc-shared/events";
|
||||||
import {useRef, useState} from "react";
|
import {useRef, useState} from "react";
|
||||||
import {FlatSelect} from "tc-shared/ui/react-elements/InputField";
|
import {Select} from "tc-shared/ui/react-elements/InputField";
|
||||||
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {Button} from "tc-shared/ui/react-elements/Button";
|
import {Button} from "tc-shared/ui/react-elements/Button";
|
||||||
|
@ -51,7 +51,7 @@ const GroupSelector = (props: { events: Registry<GroupPermissionCopyModalEvents>
|
||||||
const [ permissions, setPermissions ] = useState<"loading" | { createTemplate, createQuery }>("loading");
|
const [ permissions, setPermissions ] = useState<"loading" | { createTemplate, createQuery }>("loading");
|
||||||
const [ exitingGroups, setExitingGroups ] = useState<"loading" | GroupInfo[]>("loading");
|
const [ exitingGroups, setExitingGroups ] = useState<"loading" | GroupInfo[]>("loading");
|
||||||
|
|
||||||
const refSelect = useRef<FlatSelect>();
|
const refSelect = useRef<Select>();
|
||||||
|
|
||||||
props.events.reactUse("notify_client_permissions", event => setPermissions({
|
props.events.reactUse("notify_client_permissions", event => setPermissions({
|
||||||
createQuery: event.createQueryGroup,
|
createQuery: event.createQueryGroup,
|
||||||
|
@ -67,12 +67,12 @@ const GroupSelector = (props: { events: Registry<GroupPermissionCopyModalEvents>
|
||||||
|
|
||||||
const isLoading = exitingGroups === "loading" || permissions === "loading";
|
const isLoading = exitingGroups === "loading" || permissions === "loading";
|
||||||
if(!isLoading && selectedGroup === undefined)
|
if(!isLoading && selectedGroup === undefined)
|
||||||
props.events.fire_async(props.updateEvent, {
|
props.events.fire_react(props.updateEvent, {
|
||||||
group: (exitingGroups as GroupInfo[]).findIndex(e => e.id === props.defaultGroup) === -1 ? 0 : props.defaultGroup
|
group: (exitingGroups as GroupInfo[]).findIndex(e => e.id === props.defaultGroup) === -1 ? 0 : props.defaultGroup
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatSelect
|
<Select
|
||||||
ref={refSelect}
|
ref={refSelect}
|
||||||
label={props.label}
|
label={props.label}
|
||||||
className={props.className}
|
className={props.className}
|
||||||
|
@ -103,7 +103,7 @@ const GroupSelector = (props: { events: Registry<GroupPermissionCopyModalEvents>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</FlatSelect>
|
</Select>
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -138,8 +138,8 @@ class ModalGroupPermissionCopy extends InternalModal {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onInitialize() {
|
protected onInitialize() {
|
||||||
this.events.fire_async("query_available_groups");
|
this.events.fire_react("query_available_groups");
|
||||||
this.events.fire_async("query_client_permissions");
|
this.events.fire_react("query_client_permissions");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onDestroy() {
|
protected onDestroy() {
|
||||||
|
@ -191,7 +191,7 @@ function initializeGroupPermissionCopyController(connection: ConnectionHandler,
|
||||||
events.on("query_available_groups", event => {
|
events.on("query_available_groups", event => {
|
||||||
const groups = target === "server" ? connection.groups.serverGroups : connection.groups.channelGroups;
|
const groups = target === "server" ? connection.groups.serverGroups : connection.groups.channelGroups;
|
||||||
|
|
||||||
events.fire_async("query_available_groups_result", {
|
events.fire_react("query_available_groups_result", {
|
||||||
groups: groups.map(e => {
|
groups: groups.map(e => {
|
||||||
return {
|
return {
|
||||||
name: e.name,
|
name: e.name,
|
||||||
|
@ -202,7 +202,7 @@ function initializeGroupPermissionCopyController(connection: ConnectionHandler,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const notifyClientPermissions = () => events.fire_async("notify_client_permissions", {
|
const notifyClientPermissions = () => events.fire_react("notify_client_permissions", {
|
||||||
createQueryGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_QUERYGROUP).granted(1),
|
createQueryGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_QUERYGROUP).granted(1),
|
||||||
createTemplateGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_TEMPLATES).granted(1)
|
createTemplateGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_TEMPLATES).granted(1)
|
||||||
});
|
});
|
||||||
|
|
|
@ -75,7 +75,7 @@ export function openModalNewcomer(): Modal {
|
||||||
|
|
||||||
event_registry.fire("show_step", {step: "welcome"});
|
event_registry.fire("show_step", {step: "welcome"});
|
||||||
modal.open();
|
modal.open();
|
||||||
event_registry.fire_async("modal-shown");
|
event_registry.fire_react("modal-shown");
|
||||||
return modal;
|
return modal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -138,7 +138,7 @@ function initializeStepWelcome(tag: JQuery, event_registry: Registry<EventModalN
|
||||||
event_registry.on("show_step", e => {
|
event_registry.on("show_step", e => {
|
||||||
if (e.step !== "welcome") return;
|
if (e.step !== "welcome") return;
|
||||||
|
|
||||||
event_registry.fire_async("step-status", {allowNextStep: true, allowPreviousStep: true});
|
event_registry.fire_react("step-status", {allowNextStep: true, allowPreviousStep: true});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -146,7 +146,7 @@ function initializeStepFinish(tag: JQuery, event_registry: Registry<EventModalNe
|
||||||
event_registry.on("show_step", e => {
|
event_registry.on("show_step", e => {
|
||||||
if (e.step !== "finish") return;
|
if (e.step !== "finish") return;
|
||||||
|
|
||||||
event_registry.fire_async("step-status", {allowNextStep: true, allowPreviousStep: true});
|
event_registry.fire_react("step-status", {allowNextStep: true, allowPreviousStep: true});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,7 +159,7 @@ function initializeStepIdentity(tag: JQuery, event_registry: Registry<EventModal
|
||||||
let stepShown = false;
|
let stepShown = false;
|
||||||
let help_animation_done = false;
|
let help_animation_done = false;
|
||||||
const update_step_status = () => {
|
const update_step_status = () => {
|
||||||
event_registry.fire_async("step-status", {
|
event_registry.fire_react("step-status", {
|
||||||
allowNextStep: help_animation_done,
|
allowNextStep: help_animation_done,
|
||||||
allowPreviousStep: help_animation_done
|
allowPreviousStep: help_animation_done
|
||||||
});
|
});
|
||||||
|
@ -344,7 +344,7 @@ function initializeStepMicrophone(tag: JQuery, event_registry: Registry<EventMod
|
||||||
let stepShown = false;
|
let stepShown = false;
|
||||||
|
|
||||||
const settingEvents = new Registry<MicrophoneSettingsEvents>();
|
const settingEvents = new Registry<MicrophoneSettingsEvents>();
|
||||||
settingEvents.on("query_help", () => settingEvents.fire_async("notify_highlight", {field: helpStep <= 2 ? ("hs-" + helpStep) as any : undefined}));
|
settingEvents.on("query_help", () => settingEvents.fire_react("notify_highlight", {field: helpStep <= 2 ? ("hs-" + helpStep) as any : undefined}));
|
||||||
settingEvents.on("action_help_click", () => {
|
settingEvents.on("action_help_click", () => {
|
||||||
if (!stepShown) {
|
if (!stepShown) {
|
||||||
return;
|
return;
|
||||||
|
@ -353,7 +353,7 @@ function initializeStepMicrophone(tag: JQuery, event_registry: Registry<EventMod
|
||||||
helpStep++;
|
helpStep++;
|
||||||
settingEvents.fire("query_help");
|
settingEvents.fire("query_help");
|
||||||
|
|
||||||
event_registry.fire_async("step-status", {allowNextStep: helpStep > 2, allowPreviousStep: helpStep > 2})
|
event_registry.fire_react("step-status", {allowNextStep: helpStep > 2, allowPreviousStep: helpStep > 2})
|
||||||
});
|
});
|
||||||
event_registry.on("action-next-help", () => settingEvents.fire("action_help_click"));
|
event_registry.on("action-next-help", () => settingEvents.fire("action_help_click"));
|
||||||
|
|
||||||
|
@ -372,6 +372,6 @@ function initializeStepMicrophone(tag: JQuery, event_registry: Registry<EventMod
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event_registry.fire_async("step-status", {allowNextStep: helpStep > 2, allowPreviousStep: helpStep > 2});
|
event_registry.fire_react("step-status", {allowNextStep: helpStep > 2, allowPreviousStep: helpStep > 2});
|
||||||
});
|
});
|
||||||
}
|
}
|
|
@ -694,7 +694,7 @@ export namespace modal_settings {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initialize_identity_profiles_controller(event_registry: Registry<events.modal.settings.profiles>) {
|
export function initialize_identity_profiles_controller(event_registry: Registry<events.modal.settings.profiles>) {
|
||||||
const send_error = (event, profile, text) => event_registry.fire_async(event, {
|
const send_error = (event, profile, text) => event_registry.fire_react(event, {
|
||||||
status: "error",
|
status: "error",
|
||||||
profile_id: profile,
|
profile_id: profile,
|
||||||
error: text
|
error: text
|
||||||
|
@ -702,7 +702,7 @@ export namespace modal_settings {
|
||||||
event_registry.on("create-profile", event => {
|
event_registry.on("create-profile", event => {
|
||||||
const profile = profiles.createConnectProfile(event.name);
|
const profile = profiles.createConnectProfile(event.name);
|
||||||
profiles.mark_need_save();
|
profiles.mark_need_save();
|
||||||
event_registry.fire_async("create-profile-result", {
|
event_registry.fire_react("create-profile-result", {
|
||||||
status: "success",
|
status: "success",
|
||||||
name: event.name,
|
name: event.name,
|
||||||
profile_id: profile.id
|
profile_id: profile.id
|
||||||
|
@ -718,7 +718,7 @@ export namespace modal_settings {
|
||||||
}
|
}
|
||||||
|
|
||||||
profiles.delete_profile(profile);
|
profiles.delete_profile(profile);
|
||||||
event_registry.fire_async("delete-profile-result", {status: "success", profile_id: event.profile_id});
|
event_registry.fire_react("delete-profile-result", {status: "success", profile_id: event.profile_id});
|
||||||
});
|
});
|
||||||
|
|
||||||
const build_profile_info = (profile: ConnectionProfile) => {
|
const build_profile_info = (profile: ConnectionProfile) => {
|
||||||
|
@ -746,7 +746,7 @@ export namespace modal_settings {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
event_registry.on("query-profile-list", event => {
|
event_registry.on("query-profile-list", event => {
|
||||||
event_registry.fire_async("query-profile-list-result", {
|
event_registry.fire_react("query-profile-list-result", {
|
||||||
status: "success",
|
status: "success",
|
||||||
profiles: profiles.availableConnectProfiles().map(e => build_profile_info(e))
|
profiles: profiles.availableConnectProfiles().map(e => build_profile_info(e))
|
||||||
});
|
});
|
||||||
|
@ -760,7 +760,7 @@ export namespace modal_settings {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event_registry.fire_async("query-profile-result", {
|
event_registry.fire_react("query-profile-result", {
|
||||||
status: "success",
|
status: "success",
|
||||||
profile_id: event.profile_id,
|
profile_id: event.profile_id,
|
||||||
info: build_profile_info(profile)
|
info: build_profile_info(profile)
|
||||||
|
@ -776,7 +776,7 @@ export namespace modal_settings {
|
||||||
}
|
}
|
||||||
|
|
||||||
const old = profiles.set_default_profile(profile);
|
const old = profiles.set_default_profile(profile);
|
||||||
event_registry.fire_async("set-default-profile-result", {
|
event_registry.fire_react("set-default-profile-result", {
|
||||||
status: "success",
|
status: "success",
|
||||||
old_profile_id: event.profile_id,
|
old_profile_id: event.profile_id,
|
||||||
new_profile_id: old.id
|
new_profile_id: old.id
|
||||||
|
@ -793,7 +793,7 @@ export namespace modal_settings {
|
||||||
|
|
||||||
profile.profileName = event.name;
|
profile.profileName = event.name;
|
||||||
profiles.mark_need_save();
|
profiles.mark_need_save();
|
||||||
event_registry.fire_async("set-profile-name-result", {
|
event_registry.fire_react("set-profile-name-result", {
|
||||||
name: event.name,
|
name: event.name,
|
||||||
profile_id: event.profile_id,
|
profile_id: event.profile_id,
|
||||||
status: "success"
|
status: "success"
|
||||||
|
@ -810,7 +810,7 @@ export namespace modal_settings {
|
||||||
|
|
||||||
profile.defaultUsername = event.name;
|
profile.defaultUsername = event.name;
|
||||||
profiles.mark_need_save();
|
profiles.mark_need_save();
|
||||||
event_registry.fire_async("set-default-name-result", {
|
event_registry.fire_react("set-default-name-result", {
|
||||||
name: event.name,
|
name: event.name,
|
||||||
profile_id: event.profile_id,
|
profile_id: event.profile_id,
|
||||||
status: "success"
|
status: "success"
|
||||||
|
@ -831,7 +831,7 @@ export namespace modal_settings {
|
||||||
identity.set_name(event.name);
|
identity.set_name(event.name);
|
||||||
profiles.mark_need_save();
|
profiles.mark_need_save();
|
||||||
|
|
||||||
event_registry.fire_async("set-identity-name-name-result", {
|
event_registry.fire_react("set-identity-name-name-result", {
|
||||||
name: event.name,
|
name: event.name,
|
||||||
profile_id: event.profile_id,
|
profile_id: event.profile_id,
|
||||||
status: "success"
|
status: "success"
|
||||||
|
@ -846,7 +846,7 @@ export namespace modal_settings {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event_registry.fire_async("query-profile-validity-result", {
|
event_registry.fire_react("query-profile-validity-result", {
|
||||||
status: "success",
|
status: "success",
|
||||||
profile_id: event.profile_id,
|
profile_id: event.profile_id,
|
||||||
valid: profile.valid()
|
valid: profile.valid()
|
||||||
|
@ -863,7 +863,7 @@ export namespace modal_settings {
|
||||||
|
|
||||||
const ts = profile.selectedIdentity(IdentitifyType.TEAMSPEAK) as TeaSpeakIdentity;
|
const ts = profile.selectedIdentity(IdentitifyType.TEAMSPEAK) as TeaSpeakIdentity;
|
||||||
if (!ts) {
|
if (!ts) {
|
||||||
event_registry.fire_async("query-identity-teamspeak-result", {
|
event_registry.fire_react("query-identity-teamspeak-result", {
|
||||||
status: "error",
|
status: "error",
|
||||||
profile_id: event.profile_id,
|
profile_id: event.profile_id,
|
||||||
error: tr("Missing identity")
|
error: tr("Missing identity")
|
||||||
|
@ -872,7 +872,7 @@ export namespace modal_settings {
|
||||||
}
|
}
|
||||||
|
|
||||||
ts.level().then(level => {
|
ts.level().then(level => {
|
||||||
event_registry.fire_async("query-identity-teamspeak-result", {
|
event_registry.fire_react("query-identity-teamspeak-result", {
|
||||||
status: "success",
|
status: "success",
|
||||||
level: level,
|
level: level,
|
||||||
profile_id: event.profile_id
|
profile_id: event.profile_id
|
||||||
|
@ -911,7 +911,7 @@ export namespace modal_settings {
|
||||||
profiles.mark_need_save();
|
profiles.mark_need_save();
|
||||||
|
|
||||||
identity.level().then(level => {
|
identity.level().then(level => {
|
||||||
event_registry.fire_async("generate-identity-teamspeak-result", {
|
event_registry.fire_react("generate-identity-teamspeak-result", {
|
||||||
status: "success",
|
status: "success",
|
||||||
profile_id: event.profile_id,
|
profile_id: event.profile_id,
|
||||||
unique_id: identity.uid(),
|
unique_id: identity.uid(),
|
||||||
|
@ -942,7 +942,7 @@ export namespace modal_settings {
|
||||||
console.error(tr("Failed to calculate level for a new imported identity. Error object: %o"), error);
|
console.error(tr("Failed to calculate level for a new imported identity. Error object: %o"), error);
|
||||||
return Promise.resolve(undefined);
|
return Promise.resolve(undefined);
|
||||||
}).then(level => {
|
}).then(level => {
|
||||||
event_registry.fire_async("import-identity-teamspeak-result", {
|
event_registry.fire_react("import-identity-teamspeak-result", {
|
||||||
profile_id: event.profile_id,
|
profile_id: event.profile_id,
|
||||||
unique_id: identity.uid(),
|
unique_id: identity.uid(),
|
||||||
level: level
|
level: level
|
||||||
|
@ -965,7 +965,7 @@ export namespace modal_settings {
|
||||||
profiles.mark_need_save();
|
profiles.mark_need_save();
|
||||||
|
|
||||||
identity.level().then(level => {
|
identity.level().then(level => {
|
||||||
event_registry.fire_async("improve-identity-teamspeak-level-update", {
|
event_registry.fire_react("improve-identity-teamspeak-level-update", {
|
||||||
profile_id: event.profile_id,
|
profile_id: event.profile_id,
|
||||||
new_level: level
|
new_level: level
|
||||||
});
|
});
|
||||||
|
@ -1533,7 +1533,7 @@ export namespace modal_settings {
|
||||||
event_registry.on("import-identity-teamspeak-result", event => {
|
event_registry.on("import-identity-teamspeak-result", event => {
|
||||||
if (event.profile_id !== current_profile) return;
|
if (event.profile_id !== current_profile) return;
|
||||||
|
|
||||||
event_registry.fire_async("query-profile", {profile_id: event.profile_id}); /* we do it like this so the default nickname changes as well */
|
event_registry.fire_react("query-profile", {profile_id: event.profile_id}); /* we do it like this so the default nickname changes as well */
|
||||||
createInfoModal(tr("Identity imported"), tr("Your identity had been successfully imported generated")).open();
|
createInfoModal(tr("Identity imported"), tr("Your identity had been successfully imported generated")).open();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1582,7 +1582,7 @@ export namespace modal_settings {
|
||||||
container_invalid.toggle(!valid);
|
container_invalid.toggle(!valid);
|
||||||
});
|
});
|
||||||
|
|
||||||
button_setup.on('click', event => event_registry.fire_async("setup-forum-connection"));
|
button_setup.on('click', event => event_registry.fire_react("setup-forum-connection"));
|
||||||
button_setup.toggle(settings.forum_setuppable);
|
button_setup.toggle(settings.forum_setuppable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -177,7 +177,7 @@ export function spawnModalCssVariableEditor() {
|
||||||
|
|
||||||
function cssVariableEditorController(events: Registry<CssEditorEvents>) {
|
function cssVariableEditorController(events: Registry<CssEditorEvents>) {
|
||||||
events.on("query_css_variables", () => {
|
events.on("query_css_variables", () => {
|
||||||
events.fire_async("notify_css_variables", {
|
events.fire_react("notify_css_variables", {
|
||||||
variables: cssVariableManager.getAllCssVariables()
|
variables: cssVariableManager.getAllCssVariables()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
@ -191,7 +191,7 @@ function cssVariableEditorController(events: Registry<CssEditorEvents>) {
|
||||||
});
|
});
|
||||||
|
|
||||||
events.on("action_export", event => {
|
events.on("action_export", event => {
|
||||||
events.fire_async("notify_export_result", {
|
events.fire_react("notify_export_result", {
|
||||||
config: cssVariableManager.exportConfig(event.allValues)
|
config: cssVariableManager.exportConfig(event.allValues)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -199,25 +199,25 @@ function cssVariableEditorController(events: Registry<CssEditorEvents>) {
|
||||||
events.on("action_import", event => {
|
events.on("action_import", event => {
|
||||||
try {
|
try {
|
||||||
cssVariableManager.importConfig(event.config);
|
cssVariableManager.importConfig(event.config);
|
||||||
events.fire_async("notify_import_result", {success: true});
|
events.fire_react("notify_import_result", {success: true});
|
||||||
events.fire_async("action_select_entry", {variable: undefined});
|
events.fire_react("action_select_entry", {variable: undefined});
|
||||||
events.fire_async("query_css_variables");
|
events.fire_react("query_css_variables");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to import CSS variable values: %o", error);
|
console.warn("Failed to import CSS variable values: %o", error);
|
||||||
events.fire_async("notify_import_result", {success: false});
|
events.fire_react("notify_import_result", {success: false});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
events.on("action_reset", () => {
|
events.on("action_reset", () => {
|
||||||
cssVariableManager.reset();
|
cssVariableManager.reset();
|
||||||
events.fire_async("action_select_entry", {variable: undefined});
|
events.fire_react("action_select_entry", {variable: undefined});
|
||||||
events.fire_async("query_css_variables");
|
events.fire_react("query_css_variables");
|
||||||
});
|
});
|
||||||
|
|
||||||
events.on("action_randomize", () => {
|
events.on("action_randomize", () => {
|
||||||
cssVariableManager.randomize();
|
cssVariableManager.randomize();
|
||||||
events.fire_async("action_select_entry", {variable: undefined});
|
events.fire_react("action_select_entry", {variable: undefined});
|
||||||
events.fire_async("query_css_variables");
|
events.fire_react("query_css_variables");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -63,7 +63,7 @@ const CssVariableRenderer = React.memo((props: { events: Registry<CssEditorEvent
|
||||||
|
|
||||||
const CssVariableListBodyRenderer = (props: { events: Registry<CssEditorEvents> }) => {
|
const CssVariableListBodyRenderer = (props: { events: Registry<CssEditorEvents> }) => {
|
||||||
const [variables, setVariables] = useState<"loading" | CssVariable[]>(() => {
|
const [variables, setVariables] = useState<"loading" | CssVariable[]>(() => {
|
||||||
props.events.fire_async("query_css_variables");
|
props.events.fire_react("query_css_variables");
|
||||||
return "loading";
|
return "loading";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -61,11 +61,11 @@ function initializeController(connection: ConnectionHandler, events: Registry<Ec
|
||||||
});
|
});
|
||||||
|
|
||||||
events.on("query_test_state", () => {
|
events.on("query_test_state", () => {
|
||||||
events.fire_async("notify_tests_toggle", {enabled: settings.global(Settings.KEY_VOICE_ECHO_TEST_ENABLED)});
|
events.fire_react("notify_tests_toggle", {enabled: settings.global(Settings.KEY_VOICE_ECHO_TEST_ENABLED)});
|
||||||
});
|
});
|
||||||
|
|
||||||
events.on("notify_destroy", settings.globalChangeListener(Settings.KEY_VOICE_ECHO_TEST_ENABLED, value => {
|
events.on("notify_destroy", settings.globalChangeListener(Settings.KEY_VOICE_ECHO_TEST_ENABLED, value => {
|
||||||
events.fire_async("notify_tests_toggle", {enabled: value});
|
events.fire_react("notify_tests_toggle", {enabled: value});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
events.on("action_test_result", event => {
|
events.on("action_test_result", event => {
|
||||||
|
@ -127,7 +127,7 @@ function initializeController(connection: ConnectionHandler, events: Registry<Ec
|
||||||
events.on("query_voice_connection_state", () => reportVoiceConnectionState(connection.getServerConnection().getVoiceConnection().getConnectionState()));
|
events.on("query_voice_connection_state", () => reportVoiceConnectionState(connection.getServerConnection().getVoiceConnection().getConnectionState()));
|
||||||
|
|
||||||
events.on("query_test_state", () => {
|
events.on("query_test_state", () => {
|
||||||
events.fire_async("notify_test_state", {state: testState});
|
events.fire_react("notify_test_state", {state: testState});
|
||||||
});
|
});
|
||||||
|
|
||||||
events.on("action_start_test", () => {
|
events.on("action_start_test", () => {
|
||||||
|
|
|
@ -3,7 +3,6 @@ import {ModalGlobalSettingsEditor} from "tc-shared/ui/modal/global-settings-edit
|
||||||
import {Registry} from "tc-shared/events";
|
import {Registry} from "tc-shared/events";
|
||||||
import {ModalGlobalSettingsEditorEvents, Setting} from "tc-shared/ui/modal/global-settings-editor/Definitions";
|
import {ModalGlobalSettingsEditorEvents, Setting} from "tc-shared/ui/modal/global-settings-editor/Definitions";
|
||||||
import {ConfigValueTypes, settings, Settings, SettingsKey} from "tc-shared/settings";
|
import {ConfigValueTypes, settings, Settings, SettingsKey} from "tc-shared/settings";
|
||||||
import {key} from "tc-shared/KeyControl";
|
|
||||||
|
|
||||||
export function spawnGlobalSettingsEditor() {
|
export function spawnGlobalSettingsEditor() {
|
||||||
const events = new Registry<ModalGlobalSettingsEditorEvents>();
|
const events = new Registry<ModalGlobalSettingsEditorEvents>();
|
||||||
|
@ -31,7 +30,7 @@ function initializeController(events: Registry<ModalGlobalSettingsEditorEvents>)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
events.fire_async("notify_settings", { settings: settingsList });
|
events.fire_react("notify_settings", { settings: settingsList });
|
||||||
});
|
});
|
||||||
|
|
||||||
events.on("action_select_setting", event => {
|
events.on("action_select_setting", event => {
|
||||||
|
@ -70,12 +69,12 @@ function initializeController(events: Registry<ModalGlobalSettingsEditorEvents>)
|
||||||
/* the change will may already trigger a notify_setting_value, but just to ensure we're fiering it later as well */
|
/* the change will may already trigger a notify_setting_value, but just to ensure we're fiering it later as well */
|
||||||
settings.changeGlobal(setting, event.value);
|
settings.changeGlobal(setting, event.value);
|
||||||
|
|
||||||
events.fire_async("notify_setting_value", { setting: event.setting, value: event.value });
|
events.fire_react("notify_setting_value", { setting: event.setting, value: event.value });
|
||||||
});
|
});
|
||||||
|
|
||||||
events.on("notify_destroy", settings.events.on("notify_setting_changed", event => {
|
events.on("notify_destroy", settings.events.on("notify_setting_changed", event => {
|
||||||
if(event.mode === "global") {
|
if(event.mode === "global") {
|
||||||
events.fire_async("notify_setting_value", { setting: event.setting, value: event.newValue });
|
events.fire_react("notify_setting_value", { setting: event.setting, value: event.newValue });
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
|
@ -410,7 +410,7 @@ const stringifyError = error => {
|
||||||
function initializePermissionModalController(connection: ConnectionHandler, events: Registry<PermissionModalEvents>) {
|
function initializePermissionModalController(connection: ConnectionHandler, events: Registry<PermissionModalEvents>) {
|
||||||
events.on("query_groups", event => {
|
events.on("query_groups", event => {
|
||||||
const groups = event.target === "server" ? connection.groups.serverGroups : connection.groups.channelGroups;
|
const groups = event.target === "server" ? connection.groups.serverGroups : connection.groups.channelGroups;
|
||||||
events.fire_async("query_groups_result", {
|
events.fire_react("query_groups_result", {
|
||||||
target: event.target, groups: groups.map(group => {
|
target: event.target, groups: groups.map(group => {
|
||||||
return {
|
return {
|
||||||
id: group.id,
|
id: group.id,
|
||||||
|
@ -706,7 +706,7 @@ function initializePermissionModalController(connection: ConnectionHandler, even
|
||||||
}));
|
}));
|
||||||
|
|
||||||
events.on("query_channels", () => {
|
events.on("query_channels", () => {
|
||||||
events.fire_async("query_channels_result", {
|
events.fire_react("query_channels_result", {
|
||||||
channels: connection.channelTree.channelsOrdered().map(e => {
|
channels: connection.channelTree.channelsOrdered().map(e => {
|
||||||
return {
|
return {
|
||||||
id: e.channelId,
|
id: e.channelId,
|
||||||
|
@ -888,7 +888,7 @@ function initializePermissionEditor(connection: ConnectionHandler, modalEvents:
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
events.fire_async("query_permission_list_result", {
|
events.fire_react("query_permission_list_result", {
|
||||||
hideSenselessPermissions: !settings.static_global(Settings.KEY_PERMISSIONS_SHOW_ALL),
|
hideSenselessPermissions: !settings.static_global(Settings.KEY_PERMISSIONS_SHOW_ALL),
|
||||||
permissions: (groups || []).map(visitGroup)
|
permissions: (groups || []).map(visitGroup)
|
||||||
});
|
});
|
||||||
|
|
|
@ -1025,7 +1025,7 @@ const ClientSelect = (props: { events: Registry<PermissionModalEvents>, tabTarge
|
||||||
refInput.current.setValue(typeof event.id === "undefined" ? "" : event.id.toString());
|
refInput.current.setValue(typeof event.id === "undefined" ? "" : event.id.toString());
|
||||||
if (typeof event.id === "number" || typeof event.id === "string") {
|
if (typeof event.id === "number" || typeof event.id === "string") {
|
||||||
/* first do the state update */
|
/* first do the state update */
|
||||||
props.events.fire_async("query_client_info", {client: event.id});
|
props.events.fire_react("query_client_info", {client: event.id});
|
||||||
} else {
|
} else {
|
||||||
refInput.current?.setValue(undefined);
|
refInput.current?.setValue(undefined);
|
||||||
resetInfoFields(undefined);
|
resetInfoFields(undefined);
|
||||||
|
|
|
@ -356,7 +356,7 @@ function initialize_timeouts(event_registry: Registry<KeyMapEvents>) {
|
||||||
|
|
||||||
function initialize_controller(event_registry: Registry<KeyMapEvents>) {
|
function initialize_controller(event_registry: Registry<KeyMapEvents>) {
|
||||||
event_registry.on("query_keymap", event => {
|
event_registry.on("query_keymap", event => {
|
||||||
event_registry.fire_async("query_keymap_result", {
|
event_registry.fire_react("query_keymap_result", {
|
||||||
status: "success",
|
status: "success",
|
||||||
action: event.action,
|
action: event.action,
|
||||||
key: keycontrol.key(event.action)
|
key: keycontrol.key(event.action)
|
||||||
|
@ -366,10 +366,10 @@ function initialize_controller(event_registry: Registry<KeyMapEvents>) {
|
||||||
event_registry.on("set_keymap", event => {
|
event_registry.on("set_keymap", event => {
|
||||||
try {
|
try {
|
||||||
keycontrol.set_key(event.action, event.key);
|
keycontrol.set_key(event.action, event.key);
|
||||||
event_registry.fire_async("set_keymap_result", {status: "success", action: event.action, key: event.key});
|
event_registry.fire_react("set_keymap_result", {status: "success", action: event.action, key: event.key});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to change key for action %s: %o", event.action, error);
|
console.warn("Failed to change key for action %s: %o", event.action, error);
|
||||||
event_registry.fire_async("set_keymap_result", {
|
event_registry.fire_react("set_keymap_result", {
|
||||||
status: "error",
|
status: "error",
|
||||||
action: event.action,
|
action: event.action,
|
||||||
error: error instanceof Error ? error.message : error?.toString()
|
error: error instanceof Error ? error.message : error?.toString()
|
||||||
|
|
|
@ -159,21 +159,21 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
|
||||||
{
|
{
|
||||||
events.on("query_devices", event => {
|
events.on("query_devices", event => {
|
||||||
if (!aplayer.initialized()) {
|
if (!aplayer.initialized()) {
|
||||||
events.fire_async("notify_devices", {status: "audio-not-initialized"});
|
events.fire_react("notify_devices", {status: "audio-not-initialized"});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceList = recorderBackend.getDeviceList();
|
const deviceList = recorderBackend.getDeviceList();
|
||||||
switch (deviceList.getStatus()) {
|
switch (deviceList.getStatus()) {
|
||||||
case "no-permissions":
|
case "no-permissions":
|
||||||
events.fire_async("notify_devices", {
|
events.fire_react("notify_devices", {
|
||||||
status: "no-permissions",
|
status: "no-permissions",
|
||||||
shouldAsk: deviceList.getPermissionState() === "denied"
|
shouldAsk: deviceList.getPermissionState() === "denied"
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case "uninitialized":
|
case "uninitialized":
|
||||||
events.fire_async("notify_devices", {status: "audio-not-initialized"});
|
events.fire_react("notify_devices", {status: "audio-not-initialized"});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,7 +184,7 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
|
||||||
} else {
|
} else {
|
||||||
const devices = deviceList.getDevices();
|
const devices = deviceList.getDevices();
|
||||||
|
|
||||||
events.fire_async("notify_devices", {
|
events.fire_react("notify_devices", {
|
||||||
status: "success",
|
status: "success",
|
||||||
selectedDevice: defaultRecorder.getDeviceId(),
|
selectedDevice: defaultRecorder.getDeviceId(),
|
||||||
devices: devices.map(e => {
|
devices: devices.map(e => {
|
||||||
|
@ -197,7 +197,7 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
|
||||||
events.on("action_set_selected_device", event => {
|
events.on("action_set_selected_device", event => {
|
||||||
const device = recorderBackend.getDeviceList().getDevices().find(e => e.deviceId === event.deviceId);
|
const device = recorderBackend.getDeviceList().getDevices().find(e => e.deviceId === event.deviceId);
|
||||||
if (!device && event.deviceId !== IDevice.NoDeviceId) {
|
if (!device && event.deviceId !== IDevice.NoDeviceId) {
|
||||||
events.fire_async("action_set_selected_device_result", {
|
events.fire_react("action_set_selected_device_result", {
|
||||||
status: "error",
|
status: "error",
|
||||||
error: tr("Invalid device id"),
|
error: tr("Invalid device id"),
|
||||||
deviceId: defaultRecorder.getDeviceId()
|
deviceId: defaultRecorder.getDeviceId()
|
||||||
|
@ -207,10 +207,10 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
|
||||||
|
|
||||||
defaultRecorder.setDevice(device).then(() => {
|
defaultRecorder.setDevice(device).then(() => {
|
||||||
console.debug(tr("Changed default microphone device to %s"), event.deviceId);
|
console.debug(tr("Changed default microphone device to %s"), event.deviceId);
|
||||||
events.fire_async("action_set_selected_device_result", {status: "success", deviceId: event.deviceId});
|
events.fire_react("action_set_selected_device_result", {status: "success", deviceId: event.deviceId});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
log.warn(LogCategory.AUDIO, tr("Failed to change microphone to device %s: %o"), device ? device.deviceId : IDevice.NoDeviceId, error);
|
log.warn(LogCategory.AUDIO, tr("Failed to change microphone to device %s: %o"), device ? device.deviceId : IDevice.NoDeviceId, error);
|
||||||
events.fire_async("action_set_selected_device_result", {status: "success", deviceId: event.deviceId});
|
events.fire_react("action_set_selected_device_result", {status: "success", deviceId: event.deviceId});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -252,7 +252,7 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
events.fire_async("notify_setting", {setting: event.setting, value: value});
|
events.fire_react("notify_setting", {setting: event.setting, value: value});
|
||||||
});
|
});
|
||||||
|
|
||||||
events.on("action_set_setting", event => {
|
events.on("action_set_setting", event => {
|
||||||
|
@ -310,7 +310,7 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
events.fire_async("notify_setting", {setting: event.setting, value: event.value});
|
events.fire_react("notify_setting", {setting: event.setting, value: event.value});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -320,7 +320,7 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
|
||||||
if (result === "granted") {
|
if (result === "granted") {
|
||||||
/* we've nothing to do, the device change event will already update out list */
|
/* we've nothing to do, the device change event will already update out list */
|
||||||
} else {
|
} else {
|
||||||
events.fire_async("notify_devices", {status: "no-permissions", shouldAsk: result === "denied"});
|
events.fire_react("notify_devices", {status: "no-permissions", shouldAsk: result === "denied"});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
@ -335,7 +335,7 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
|
||||||
|
|
||||||
if (!aplayer.initialized()) {
|
if (!aplayer.initialized()) {
|
||||||
aplayer.on_ready(() => {
|
aplayer.on_ready(() => {
|
||||||
events.fire_async("query_devices");
|
events.fire_react("query_devices");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -100,7 +100,7 @@ const EventTableHeader = (props: { focus: boolean }) => {
|
||||||
|
|
||||||
const EventTableEntry = React.memo((props: { events: Registry<NotificationSettingsEvents>, event: string, depth: number, focusEnabled: boolean }) => {
|
const EventTableEntry = React.memo((props: { events: Registry<NotificationSettingsEvents>, event: string, depth: number, focusEnabled: boolean }) => {
|
||||||
const [name, setName] = useState(() => {
|
const [name, setName] = useState(() => {
|
||||||
props.events.fire_async("query_event_info", {key: props.event});
|
props.events.fire_react("query_event_info", {key: props.event});
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
const [notificationState, setNotificationState] = useState<NotificationState>("unavailable");
|
const [notificationState, setNotificationState] = useState<NotificationState>("unavailable");
|
||||||
|
@ -306,7 +306,7 @@ const EventFilter = (props: { events: Registry<NotificationSettingsEvents> }) =>
|
||||||
className={cssStyle.input}
|
className={cssStyle.input}
|
||||||
label={<Translatable>Filter Events</Translatable>}
|
label={<Translatable>Filter Events</Translatable>}
|
||||||
labelType={"floating"}
|
labelType={"floating"}
|
||||||
onChange={text => props.events.fire_async("action_set_filter", {filter: text})}
|
onChange={text => props.events.fire_react("action_set_filter", {filter: text})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -437,13 +437,13 @@ function initializeController(events: Registry<NotificationSettingsEvents>) {
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
events.fire_async("notify_events", {
|
events.fire_react("notify_events", {
|
||||||
groups: knownEventGroups.map(groupMapper).filter(e => !!e),
|
groups: knownEventGroups.map(groupMapper).filter(e => !!e),
|
||||||
focusEnabled: __build.target === "client"
|
focusEnabled: __build.target === "client"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
events.on("query_event_info", event => {
|
events.on("query_event_info", event => {
|
||||||
events.fire_async("notify_event_info", {
|
events.fire_react("notify_event_info", {
|
||||||
key: event.key,
|
key: event.key,
|
||||||
name: groupNames[event.key] || event.key,
|
name: groupNames[event.key] || event.key,
|
||||||
log: settings.global(Settings.FN_EVENTS_LOG_ENABLED(event.key), true) ? "enabled" : "disabled",
|
log: settings.global(Settings.FN_EVENTS_LOG_ENABLED(event.key), true) ? "enabled" : "disabled",
|
||||||
|
@ -467,7 +467,7 @@ function initializeController(events: Registry<NotificationSettingsEvents>) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
events.fire_async("notify_set_state_result", {
|
events.fire_react("notify_set_state_result", {
|
||||||
key: event.key,
|
key: event.key,
|
||||||
state: event.state,
|
state: event.state,
|
||||||
value: event.value
|
value: event.value
|
||||||
|
|
|
@ -1000,7 +1000,7 @@ export class FileBrowser extends ReactComponentBase<FileListTableProperties, Fil
|
||||||
});
|
});
|
||||||
|
|
||||||
/* fire_async because our children have to render first in order to have the row selected! */
|
/* fire_async because our children have to render first in order to have the row selected! */
|
||||||
this.forceUpdate(() => this.props.events.fire_async("action_select_files", {
|
this.forceUpdate(() => this.props.events.fire_react("action_select_files", {
|
||||||
files: [{
|
files: [{
|
||||||
name: name,
|
name: name,
|
||||||
type: FileType.DIRECTORY
|
type: FileType.DIRECTORY
|
||||||
|
|
|
@ -79,13 +79,13 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
try {
|
try {
|
||||||
const info = parsePath(event.path, connection);
|
const info = parsePath(event.path, connection);
|
||||||
|
|
||||||
events.fire_async("action_navigate_to_result", {
|
events.fire_react("action_navigate_to_result", {
|
||||||
path: event.path || "/",
|
path: event.path || "/",
|
||||||
status: "success",
|
status: "success",
|
||||||
pathInfo: info
|
pathInfo: info
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
events.fire_async("action_navigate_to_result", {
|
events.fire_react("action_navigate_to_result", {
|
||||||
path: event.path,
|
path: event.path,
|
||||||
status: "error",
|
status: "error",
|
||||||
error: error
|
error: error
|
||||||
|
@ -98,7 +98,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
try {
|
try {
|
||||||
path = parsePath(event.path, connection);
|
path = parsePath(event.path, connection);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
events.fire_async("query_files_result", {
|
events.fire_react("query_files_result", {
|
||||||
path: event.path,
|
path: event.path,
|
||||||
status: "error",
|
status: "error",
|
||||||
error: error
|
error: error
|
||||||
|
@ -213,7 +213,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
} as ListedFileInfo;
|
} as ListedFileInfo;
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
events.fire_async("query_files_result", {
|
events.fire_react("query_files_result", {
|
||||||
path: event.path,
|
path: event.path,
|
||||||
status: "error",
|
status: "error",
|
||||||
error: tr("Unknown parsed path type")
|
error: tr("Unknown parsed path type")
|
||||||
|
@ -222,7 +222,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
}
|
}
|
||||||
|
|
||||||
request.then(files => {
|
request.then(files => {
|
||||||
events.fire_async("query_files_result", {
|
events.fire_react("query_files_result", {
|
||||||
path: event.path,
|
path: event.path,
|
||||||
status: "success",
|
status: "success",
|
||||||
files: files.map(e => {
|
files: files.map(e => {
|
||||||
|
@ -235,14 +235,14 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
if (error instanceof CommandResult) {
|
if (error instanceof CommandResult) {
|
||||||
if (error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
|
if (error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
|
||||||
const permission = connection.permissions.resolveInfo(error.json["failed_permid"] as number);
|
const permission = connection.permissions.resolveInfo(error.json["failed_permid"] as number);
|
||||||
events.fire_async("query_files_result", {
|
events.fire_react("query_files_result", {
|
||||||
path: event.path,
|
path: event.path,
|
||||||
status: "no-permissions",
|
status: "no-permissions",
|
||||||
error: permission ? permission.name : "unknown"
|
error: permission ? permission.name : "unknown"
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} else if (error.id === 781) { //Invalid password
|
} else if (error.id === 781) { //Invalid password
|
||||||
events.fire_async("query_files_result", {
|
events.fire_react("query_files_result", {
|
||||||
path: event.path,
|
path: event.path,
|
||||||
status: "invalid-password"
|
status: "invalid-password"
|
||||||
});
|
});
|
||||||
|
@ -257,7 +257,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
message = tr("lookup the console");
|
message = tr("lookup the console");
|
||||||
}
|
}
|
||||||
|
|
||||||
events.fire_async("query_files_result", {
|
events.fire_react("query_files_result", {
|
||||||
path: event.path,
|
path: event.path,
|
||||||
status: "error",
|
status: "error",
|
||||||
error: message
|
error: message
|
||||||
|
@ -267,7 +267,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
|
|
||||||
events.on("action_rename_file", event => {
|
events.on("action_rename_file", event => {
|
||||||
if (event.newPath === event.oldPath && event.newName === event.oldName) {
|
if (event.newPath === event.oldPath && event.newName === event.oldName) {
|
||||||
events.fire_async("action_rename_file_result", {
|
events.fire_react("action_rename_file_result", {
|
||||||
oldPath: event.oldPath,
|
oldPath: event.oldPath,
|
||||||
oldName: event.oldName,
|
oldName: event.oldName,
|
||||||
|
|
||||||
|
@ -285,7 +285,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
if (sourcePath.type !== "channel")
|
if (sourcePath.type !== "channel")
|
||||||
throw tr("Icon/avatars could not be renamed");
|
throw tr("Icon/avatars could not be renamed");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
events.fire_async("action_rename_file_result", {
|
events.fire_react("action_rename_file_result", {
|
||||||
oldPath: event.oldPath,
|
oldPath: event.oldPath,
|
||||||
oldName: event.oldName,
|
oldName: event.oldName,
|
||||||
status: "error",
|
status: "error",
|
||||||
|
@ -298,7 +298,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
if (sourcePath.type !== "channel")
|
if (sourcePath.type !== "channel")
|
||||||
throw tr("Target path isn't a channel");
|
throw tr("Target path isn't a channel");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
events.fire_async("action_rename_file_result", {
|
events.fire_react("action_rename_file_result", {
|
||||||
oldPath: event.oldPath,
|
oldPath: event.oldPath,
|
||||||
oldName: event.oldName,
|
oldName: event.oldName,
|
||||||
status: "error",
|
status: "error",
|
||||||
|
@ -335,7 +335,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
if (error instanceof CommandResult) {
|
if (error instanceof CommandResult) {
|
||||||
if (error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
|
if (error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
|
||||||
const permission = connection.permissions.resolveInfo(error.json["failed_permid"] as number);
|
const permission = connection.permissions.resolveInfo(error.json["failed_permid"] as number);
|
||||||
events.fire_async("action_rename_file_result", {
|
events.fire_react("action_rename_file_result", {
|
||||||
oldPath: event.oldPath,
|
oldPath: event.oldPath,
|
||||||
oldName: event.oldName,
|
oldName: event.oldName,
|
||||||
status: "error",
|
status: "error",
|
||||||
|
@ -343,7 +343,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} else if (error.id === 781) { //Invalid password
|
} else if (error.id === 781) { //Invalid password
|
||||||
events.fire_async("action_rename_file_result", {
|
events.fire_react("action_rename_file_result", {
|
||||||
oldPath: event.oldPath,
|
oldPath: event.oldPath,
|
||||||
oldName: event.oldName,
|
oldName: event.oldName,
|
||||||
status: "error",
|
status: "error",
|
||||||
|
@ -359,7 +359,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
log.error(LogCategory.FILE_TRANSFER, tr("Failed to rename/move files: %o"), error);
|
log.error(LogCategory.FILE_TRANSFER, tr("Failed to rename/move files: %o"), error);
|
||||||
message = tr("lookup the console");
|
message = tr("lookup the console");
|
||||||
}
|
}
|
||||||
events.fire_async("action_rename_file_result", {
|
events.fire_react("action_rename_file_result", {
|
||||||
oldPath: event.oldPath,
|
oldPath: event.oldPath,
|
||||||
oldName: event.oldName,
|
oldName: event.oldName,
|
||||||
status: "error",
|
status: "error",
|
||||||
|
@ -532,7 +532,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
let message;
|
let message;
|
||||||
if (result instanceof CommandResult) {
|
if (result instanceof CommandResult) {
|
||||||
if (result.bulks.length !== fileInfos.length) {
|
if (result.bulks.length !== fileInfos.length) {
|
||||||
events.fire_async("action_delete_file_result", {
|
events.fire_react("action_delete_file_result", {
|
||||||
results: fileInfos.map((e) => {
|
results: fileInfos.map((e) => {
|
||||||
return {
|
return {
|
||||||
error: result.bulks.length === 1 ? (result.message + (result.extra_message ? " (" + result.extra_message + ")" : "")) : tr("Response contained invalid bulk length"),
|
error: result.bulks.length === 1 ? (result.message + (result.extra_message ? " (" + result.extra_message + ")" : "")) : tr("Response contained invalid bulk length"),
|
||||||
|
@ -582,7 +582,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
return;
|
return;
|
||||||
});
|
});
|
||||||
|
|
||||||
events.fire_async("action_delete_file_result", {
|
events.fire_react("action_delete_file_result", {
|
||||||
results: results
|
results: results
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
@ -593,7 +593,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
message = tr("lookup the console");
|
message = tr("lookup the console");
|
||||||
}
|
}
|
||||||
|
|
||||||
events.fire_async("action_delete_file_result", {
|
events.fire_react("action_delete_file_result", {
|
||||||
results: files.map((e) => {
|
results: files.map((e) => {
|
||||||
return {
|
return {
|
||||||
error: message,
|
error: message,
|
||||||
|
@ -605,7 +605,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
events.fire_async("action_delete_file_result", {
|
events.fire_react("action_delete_file_result", {
|
||||||
results: files.map((e) => {
|
results: files.map((e) => {
|
||||||
return {
|
return {
|
||||||
error: tr("Failed to parse path for one or more entries ") + " (" + error + ")",
|
error: tr("Failed to parse path for one or more entries ") + " (" + error + ")",
|
||||||
|
@ -625,7 +625,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
if (path.type !== "channel")
|
if (path.type !== "channel")
|
||||||
throw tr("Directories could only created for channels");
|
throw tr("Directories could only created for channels");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
events.fire_async("action_create_directory_result", {
|
events.fire_react("action_create_directory_result", {
|
||||||
name: event.name,
|
name: event.name,
|
||||||
path: event.path,
|
path: event.path,
|
||||||
status: "error",
|
status: "error",
|
||||||
|
@ -646,7 +646,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
if (error instanceof CommandResult) {
|
if (error instanceof CommandResult) {
|
||||||
if (error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
|
if (error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
|
||||||
const permission = connection.permissions.resolveInfo(error.json["failed_permid"] as number);
|
const permission = connection.permissions.resolveInfo(error.json["failed_permid"] as number);
|
||||||
events.fire_async("action_create_directory_result", {
|
events.fire_react("action_create_directory_result", {
|
||||||
name: event.name,
|
name: event.name,
|
||||||
path: event.path,
|
path: event.path,
|
||||||
status: "error",
|
status: "error",
|
||||||
|
@ -654,7 +654,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} else if (error.id === 781) { //Invalid password
|
} else if (error.id === 781) { //Invalid password
|
||||||
events.fire_async("action_create_directory_result", {
|
events.fire_react("action_create_directory_result", {
|
||||||
name: event.name,
|
name: event.name,
|
||||||
path: event.path,
|
path: event.path,
|
||||||
status: "error",
|
status: "error",
|
||||||
|
@ -670,7 +670,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
log.error(LogCategory.FILE_TRANSFER, tr("Failed to create directory: %o"), error);
|
log.error(LogCategory.FILE_TRANSFER, tr("Failed to create directory: %o"), error);
|
||||||
message = tr("lookup the console");
|
message = tr("lookup the console");
|
||||||
}
|
}
|
||||||
events.fire_async("action_create_directory_result", {
|
events.fire_react("action_create_directory_result", {
|
||||||
name: event.name,
|
name: event.name,
|
||||||
path: event.path,
|
path: event.path,
|
||||||
status: "error",
|
status: "error",
|
||||||
|
|
|
@ -90,7 +90,7 @@ export const initializeTransferInfoController = (connection: ConnectionHandler,
|
||||||
} as TransferInfoData;
|
} as TransferInfoData;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
events.fire_async("query_transfer_result", {
|
events.fire_react("query_transfer_result", {
|
||||||
status: "success",
|
status: "success",
|
||||||
transfers: transfers,
|
transfers: transfers,
|
||||||
showFinished: settings.global(Settings.KEY_TRANSFERS_SHOW_FINISHED)
|
showFinished: settings.global(Settings.KEY_TRANSFERS_SHOW_FINISHED)
|
||||||
|
|
174
shared/js/ui/modal/video-source/Controller.tsx
Normal file
174
shared/js/ui/modal/video-source/Controller.tsx
Normal file
|
@ -0,0 +1,174 @@
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
|
||||||
|
import {ModalVideoSourceEvents} from "tc-shared/ui/modal/video-source/Definitions";
|
||||||
|
import {ModalVideoSource} from "tc-shared/ui/modal/video-source/Renderer";
|
||||||
|
import {getVideoDriver, VideoPermissionStatus, VideoSource} from "tc-shared/video/VideoSource";
|
||||||
|
import {LogCategory, logError} from "tc-shared/log";
|
||||||
|
|
||||||
|
type VideoSourceRef = { source: VideoSource };
|
||||||
|
export async function spawnVideoSourceSelectModal() : Promise<VideoSource> {
|
||||||
|
const refSource: VideoSourceRef = {
|
||||||
|
source: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = new Registry<ModalVideoSourceEvents>();
|
||||||
|
events.enableDebug("video-source-select");
|
||||||
|
initializeController(events, refSource);
|
||||||
|
|
||||||
|
const modal = spawnReactModal(ModalVideoSource, events);
|
||||||
|
modal.events.on("destroy", () => {
|
||||||
|
events.fire("notify_destroy");
|
||||||
|
events.destroy();
|
||||||
|
});
|
||||||
|
events.on(["action_start", "action_cancel"], () => {
|
||||||
|
modal.destroy();
|
||||||
|
});
|
||||||
|
modal.show().then(undefined);
|
||||||
|
|
||||||
|
await new Promise(resolve => {
|
||||||
|
modal.events.one(["destroy", "close"], resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
return refSource.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeController(events: Registry<ModalVideoSourceEvents>, currentSourceRef: VideoSourceRef) {
|
||||||
|
let currentSource: VideoSource | string;
|
||||||
|
let currentSourceId: string;
|
||||||
|
let fallbackCurrentSourceName: string;
|
||||||
|
|
||||||
|
const notifyStartButton = () => {
|
||||||
|
events.fire_react("notify_start_button", { enabled: typeof currentSource === "object" })
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifyDeviceList = () => {
|
||||||
|
const driver = getVideoDriver();
|
||||||
|
driver.getDevices().then(devices => {
|
||||||
|
if(devices === false) {
|
||||||
|
if(driver.getPermissionStatus() === VideoPermissionStatus.SystemDenied) {
|
||||||
|
events.fire_react("notify_device_list", { status: { status: "error", reason: "no-permissions" } });
|
||||||
|
} else {
|
||||||
|
events.fire_react("notify_device_list", { status: { status: "error", reason: "request-permissions" } });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
events.fire_react("notify_device_list", {
|
||||||
|
status: {
|
||||||
|
status: "success",
|
||||||
|
devices: devices.map(e => { return { id: e.id, displayName: e.name }}),
|
||||||
|
selectedDeviceId: currentSourceId,
|
||||||
|
fallbackSelectedDeviceName: fallbackCurrentSourceName
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyCurrentSource = () => {
|
||||||
|
const driver = getVideoDriver();
|
||||||
|
switch (driver.getPermissionStatus()) {
|
||||||
|
case VideoPermissionStatus.SystemDenied:
|
||||||
|
events.fire_react("notify_video_preview", { status: { status: "error", reason: "no-permissions" }});
|
||||||
|
break;
|
||||||
|
case VideoPermissionStatus.UserDenied:
|
||||||
|
events.fire_react("notify_video_preview", { status: { status: "error", reason: "request-permissions" }});
|
||||||
|
break;
|
||||||
|
case VideoPermissionStatus.Granted:
|
||||||
|
if(typeof currentSource === "string") {
|
||||||
|
events.fire_react("notify_video_preview", { status: {
|
||||||
|
status: "error",
|
||||||
|
reason: "custom",
|
||||||
|
message: currentSource
|
||||||
|
}});
|
||||||
|
} else if(currentSource) {
|
||||||
|
events.fire_react("notify_video_preview", { status: {
|
||||||
|
status: "preview",
|
||||||
|
stream: currentSource.getStream()
|
||||||
|
}});
|
||||||
|
} else {
|
||||||
|
events.fire_react("notify_video_preview", { status: { status: "none" }});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCurrentSource = (source: VideoSource | string | undefined) => {
|
||||||
|
if(typeof currentSource === "object") {
|
||||||
|
currentSource.deref();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(typeof source === "object") {
|
||||||
|
currentSourceRef.source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSource = source;
|
||||||
|
notifyCurrentSource();
|
||||||
|
notifyStartButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
events.on("query_device_list", () => notifyDeviceList());
|
||||||
|
events.on("query_video_preview", () => notifyCurrentSource());
|
||||||
|
events.on("query_start_button", () => notifyStartButton());
|
||||||
|
|
||||||
|
events.on("action_request_permissions", () => {
|
||||||
|
getVideoDriver().requestPermissions().then(result => {
|
||||||
|
if(typeof result === "object") {
|
||||||
|
currentSourceId = result.getId() + " --";
|
||||||
|
fallbackCurrentSourceName = result.getName();
|
||||||
|
notifyDeviceList();
|
||||||
|
|
||||||
|
setCurrentSource(result);
|
||||||
|
} else {
|
||||||
|
/* the device list will already be updated due to the notify_permissions_changed event */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on("action_select_source", event => {
|
||||||
|
const driver = getVideoDriver();
|
||||||
|
|
||||||
|
currentSourceId = event.id;
|
||||||
|
fallbackCurrentSourceName = tr("loading...");
|
||||||
|
notifyDeviceList();
|
||||||
|
|
||||||
|
driver.createVideoSource(event.id).then(stream => {
|
||||||
|
setCurrentSource(stream);
|
||||||
|
fallbackCurrentSourceName = stream.getName();
|
||||||
|
}).catch(error => {
|
||||||
|
fallbackCurrentSourceName = "invalid device";
|
||||||
|
if(typeof error === "string") {
|
||||||
|
setCurrentSource(error);
|
||||||
|
} else {
|
||||||
|
logError(LogCategory.GENERAL, tr("Failed to open video device %s: %o"), event.id, error);
|
||||||
|
setCurrentSource(tr("Failed to open video device (Lookup the console)"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on("action_cancel", () => {
|
||||||
|
if(typeof currentSource === "object") {
|
||||||
|
currentSourceRef.source = undefined;
|
||||||
|
currentSource.deref();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on("notify_destroy", getVideoDriver().getEvents().on("notify_permissions_changed", () => {
|
||||||
|
if(getVideoDriver().getPermissionStatus() !== VideoPermissionStatus.Granted) {
|
||||||
|
currentSourceId = undefined;
|
||||||
|
fallbackCurrentSourceName = undefined;
|
||||||
|
notifyDeviceList();
|
||||||
|
|
||||||
|
/* implicitly updates the start button */
|
||||||
|
setCurrentSource(undefined);
|
||||||
|
} else {
|
||||||
|
notifyDeviceList();
|
||||||
|
notifyCurrentSource();
|
||||||
|
notifyStartButton();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
events.on("notify_destroy", () => {
|
||||||
|
if(typeof currentSource === "object" && currentSourceRef.source !== currentSource) {
|
||||||
|
currentSource.deref();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
37
shared/js/ui/modal/video-source/Definitions.ts
Normal file
37
shared/js/ui/modal/video-source/Definitions.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
export type DeviceListResult = {
|
||||||
|
status: "success",
|
||||||
|
devices: { id: string, displayName: string }[],
|
||||||
|
selectedDeviceId: string | undefined,
|
||||||
|
fallbackSelectedDeviceName: string | undefined
|
||||||
|
} | {
|
||||||
|
status: "error",
|
||||||
|
reason: "no-permissions" | "request-permissions" | "custom"
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VideoPreviewStatus = {
|
||||||
|
status: "preview",
|
||||||
|
stream: MediaStream /* Attention: This makes this window non popoutable! */
|
||||||
|
} | {
|
||||||
|
status: "error",
|
||||||
|
reason: "no-permissions" | "request-permissions" | "custom",
|
||||||
|
message?: string
|
||||||
|
} | {
|
||||||
|
status: "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModalVideoSourceEvents {
|
||||||
|
action_cancel: {},
|
||||||
|
action_start: {},
|
||||||
|
action_request_permissions: {},
|
||||||
|
action_select_source: { id: string },
|
||||||
|
|
||||||
|
query_device_list: {},
|
||||||
|
query_video_preview: {},
|
||||||
|
query_start_button: {},
|
||||||
|
|
||||||
|
notify_device_list: { status: DeviceListResult },
|
||||||
|
notify_video_preview: { status: VideoPreviewStatus },
|
||||||
|
notify_start_button: { enabled: boolean },
|
||||||
|
|
||||||
|
notify_destroy: {}
|
||||||
|
}
|
126
shared/js/ui/modal/video-source/Renderer.scss
Normal file
126
shared/js/ui/modal/video-source/Renderer.scss
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
@import "../../../../css/static/mixin";
|
||||||
|
@import "../../../../css/static/properties";
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
padding: 1em;
|
||||||
|
|
||||||
|
@include user-select(none);
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
z-index: 10;
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
background-color: var(--modal-content-background);
|
||||||
|
|
||||||
|
&.noPermissions {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
|
||||||
|
.head {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
align-self: flex-end;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #e0e0e0;
|
||||||
|
|
||||||
|
@include text-dotdotdot();
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
.selectError {
|
||||||
|
color: #a10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoContainer {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
width: 37.5em; /* 600px for 16px/em */
|
||||||
|
height: 25em; /* 400px for 16px/em */
|
||||||
|
|
||||||
|
border-radius: .2em;
|
||||||
|
border: 1px solid var(--boxed-input-field-border);
|
||||||
|
background-color: var(--boxed-input-field-background);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
video {
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
z-index: 10;
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
background-color: var(--boxed-input-field-background);
|
||||||
|
|
||||||
|
&.shown {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.permissions {
|
||||||
|
.text {
|
||||||
|
font-size: 1.2em;
|
||||||
|
padding-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
width: min-content;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 1.2em;
|
||||||
|
color: #a10000;
|
||||||
|
padding-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
font-size: 1.8em;
|
||||||
|
padding-bottom: 1em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4d4d4d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
246
shared/js/ui/modal/video-source/Renderer.tsx
Normal file
246
shared/js/ui/modal/video-source/Renderer.tsx
Normal file
|
@ -0,0 +1,246 @@
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
DeviceListResult,
|
||||||
|
ModalVideoSourceEvents,
|
||||||
|
VideoPreviewStatus
|
||||||
|
} from "tc-shared/ui/modal/video-source/Definitions";
|
||||||
|
import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller";
|
||||||
|
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||||
|
import {Select} from "tc-shared/ui/react-elements/InputField";
|
||||||
|
import {Button} from "tc-shared/ui/react-elements/Button";
|
||||||
|
import {useContext, useEffect, useRef, useState} from "react";
|
||||||
|
|
||||||
|
const cssStyle = require("./Renderer.scss");
|
||||||
|
const ModalEvents = React.createContext<Registry<ModalVideoSourceEvents>>(undefined);
|
||||||
|
const kNoDeviceId = "__no__device";
|
||||||
|
|
||||||
|
const VideoSourceBody = () => {
|
||||||
|
const events = useContext(ModalEvents);
|
||||||
|
const [ deviceList, setDeviceList ] = useState<DeviceListResult | "loading">(() => {
|
||||||
|
events.fire("query_device_list");
|
||||||
|
return "loading";
|
||||||
|
});
|
||||||
|
|
||||||
|
events.reactUse("notify_device_list", event => setDeviceList(event.status));
|
||||||
|
|
||||||
|
if(deviceList === "loading") {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.body} key={"loading"}>
|
||||||
|
<Select type={"boxed"} disabled={true}>
|
||||||
|
<option>{tr("loading ...")}</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if(deviceList.status === "error") {
|
||||||
|
let message;
|
||||||
|
switch (deviceList.reason) {
|
||||||
|
case "no-permissions":
|
||||||
|
message = tr("Missing device query permissions");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "request-permissions":
|
||||||
|
message = tr("Please grant video device permissions");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "custom":
|
||||||
|
message = tr("An error happened");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.body} key={"error"}>
|
||||||
|
<Select type={"boxed"} disabled={true} className={cssStyle.selectError}>
|
||||||
|
<option>{message}</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.body} key={"normal"}>
|
||||||
|
<Select
|
||||||
|
type={"boxed"}
|
||||||
|
value={deviceList.selectedDeviceId || kNoDeviceId}
|
||||||
|
onChange={event => events.fire("action_select_source", { id: event.target.value })}
|
||||||
|
>
|
||||||
|
<option key={kNoDeviceId} value={kNoDeviceId} style={{ display: "none" }}>{tr("No device")}</option>
|
||||||
|
{deviceList.devices.map(device => <option value={device.id} key={device.id}>{device.displayName}</option>)}
|
||||||
|
{deviceList.devices.findIndex(device => device.id === deviceList.selectedDeviceId) === -1 ?
|
||||||
|
<option key={"selected-device-" + deviceList.selectedDeviceId} style={{ display: "none" }} value={deviceList.selectedDeviceId}>
|
||||||
|
{deviceList.fallbackSelectedDeviceName}
|
||||||
|
</option> :
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const VideoPreviewMessage = (props: { message: any, kind: "info" | "error" }) => {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.overlay + " " + (props.message ? cssStyle.shown : "")}>
|
||||||
|
<div className={cssStyle[props.kind]}>
|
||||||
|
{props.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoRequestPermissions = (props: { systemDenied: boolean }) => {
|
||||||
|
const events = useContext(ModalEvents);
|
||||||
|
|
||||||
|
let body;
|
||||||
|
let button;
|
||||||
|
if(props.systemDenied) {
|
||||||
|
body = (
|
||||||
|
<div className={cssStyle.text} key={"system-denied"}>
|
||||||
|
<Translatable>Camara access has been denied by your browser.<br />Please allow camara access in order to broadcast video.</Translatable>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
button = <Translatable key={"retry"}>Retry to query</Translatable>;
|
||||||
|
} else {
|
||||||
|
body = (
|
||||||
|
<div className={cssStyle.text} key={"user-denied"}>
|
||||||
|
<Translatable>In order to be able to broadcast video,<br /> you have to allow camara access.</Translatable>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
button = <Translatable key={"request"}>Request permissions</Translatable>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.overlay + " " + cssStyle.shown + " " + cssStyle.permissions}>
|
||||||
|
{body}
|
||||||
|
<Button
|
||||||
|
type={"normal"}
|
||||||
|
color={"green"}
|
||||||
|
className={cssStyle.button}
|
||||||
|
onClick={() => events.fire("action_request_permissions")}
|
||||||
|
>
|
||||||
|
{button}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoPreview = () => {
|
||||||
|
const events = useContext(ModalEvents);
|
||||||
|
|
||||||
|
const refVideo = useRef<HTMLVideoElement>();
|
||||||
|
const [ status, setStatus ] = useState<VideoPreviewStatus | "loading">(() => {
|
||||||
|
events.fire("query_video_preview");
|
||||||
|
return "loading";
|
||||||
|
});
|
||||||
|
|
||||||
|
events.reactUse("notify_video_preview", event => {
|
||||||
|
setStatus(event.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
let body;
|
||||||
|
if(status === "loading") {
|
||||||
|
/* Nothing to show */
|
||||||
|
} else {
|
||||||
|
switch (status.status) {
|
||||||
|
case "none":
|
||||||
|
body = <VideoPreviewMessage message={tr("No video source")} kind={"info"} key={"none"} />;
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
if(status.reason === "no-permissions" || status.reason === "request-permissions") {
|
||||||
|
body = <VideoRequestPermissions systemDenied={status.reason === "no-permissions"} key={"permissions"} />;
|
||||||
|
} else {
|
||||||
|
body = <VideoPreviewMessage message={status.message} kind={"error"} key={"error"} />;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "preview":
|
||||||
|
body = (
|
||||||
|
<video
|
||||||
|
key={"preview"}
|
||||||
|
ref={refVideo}
|
||||||
|
autoPlay={true}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stream = status !== "loading" && status.status === "preview" && status.stream;
|
||||||
|
if(stream && refVideo.current) {
|
||||||
|
refVideo.current.srcObject = stream;
|
||||||
|
}
|
||||||
|
}, [status !== "loading" && status.status === "preview" && status.stream])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.body}>
|
||||||
|
<div className={cssStyle.videoContainer}>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ButtonStart = () => {
|
||||||
|
const events = useContext(ModalEvents);
|
||||||
|
const [ enabled, setEnabled ] = useState(() => {
|
||||||
|
events.fire("query_start_button");
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
events.reactUse("notify_start_button", event => setEnabled(event.enabled));
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={"small"}
|
||||||
|
color={"green"}
|
||||||
|
disabled={!enabled}
|
||||||
|
onClick={() => enabled && events.fire("action_start")}
|
||||||
|
>
|
||||||
|
<Translatable>Start</Translatable>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ModalVideoSource extends InternalModal {
|
||||||
|
protected readonly events: Registry<ModalVideoSourceEvents>;
|
||||||
|
|
||||||
|
constructor(events: Registry<ModalVideoSourceEvents>) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.events = events;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBody(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<ModalEvents.Provider value={this.events}>
|
||||||
|
<div className={cssStyle.container}>
|
||||||
|
<div className={cssStyle.content}>
|
||||||
|
<div className={cssStyle.section}>
|
||||||
|
<div className={cssStyle.head}>
|
||||||
|
<Translatable>Select your source</Translatable>
|
||||||
|
</div>
|
||||||
|
<VideoSourceBody />
|
||||||
|
</div>
|
||||||
|
<div className={cssStyle.section}>
|
||||||
|
<div className={cssStyle.head}>
|
||||||
|
<Translatable>Video preview</Translatable>
|
||||||
|
</div>
|
||||||
|
<div className={cssStyle.body}>
|
||||||
|
<VideoPreview />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ /* TODO: All the overlays */ }
|
||||||
|
</div>
|
||||||
|
<div className={cssStyle.buttons}>
|
||||||
|
<Button type={"small"} color={"red"} onClick={() => this.events.fire("action_cancel")}>
|
||||||
|
<Translatable>Cancel</Translatable>
|
||||||
|
</Button>
|
||||||
|
<ButtonStart />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalEvents.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
title(): string | React.ReactElement<Translatable> {
|
||||||
|
return <Translatable>Start video Broadcasting</Translatable>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -35,7 +35,7 @@ html:root {
|
||||||
@include text-dotdotdot();
|
@include text-dotdotdot();
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #0a0a0a;
|
background-color: #121212;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|
|
@ -69,7 +69,7 @@ html:root {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-invalid {
|
&.isInvalid {
|
||||||
background-color: var(--boxed-input-field-invalid-background);
|
background-color: var(--boxed-input-field-invalid-background);
|
||||||
border-color: var(--boxed-input-field-invalid-border);
|
border-color: var(--boxed-input-field-invalid-border);
|
||||||
color: var(--boxed-input-field-invalid-text);
|
color: var(--boxed-input-field-invalid-text);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {ReactElement} from "react";
|
import {ReactElement} from "react";
|
||||||
|
import {AST_Export} from "terser";
|
||||||
|
|
||||||
const cssStyle = require("./InputField.scss");
|
const cssStyle = require("./InputField.scss");
|
||||||
|
|
||||||
|
@ -223,7 +224,9 @@ export class FlatInputField extends React.Component<FlatInputFieldProperties, Fl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface FlatSelectProperties {
|
export interface SelectProperties {
|
||||||
|
type?: "flat" | "boxed";
|
||||||
|
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
|
|
||||||
|
@ -246,14 +249,14 @@ export interface FlatSelectProperties {
|
||||||
onChange?: (event?: React.ChangeEvent<HTMLSelectElement>) => void;
|
onChange?: (event?: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FlatSelectFieldState {
|
export interface SelectFieldState {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
||||||
isInvalid: boolean;
|
isInvalid: boolean;
|
||||||
invalidMessage: string | React.ReactElement;
|
invalidMessage: string | React.ReactElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FlatSelect extends React.Component<FlatSelectProperties, FlatSelectFieldState> {
|
export class Select extends React.Component<SelectProperties, SelectFieldState> {
|
||||||
private refSelect = React.createRef<HTMLSelectElement>();
|
private refSelect = React.createRef<HTMLSelectElement>();
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
|
@ -268,7 +271,7 @@ export class FlatSelect extends React.Component<FlatSelectProperties, FlatSelect
|
||||||
render() {
|
render() {
|
||||||
const disabled = typeof this.state.disabled === "boolean" ? this.state.disabled : typeof this.props.disabled === "boolean" ? this.props.disabled : false;
|
const disabled = typeof this.state.disabled === "boolean" ? this.state.disabled : typeof this.props.disabled === "boolean" ? this.props.disabled : false;
|
||||||
return (
|
return (
|
||||||
<div className={cssStyle.containerFlat + " " + (this.state.isInvalid ? cssStyle.isInvalid : "") + " " + (this.props.className || "")}>
|
<div className={(this.props.type === "boxed" ? cssStyle.containerBoxed : cssStyle.containerFlat) + " " + (this.state.isInvalid ? cssStyle.isInvalid : "") + " " + (this.props.className || "") + " " + cssStyle.noLeftIcon + " " + cssStyle.noRightIcon}>
|
||||||
{this.props.label ?
|
{this.props.label ?
|
||||||
<label className={cssStyle["type-static"] + " " + (this.props.labelClassName || "")}>{this.props.label}</label> : undefined}
|
<label className={cssStyle["type-static"] + " " + (this.props.labelClassName || "")}>{this.props.label}</label> : undefined}
|
||||||
<select
|
<select
|
||||||
|
@ -308,16 +311,6 @@ export class FlatSelect extends React.Component<FlatSelectProperties, FlatSelect
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,8 @@ export interface PopoutIPCMessage {
|
||||||
"hello-controller": { accepted: boolean, message?: string, userData?: any, registries?: string[] },
|
"hello-controller": { accepted: boolean, message?: string, userData?: any, registries?: string[] },
|
||||||
|
|
||||||
"fire-event": {
|
"fire-event": {
|
||||||
type: string;
|
type: "sync" | "react" | "later";
|
||||||
|
eventType: string;
|
||||||
payload: any;
|
payload: any;
|
||||||
callbackId: string;
|
callbackId: string;
|
||||||
registry: string;
|
registry: string;
|
||||||
|
@ -62,19 +63,10 @@ export abstract class EventControllerBase<Type extends "controller" | "popout">
|
||||||
|
|
||||||
private createEventReceiver(key: string) : EventReceiver {
|
private createEventReceiver(key: string) : EventReceiver {
|
||||||
let refThis = this;
|
let refThis = this;
|
||||||
return new class implements EventReceiver {
|
|
||||||
fire<T extends keyof {}>(eventType: T, data?: any[T], overrideTypeKey?: boolean) {
|
|
||||||
if(refThis.omitEventType === eventType && refThis.omitEventData === data) {
|
|
||||||
refThis.omitEventType = undefined;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
refThis.sendIPCMessage("fire-event", { type: eventType, payload: data, callbackId: undefined, registry: key });
|
const fireEvent = (type: "react" | "later", eventType: any, data?: any[], callback?: () => void) => {
|
||||||
}
|
|
||||||
|
|
||||||
fire_async<T extends keyof {}>(eventType: T, data?: any[T], callback?: () => void) {
|
|
||||||
const callbackId = callback ? (++callbackIdIndex) + "-ev-cb" : undefined;
|
const callbackId = callback ? (++callbackIdIndex) + "-ev-cb" : undefined;
|
||||||
refThis.sendIPCMessage("fire-event", { type: eventType, payload: data, callbackId: callbackId, registry: key });
|
refThis.sendIPCMessage("fire-event", { type: type, eventType: eventType, payload: data, callbackId: callbackId, registry: key });
|
||||||
if(callbackId) {
|
if(callbackId) {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
delete refThis.eventFiredListeners[callbackId];
|
delete refThis.eventFiredListeners[callbackId];
|
||||||
|
@ -86,6 +78,24 @@ export abstract class EventControllerBase<Type extends "controller" | "popout">
|
||||||
timeout: timeout
|
timeout: timeout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new class implements EventReceiver {
|
||||||
|
fire<T extends keyof {}>(eventType: T, data?: any[T], overrideTypeKey?: boolean) {
|
||||||
|
if(refThis.omitEventType === eventType && refThis.omitEventData === data) {
|
||||||
|
refThis.omitEventType = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refThis.sendIPCMessage("fire-event", { type: "sync", eventType: eventType, payload: data, callbackId: undefined, registry: key });
|
||||||
|
}
|
||||||
|
|
||||||
|
fire_later<T extends keyof { [p: string]: any }>(eventType: T, data?: { [p: string]: any }[T], callback?: () => void) {
|
||||||
|
fireEvent("later", eventType, data, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
fire_react<T extends keyof {}>(eventType: T, data?: any[T], callback?: () => void) {
|
||||||
|
fireEvent("react", eventType, data, callback);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -107,9 +117,11 @@ export abstract class EventControllerBase<Type extends "controller" | "popout">
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "fire-event": {
|
case "fire-event": {
|
||||||
const tpayload = payload as PopoutIPCMessage["fire-event"];
|
const tpayload = payload as PopoutIPCMessage["fire-event"];
|
||||||
|
|
||||||
|
/* FIXME: Pay respect to the different event types and may bundle react updates! */
|
||||||
this.omitEventData = tpayload.payload;
|
this.omitEventData = tpayload.payload;
|
||||||
this.omitEventType = tpayload.type;
|
this.omitEventType = tpayload.eventType;
|
||||||
this.localRegistries[tpayload.registry].fire(tpayload.type as any, tpayload.payload);
|
this.localRegistries[tpayload.registry].fire(tpayload.eventType, tpayload.payload);
|
||||||
if(tpayload.callbackId)
|
if(tpayload.callbackId)
|
||||||
this.sendIPCMessage("fire-event-callback", { callbackId: tpayload.callbackId });
|
this.sendIPCMessage("fire-event-callback", { callbackId: tpayload.callbackId });
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -345,7 +345,7 @@ class ChannelTreeController {
|
||||||
|
|
||||||
/* notify state update methods */
|
/* notify state update methods */
|
||||||
public sendPopoutState() {
|
public sendPopoutState() {
|
||||||
this.events.fire_async("notify_popout_state", {
|
this.events.fire_react("notify_popout_state", {
|
||||||
showButton: this.options.popoutButton,
|
showButton: this.options.popoutButton,
|
||||||
shown: this.channelTree.popoutController.hasBeenPopedOut()
|
shown: this.channelTree.popoutController.hasBeenPopedOut()
|
||||||
});
|
});
|
||||||
|
@ -378,11 +378,11 @@ class ChannelTreeController {
|
||||||
|
|
||||||
this.channelTree.rootChannel().forEach(entry => buildSubTree(entry, 1));
|
this.channelTree.rootChannel().forEach(entry => buildSubTree(entry, 1));
|
||||||
|
|
||||||
this.events.fire_async("notify_tree_entries", { entries: entries });
|
this.events.fire_react("notify_tree_entries", { entries: entries });
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendChannelInfo(channel: ChannelEntry) {
|
public sendChannelInfo(channel: ChannelEntry) {
|
||||||
this.events.fire_async("notify_channel_info", {
|
this.events.fire_react("notify_channel_info", {
|
||||||
treeEntryId: channel.uniqueEntryId,
|
treeEntryId: channel.uniqueEntryId,
|
||||||
info: {
|
info: {
|
||||||
collapsedState: channel.child_channel_head || channel.channelClientsOrdered().length > 0 ? channel.collapsed ? "collapsed" : "expended" : "unset",
|
collapsedState: channel.child_channel_head || channel.channelClientsOrdered().length > 0 ? channel.collapsed ? "collapsed" : "expended" : "unset",
|
||||||
|
@ -393,7 +393,7 @@ class ChannelTreeController {
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendChannelStatusIcon(channel: ChannelEntry) {
|
public sendChannelStatusIcon(channel: ChannelEntry) {
|
||||||
this.events.fire_async("notify_channel_icon", { icon: channel.getStatusIcon(), treeEntryId: channel.uniqueEntryId });
|
this.events.fire_react("notify_channel_icon", { icon: channel.getStatusIcon(), treeEntryId: channel.uniqueEntryId });
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendChannelIcons(channel: ChannelEntry) {
|
public sendChannelIcons(channel: ChannelEntry) {
|
||||||
|
@ -421,7 +421,7 @@ class ChannelTreeController {
|
||||||
icons.codecUnsupported = true;
|
icons.codecUnsupported = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.events.fire_async("notify_channel_icons", { icons: icons, treeEntryId: channel.uniqueEntryId });
|
this.events.fire_react("notify_channel_icons", { icons: icons, treeEntryId: channel.uniqueEntryId });
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendClientNameInfo(client: ClientEntry) {
|
public sendClientNameInfo(client: ClientEntry) {
|
||||||
|
@ -450,7 +450,7 @@ class ChannelTreeController {
|
||||||
}
|
}
|
||||||
|
|
||||||
const afkMessage = client.properties.client_away ? client.properties.client_away_message : undefined;
|
const afkMessage = client.properties.client_away ? client.properties.client_away_message : undefined;
|
||||||
this.events.fire_async("notify_client_name", {
|
this.events.fire_react("notify_client_name", {
|
||||||
info: {
|
info: {
|
||||||
name: client.clientNickName(),
|
name: client.clientNickName(),
|
||||||
awayMessage: afkMessage,
|
awayMessage: afkMessage,
|
||||||
|
@ -476,7 +476,7 @@ class ChannelTreeController {
|
||||||
.map(group => { return { iconId: group.properties.iconid, groupName: group.name, groupId: group.id, serverUniqueId: uniqueServerId }; });
|
.map(group => { return { iconId: group.properties.iconid, groupName: group.name, groupId: group.id, serverUniqueId: uniqueServerId }; });
|
||||||
|
|
||||||
const clientIcon = client.properties.client_icon_id === 0 ? [] : [client.properties.client_icon_id];
|
const clientIcon = client.properties.client_icon_id === 0 ? [] : [client.properties.client_icon_id];
|
||||||
this.events.fire_async("notify_client_icons", {
|
this.events.fire_react("notify_client_icons", {
|
||||||
icons: {
|
icons: {
|
||||||
serverGroupIcons: serverGroupIcons,
|
serverGroupIcons: serverGroupIcons,
|
||||||
channelGroupIcon: channelGroupIcon[0],
|
channelGroupIcon: channelGroupIcon[0],
|
||||||
|
@ -499,7 +499,7 @@ class ChannelTreeController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.events.fire_async("notify_client_talk_status", { treeEntryId: client.uniqueEntryId, requestMessage: client.properties.client_talk_request_msg, status: status });
|
this.events.fire_react("notify_client_talk_status", { treeEntryId: client.uniqueEntryId, requestMessage: client.properties.client_talk_request_msg, status: status });
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendServerStatus(serverEntry: ServerEntry) {
|
public sendServerStatus(serverEntry: ServerEntry) {
|
||||||
|
@ -529,7 +529,7 @@ class ChannelTreeController {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.events.fire_async("notify_server_state", { treeEntryId: serverEntry.uniqueEntryId, state: status });
|
this.events.fire_react("notify_server_state", { treeEntryId: serverEntry.uniqueEntryId, state: status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -549,7 +549,7 @@ export function initializeChannelTreeController(events: Registry<ChannelTreeUIEv
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
events.fire_async("notify_unread_state", { treeEntryId: event.treeEntryId, unread: entry.isUnread() });
|
events.fire_react("notify_unread_state", { treeEntryId: event.treeEntryId, unread: entry.isUnread() });
|
||||||
});
|
});
|
||||||
|
|
||||||
events.on("query_select_state", event => {
|
events.on("query_select_state", event => {
|
||||||
|
@ -559,7 +559,7 @@ export function initializeChannelTreeController(events: Registry<ChannelTreeUIEv
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
events.fire_async("notify_select_state", { treeEntryId: event.treeEntryId, selected: entry.isSelected() });
|
events.fire_react("notify_select_state", { treeEntryId: event.treeEntryId, selected: entry.isSelected() });
|
||||||
});
|
});
|
||||||
|
|
||||||
events.on("notify_destroy", channelTree.client.events().on("notify_visibility_changed", event => events.fire("notify_visibility_changed", event)));
|
events.on("notify_destroy", channelTree.client.events().on("notify_visibility_changed", event => events.fire("notify_visibility_changed", event)));
|
||||||
|
@ -599,7 +599,7 @@ export function initializeChannelTreeController(events: Registry<ChannelTreeUIEv
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
events.fire_async("notify_client_status", { treeEntryId: entry.uniqueEntryId, status: entry.getStatusIcon() });
|
events.fire_react("notify_client_status", { treeEntryId: entry.uniqueEntryId, status: entry.getStatusIcon() });
|
||||||
});
|
});
|
||||||
events.on("query_client_name", event => {
|
events.on("query_client_name", event => {
|
||||||
const entry = channelTree.findEntryId(event.treeEntryId);
|
const entry = channelTree.findEntryId(event.treeEntryId);
|
||||||
|
@ -785,7 +785,7 @@ export function initializeChannelTreeController(events: Registry<ChannelTreeUIEv
|
||||||
if(selection.findIndex(element => !(element instanceof ClientEntry)) !== -1) { return; }
|
if(selection.findIndex(element => !(element instanceof ClientEntry)) !== -1) { return; }
|
||||||
|
|
||||||
moveSelection = selection as any;
|
moveSelection = selection as any;
|
||||||
events.fire_async("notify_entry_move", { entries: selection.map(client => (client as ClientEntry).clientNickName()).join(", "), begin: event.start, current: event.current });
|
events.fire_react("notify_entry_move", { entries: selection.map(client => (client as ClientEntry).clientNickName()).join(", "), begin: event.start, current: event.current });
|
||||||
});
|
});
|
||||||
|
|
||||||
events.on("action_move_entries", event => {
|
events.on("action_move_entries", event => {
|
||||||
|
|
|
@ -203,7 +203,7 @@ export class RendererClient extends React.Component<{ client: RDPClient }, {}> {
|
||||||
<ClientStatus client={client} ref={client.refStatus} />
|
<ClientStatus client={client} ref={client.refStatus} />
|
||||||
{...(client.rename ? [
|
{...(client.rename ? [
|
||||||
<ClientNameEdit initialName={client.renameDefault} editFinished={value => {
|
<ClientNameEdit initialName={client.renameDefault} editFinished={value => {
|
||||||
events.fire_async("action_client_name_submit", { treeEntryId: client.entryId, name: value });
|
events.fire_react("action_client_name_submit", { treeEntryId: client.entryId, name: value });
|
||||||
}} key={"rename"} />
|
}} key={"rename"} />
|
||||||
] : [
|
] : [
|
||||||
<ClientName client={client} ref={client.refName} key={"name"} />,
|
<ClientName client={client} ref={client.refName} key={"name"} />,
|
||||||
|
|
|
@ -267,7 +267,7 @@ export class RDPChannelTree {
|
||||||
@EventHandler<ChannelTreeUIEvents>("notify_entry_move")
|
@EventHandler<ChannelTreeUIEvents>("notify_entry_move")
|
||||||
private handleNotifyEntryMove(event: ChannelTreeUIEvents["notify_entry_move"]) {
|
private handleNotifyEntryMove(event: ChannelTreeUIEvents["notify_entry_move"]) {
|
||||||
if(!this.refMove.current) {
|
if(!this.refMove.current) {
|
||||||
this.events.fire_async("action_move_entries", { treeEntryId: 0 });
|
this.events.fire_react("action_move_entries", { treeEntryId: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -114,6 +114,6 @@ export class ChannelTreePopoutController {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.uiEvents.fire_async("notify_title", { title: title });
|
this.uiEvents.fire_react("notify_title", { title: title });
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -67,7 +67,7 @@ class VideoViewer {
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.plugin.setLocalWatcherStatus(url, { status: "paused" });
|
this.plugin.setLocalWatcherStatus(url, { status: "paused" });
|
||||||
this.events.fire_async("notify_video", { url: url }); /* notify the new url */
|
this.events.fire_react("notify_video", { url: url }); /* notify the new url */
|
||||||
}
|
}
|
||||||
|
|
||||||
setFollowing(target: W2GWatcher) {
|
setFollowing(target: W2GWatcher) {
|
||||||
|
@ -75,7 +75,7 @@ class VideoViewer {
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.plugin.setLocalFollowing(target, { status: "paused" });
|
this.plugin.setLocalFollowing(target, { status: "paused" });
|
||||||
this.events.fire_async("notify_video", { url: target.getCurrentVideo() }); /* notify the new url */
|
this.events.fire_react("notify_video", { url: target.getCurrentVideo() }); /* notify the new url */
|
||||||
}
|
}
|
||||||
|
|
||||||
async open() {
|
async open() {
|
||||||
|
@ -84,7 +84,7 @@ class VideoViewer {
|
||||||
|
|
||||||
private notifyWatcherList() {
|
private notifyWatcherList() {
|
||||||
const watchers = this.plugin.getCurrentWatchers().filter(e => e.getCurrentVideo() === this.currentVideoUrl);
|
const watchers = this.plugin.getCurrentWatchers().filter(e => e.getCurrentVideo() === this.currentVideoUrl);
|
||||||
this.events.fire_async("notify_watcher_list", {
|
this.events.fire_react("notify_watcher_list", {
|
||||||
followingWatcher: this.plugin.getLocalFollowingWatcher() ? this.plugin.getLocalFollowingWatcher().clientId + " - " + this.plugin.getLocalFollowingWatcher().clientUniqueId : undefined,
|
followingWatcher: this.plugin.getLocalFollowingWatcher() ? this.plugin.getLocalFollowingWatcher().clientId + " - " + this.plugin.getLocalFollowingWatcher().clientUniqueId : undefined,
|
||||||
watcherIds: watchers.map(e => e.clientId + " - " + e.clientUniqueId)
|
watcherIds: watchers.map(e => e.clientId + " - " + e.clientUniqueId)
|
||||||
})
|
})
|
||||||
|
@ -108,7 +108,7 @@ class VideoViewer {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.events.on("notify_destroy", this.plugin.events.on("notify_following_url", event => {
|
this.events.on("notify_destroy", this.plugin.events.on("notify_following_url", event => {
|
||||||
this.events.fire_async("notify_video", { url: event.newUrl });
|
this.events.fire_react("notify_video", { url: event.newUrl });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.events.on("notify_destroy", this.plugin.events.on("notify_following_watcher_status", event => {
|
this.events.on("notify_destroy", this.plugin.events.on("notify_following_watcher_status", event => {
|
||||||
|
@ -138,7 +138,7 @@ class VideoViewer {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
watcherUnregisterCallbacks.push(watcher.events.on("notify_watcher_nickname_changed", () => {
|
watcherUnregisterCallbacks.push(watcher.events.on("notify_watcher_nickname_changed", () => {
|
||||||
this.events.fire_async("notify_watcher_info", {
|
this.events.fire_react("notify_watcher_info", {
|
||||||
watcherId: watcherId,
|
watcherId: watcherId,
|
||||||
|
|
||||||
clientId: watcher.clientId,
|
clientId: watcher.clientId,
|
||||||
|
@ -150,21 +150,21 @@ class VideoViewer {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
watcherUnregisterCallbacks.push(watcher.events.on("notify_watcher_status_changed", event => {
|
watcherUnregisterCallbacks.push(watcher.events.on("notify_watcher_status_changed", event => {
|
||||||
this.events.fire_async("notify_watcher_status", {
|
this.events.fire_react("notify_watcher_status", {
|
||||||
watcherId: watcherId,
|
watcherId: watcherId,
|
||||||
status: event.newStatus
|
status: event.newStatus
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_added", event => {
|
watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_added", event => {
|
||||||
this.events.fire_async("notify_follower_added", {
|
this.events.fire_react("notify_follower_added", {
|
||||||
watcherId: watcherId,
|
watcherId: watcherId,
|
||||||
followerId: followerWatcherId(event.follower)
|
followerId: followerWatcherId(event.follower)
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_nickname_changed", event => {
|
watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_nickname_changed", event => {
|
||||||
this.events.fire_async("notify_watcher_info", {
|
this.events.fire_react("notify_watcher_info", {
|
||||||
watcherId: followerWatcherId(event.follower),
|
watcherId: followerWatcherId(event.follower),
|
||||||
|
|
||||||
clientId: event.follower.clientId,
|
clientId: event.follower.clientId,
|
||||||
|
@ -176,14 +176,14 @@ class VideoViewer {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_status_changed", event => {
|
watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_status_changed", event => {
|
||||||
this.events.fire_async("notify_watcher_status", {
|
this.events.fire_react("notify_watcher_status", {
|
||||||
watcherId: followerWatcherId(event.follower),
|
watcherId: followerWatcherId(event.follower),
|
||||||
status: event.newStatus
|
status: event.newStatus
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_removed", event => {
|
watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_removed", event => {
|
||||||
this.events.fire_async("notify_follower_removed", {
|
this.events.fire_react("notify_follower_removed", {
|
||||||
watcherId: watcherId,
|
watcherId: watcherId,
|
||||||
followerId: followerWatcherId(event.follower)
|
followerId: followerWatcherId(event.follower)
|
||||||
});
|
});
|
||||||
|
@ -200,13 +200,13 @@ class VideoViewer {
|
||||||
const info = parseWatcherId(event.watcherId);
|
const info = parseWatcherId(event.watcherId);
|
||||||
for(const watcher of this.plugin.getCurrentWatchers()) {
|
for(const watcher of this.plugin.getCurrentWatchers()) {
|
||||||
if(watcher.clientId === info.clientId && watcher.clientUniqueId === info.clientUniqueId) {
|
if(watcher.clientId === info.clientId && watcher.clientUniqueId === info.clientUniqueId) {
|
||||||
this.events.fire_async("notify_watcher_status", { watcherId: event.watcherId, status: watcher.getWatcherStatus() });
|
this.events.fire_react("notify_watcher_status", { watcherId: event.watcherId, status: watcher.getWatcherStatus() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for(const follower of watcher.getFollowers()) {
|
for(const follower of watcher.getFollowers()) {
|
||||||
if(follower.clientUniqueId === info.clientUniqueId && follower.clientId === info.clientId) {
|
if(follower.clientUniqueId === info.clientUniqueId && follower.clientId === info.clientId) {
|
||||||
this.events.fire_async("notify_watcher_status", { watcherId: event.watcherId, status: follower.status });
|
this.events.fire_react("notify_watcher_status", { watcherId: event.watcherId, status: follower.status });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -220,7 +220,7 @@ class VideoViewer {
|
||||||
const info = parseWatcherId(event.watcherId);
|
const info = parseWatcherId(event.watcherId);
|
||||||
for(const watcher of this.plugin.getCurrentWatchers()) {
|
for(const watcher of this.plugin.getCurrentWatchers()) {
|
||||||
if(watcher.clientId === info.clientId && watcher.clientUniqueId === info.clientUniqueId) {
|
if(watcher.clientId === info.clientId && watcher.clientUniqueId === info.clientUniqueId) {
|
||||||
this.events.fire_async("notify_watcher_info", {
|
this.events.fire_react("notify_watcher_info", {
|
||||||
watcherId: event.watcherId,
|
watcherId: event.watcherId,
|
||||||
clientName: watcher.getWatcherName(),
|
clientName: watcher.getWatcherName(),
|
||||||
clientUniqueId: watcher.clientUniqueId,
|
clientUniqueId: watcher.clientUniqueId,
|
||||||
|
@ -232,7 +232,7 @@ class VideoViewer {
|
||||||
|
|
||||||
for(const follower of watcher.getFollowers()) {
|
for(const follower of watcher.getFollowers()) {
|
||||||
if(follower.clientUniqueId === info.clientUniqueId && follower.clientId === info.clientId) {
|
if(follower.clientUniqueId === info.clientUniqueId && follower.clientId === info.clientId) {
|
||||||
this.events.fire_async("notify_watcher_info", {
|
this.events.fire_react("notify_watcher_info", {
|
||||||
watcherId: event.watcherId,
|
watcherId: event.watcherId,
|
||||||
clientName: follower.clientNickname,
|
clientName: follower.clientNickname,
|
||||||
clientUniqueId: follower.clientUniqueId,
|
clientUniqueId: follower.clientUniqueId,
|
||||||
|
@ -254,7 +254,7 @@ class VideoViewer {
|
||||||
if(watcher.clientId !== info.clientId || watcher.clientUniqueId !== info.clientUniqueId)
|
if(watcher.clientId !== info.clientId || watcher.clientUniqueId !== info.clientUniqueId)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
this.events.fire_async("notify_follower_list", {
|
this.events.fire_react("notify_follower_list", {
|
||||||
followerIds: watcher.getFollowers().map(e => followerWatcherId(e)),
|
followerIds: watcher.getFollowers().map(e => followerWatcherId(e)),
|
||||||
watcherId: event.watcherId
|
watcherId: event.watcherId
|
||||||
});
|
});
|
||||||
|
@ -266,7 +266,7 @@ class VideoViewer {
|
||||||
|
|
||||||
@EventHandler<VideoViewerEvents>("query_video")
|
@EventHandler<VideoViewerEvents>("query_video")
|
||||||
private handleQueryVideo() {
|
private handleQueryVideo() {
|
||||||
this.events.fire_async("notify_video", { url: this.currentVideoUrl });
|
this.events.fire_react("notify_video", { url: this.currentVideoUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler<VideoViewerEvents>("notify_local_status")
|
@EventHandler<VideoViewerEvents>("notify_local_status")
|
||||||
|
|
|
@ -295,7 +295,7 @@ const PlayerController = React.memo((props: { events: Registry<VideoViewerEvents
|
||||||
|
|
||||||
const [ mode, setMode ] = useState<"watcher" | "follower">("watcher");
|
const [ mode, setMode ] = useState<"watcher" | "follower">("watcher");
|
||||||
const [ videoUrl, setVideoUrl ] = useState<"querying" | string>(() => {
|
const [ videoUrl, setVideoUrl ] = useState<"querying" | string>(() => {
|
||||||
props.events.fire_async("query_video");
|
props.events.fire_react("query_video");
|
||||||
return "querying";
|
return "querying";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -432,7 +432,7 @@ export class W2GPluginCmdHandler extends PluginCmdHandler {
|
||||||
case "notify_destroyed":
|
case "notify_destroyed":
|
||||||
const oldWatcher = this.localFollowing;
|
const oldWatcher = this.localFollowing;
|
||||||
this.localFollowing = undefined;
|
this.localFollowing = undefined;
|
||||||
this.events.fire_async("notify_following_changed", { newWatcher: undefined, oldWatcher: oldWatcher });
|
this.events.fire_react("notify_following_changed", { newWatcher: undefined, oldWatcher: oldWatcher });
|
||||||
this.notifyLocalStatus();
|
this.notifyLocalStatus();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
62
shared/js/video/VideoSource.ts
Normal file
62
shared/js/video/VideoSource.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
|
||||||
|
export interface VideoSource {
|
||||||
|
getId() : string;
|
||||||
|
getName() : string;
|
||||||
|
|
||||||
|
getStream() : MediaStream;
|
||||||
|
|
||||||
|
/** Add a new reference to this stream */
|
||||||
|
ref() : this;
|
||||||
|
|
||||||
|
/** Decrease the reference count. If it's zero, it will be automatically destroyed. */
|
||||||
|
deref();
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum VideoPermissionStatus {
|
||||||
|
Granted,
|
||||||
|
UserDenied,
|
||||||
|
SystemDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoDriverEvents {
|
||||||
|
notify_permissions_changed: { oldStatus: VideoPermissionStatus, newStatus: VideoPermissionStatus },
|
||||||
|
notify_device_list_changed: { devices: string[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VideoDevice = { id: string, name: string }
|
||||||
|
|
||||||
|
export interface VideoDriver {
|
||||||
|
getEvents() : Registry<VideoDriverEvents>;
|
||||||
|
getPermissionStatus() : VideoPermissionStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request permissions to access the video camara and device list.
|
||||||
|
* When requesting permissions, we're actually requesting a media stream.
|
||||||
|
* If the request succeeds, we're returning that media stream.
|
||||||
|
*/
|
||||||
|
requestPermissions() : Promise<VideoSource | boolean>;
|
||||||
|
getDevices() : Promise<VideoDevice[] | false>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws a string if an error occurs
|
||||||
|
* @returns A VideoSource on success with an initial ref count of one
|
||||||
|
* Will throw a string on error
|
||||||
|
*/
|
||||||
|
createVideoSource(id: string) : Promise<VideoSource>;
|
||||||
|
|
||||||
|
createScreenSource() : Promise<VideoSource>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let driverInstance: VideoDriver;
|
||||||
|
export function getVideoDriver() {
|
||||||
|
return driverInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setVideoDriver(driver: VideoDriver) {
|
||||||
|
if(typeof driverInstance !== "undefined") {
|
||||||
|
throw tr("A video driver has already be initiated");
|
||||||
|
}
|
||||||
|
|
||||||
|
driverInstance = driver;
|
||||||
|
}
|
|
@ -40,12 +40,12 @@ export enum InputState {
|
||||||
RECORDING
|
RECORDING
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum InputStartResult {
|
export enum MediaStreamRequestResult {
|
||||||
EOK = "eok",
|
|
||||||
EUNKNOWN = "eunknown",
|
EUNKNOWN = "eunknown",
|
||||||
EDEVICEUNKNOWN = "edeviceunknown",
|
EDEVICEUNKNOWN = "edeviceunknown",
|
||||||
EBUSY = "ebusy",
|
EBUSY = "ebusy",
|
||||||
ENOTALLOWED = "enotallowed",
|
ENOTALLOWED = "enotallowed",
|
||||||
|
ESYSTEMDENIED = "esystemdenied",
|
||||||
ENOTSUPPORTED = "enotsupported"
|
ENOTSUPPORTED = "enotsupported"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,7 +77,7 @@ export interface AbstractInput {
|
||||||
currentState() : InputState;
|
currentState() : InputState;
|
||||||
destroy();
|
destroy();
|
||||||
|
|
||||||
start() : Promise<InputStartResult>;
|
start() : Promise<MediaStreamRequestResult | true>;
|
||||||
stop() : Promise<void>;
|
stop() : Promise<void>;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
7
shared/svg-sprites/client-icons.d.ts
vendored
7
shared/svg-sprites/client-icons.d.ts
vendored
File diff suppressed because one or more lines are too long
|
@ -6,7 +6,7 @@ import {
|
||||||
InputConsumer,
|
InputConsumer,
|
||||||
InputConsumerType,
|
InputConsumerType,
|
||||||
InputEvents,
|
InputEvents,
|
||||||
InputStartResult,
|
MediaStreamRequestResult,
|
||||||
InputState,
|
InputState,
|
||||||
LevelMeter,
|
LevelMeter,
|
||||||
NodeInputConsumer
|
NodeInputConsumer
|
||||||
|
@ -17,6 +17,7 @@ import * as aplayer from "./player";
|
||||||
import {JAbstractFilter, JStateFilter, JThresholdFilter} from "./RecorderFilter";
|
import {JAbstractFilter, JStateFilter, JThresholdFilter} from "./RecorderFilter";
|
||||||
import {Filter, FilterType, FilterTypeClass} from "tc-shared/voice/Filter";
|
import {Filter, FilterType, FilterTypeClass} from "tc-shared/voice/Filter";
|
||||||
import {inputDeviceList} from "tc-backend/web/audio/RecorderDeviceList";
|
import {inputDeviceList} from "tc-backend/web/audio/RecorderDeviceList";
|
||||||
|
import {requestMediaStream} from "tc-backend/web/media/Stream";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface MediaStream {
|
interface MediaStream {
|
||||||
|
@ -28,66 +29,6 @@ export interface WebIDevice extends IDevice {
|
||||||
groupId: string;
|
groupId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestMicrophoneMediaStream(constraints: MediaTrackConstraints, updateDeviceList: boolean) : Promise<InputStartResult | MediaStream> {
|
|
||||||
try {
|
|
||||||
log.info(LogCategory.AUDIO, tr("Requesting a microphone stream for device %s in group %s"), constraints.deviceId, constraints.groupId);
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: constraints });
|
|
||||||
|
|
||||||
if(updateDeviceList && inputDeviceList.getStatus() === "no-permissions") {
|
|
||||||
inputDeviceList.refresh().then(() => {}); /* added the then body to avoid a inspection warning... */
|
|
||||||
}
|
|
||||||
|
|
||||||
return stream;
|
|
||||||
} catch(error) {
|
|
||||||
if('name' in error) {
|
|
||||||
if(error.name === "NotAllowedError") {
|
|
||||||
log.warn(LogCategory.AUDIO, tr("Microphone request failed (No permissions). Browser message: %o"), error.message);
|
|
||||||
return InputStartResult.ENOTALLOWED;
|
|
||||||
} else {
|
|
||||||
log.warn(LogCategory.AUDIO, tr("Microphone request failed. Request resulted in error: %o: %o"), error.name, error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.warn(LogCategory.AUDIO, tr("Failed to initialize recording stream (%o)"), error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return InputStartResult.EUNKNOWN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* request permission for devices only one per time! */
|
|
||||||
let currentMediaStreamRequest: Promise<MediaStream | InputStartResult>;
|
|
||||||
async function requestMediaStream(deviceId: string, groupId: string) : Promise<MediaStream | InputStartResult> {
|
|
||||||
/* wait for the current media stream requests to finish */
|
|
||||||
while(currentMediaStreamRequest) {
|
|
||||||
try {
|
|
||||||
await currentMediaStreamRequest;
|
|
||||||
} catch(error) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioConstrains: MediaTrackConstraints = {};
|
|
||||||
if(window.detectedBrowser?.name === "firefox") {
|
|
||||||
/*
|
|
||||||
* Firefox only allows to open one mic as well deciding whats the input device it.
|
|
||||||
* It does not respect the deviceId nor the groupId
|
|
||||||
*/
|
|
||||||
} else {
|
|
||||||
audioConstrains.deviceId = deviceId;
|
|
||||||
audioConstrains.groupId = groupId;
|
|
||||||
}
|
|
||||||
|
|
||||||
audioConstrains.echoCancellation = true;
|
|
||||||
audioConstrains.autoGainControl = true;
|
|
||||||
audioConstrains.noiseSuppression = true;
|
|
||||||
|
|
||||||
const promise = (currentMediaStreamRequest = requestMicrophoneMediaStream(audioConstrains, true));
|
|
||||||
try {
|
|
||||||
return await currentMediaStreamRequest;
|
|
||||||
} finally {
|
|
||||||
if(currentMediaStreamRequest === promise)
|
|
||||||
currentMediaStreamRequest = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class WebAudioRecorder implements AudioRecorderBacked {
|
export class WebAudioRecorder implements AudioRecorderBacked {
|
||||||
createInput(): AbstractInput {
|
createInput(): AbstractInput {
|
||||||
return new JavascriptInput();
|
return new JavascriptInput();
|
||||||
|
@ -133,7 +74,7 @@ class JavascriptInput implements AbstractInput {
|
||||||
private inputFiltered: boolean = false;
|
private inputFiltered: boolean = false;
|
||||||
private filterMode: FilterMode = FilterMode.Block;
|
private filterMode: FilterMode = FilterMode.Block;
|
||||||
|
|
||||||
private startPromise: Promise<InputStartResult>;
|
private startPromise: Promise<MediaStreamRequestResult | true>;
|
||||||
|
|
||||||
private volumeModifier: number = 1;
|
private volumeModifier: number = 1;
|
||||||
|
|
||||||
|
@ -211,7 +152,7 @@ class JavascriptInput implements AbstractInput {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() : Promise<InputStartResult> {
|
async start() : Promise<MediaStreamRequestResult | true> {
|
||||||
while(this.startPromise) {
|
while(this.startPromise) {
|
||||||
try {
|
try {
|
||||||
await this.startPromise;
|
await this.startPromise;
|
||||||
|
@ -225,7 +166,7 @@ class JavascriptInput implements AbstractInput {
|
||||||
return await (this.startPromise = Promise.resolve().then(() => this.doStart()));
|
return await (this.startPromise = Promise.resolve().then(() => this.doStart()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async doStart() : Promise<InputStartResult> {
|
private async doStart() : Promise<MediaStreamRequestResult | true> {
|
||||||
try {
|
try {
|
||||||
if(this.state != InputState.PAUSED) {
|
if(this.state != InputState.PAUSED) {
|
||||||
throw tr("recorder already started");
|
throw tr("recorder already started");
|
||||||
|
@ -244,7 +185,7 @@ class JavascriptInput implements AbstractInput {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestResult = await requestMediaStream(deviceId, undefined);
|
const requestResult = await requestMediaStream(deviceId, undefined, "audio");
|
||||||
if(!(requestResult instanceof MediaStream)) {
|
if(!(requestResult instanceof MediaStream)) {
|
||||||
this.state = InputState.PAUSED;
|
this.state = InputState.PAUSED;
|
||||||
return requestResult;
|
return requestResult;
|
||||||
|
@ -265,7 +206,7 @@ class JavascriptInput implements AbstractInput {
|
||||||
this.state = InputState.RECORDING;
|
this.state = InputState.RECORDING;
|
||||||
this.updateFilterStatus(true);
|
this.updateFilterStatus(true);
|
||||||
|
|
||||||
return InputStartResult.EOK;
|
return true;
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
if(this.state == InputState.INITIALIZING) {
|
if(this.state == InputState.INITIALIZING) {
|
||||||
this.state = InputState.PAUSED;
|
this.state = InputState.PAUSED;
|
||||||
|
@ -563,15 +504,15 @@ class JavascriptLevelMeter implements LevelMeter {
|
||||||
this._analyse_buffer = new Uint8Array(this._analyser_node.fftSize);
|
this._analyse_buffer = new Uint8Array(this._analyser_node.fftSize);
|
||||||
|
|
||||||
/* starting stream */
|
/* starting stream */
|
||||||
const _result = await requestMediaStream(this._device.deviceId, this._device.groupId);
|
const _result = await requestMediaStream(this._device.deviceId, this._device.groupId, "audio");
|
||||||
if(!(_result instanceof MediaStream)){
|
if(!(_result instanceof MediaStream)){
|
||||||
if(_result === InputStartResult.ENOTALLOWED)
|
if(_result === MediaStreamRequestResult.ENOTALLOWED)
|
||||||
throw tr("No permissions");
|
throw tr("No permissions");
|
||||||
if(_result === InputStartResult.ENOTSUPPORTED)
|
if(_result === MediaStreamRequestResult.ENOTSUPPORTED)
|
||||||
throw tr("Not supported");
|
throw tr("Not supported");
|
||||||
if(_result === InputStartResult.EBUSY)
|
if(_result === MediaStreamRequestResult.EBUSY)
|
||||||
throw tr("Device busy");
|
throw tr("Device busy");
|
||||||
if(_result === InputStartResult.EUNKNOWN)
|
if(_result === MediaStreamRequestResult.EUNKNOWN)
|
||||||
throw tr("an error occurred");
|
throw tr("an error occurred");
|
||||||
throw _result;
|
throw _result;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {LogCategory, logWarn} from "tc-shared/log";
|
||||||
import {Registry} from "tc-shared/events";
|
import {Registry} from "tc-shared/events";
|
||||||
import {WebIDevice} from "tc-backend/web/audio/Recorder";
|
import {WebIDevice} from "tc-backend/web/audio/Recorder";
|
||||||
import * as loader from "tc-loader";
|
import * as loader from "tc-loader";
|
||||||
|
import {queryMediaPermissions} from "tc-backend/web/media/Stream";
|
||||||
|
|
||||||
async function requestMicrophonePermissions() : Promise<PermissionState> {
|
async function requestMicrophonePermissions() : Promise<PermissionState> {
|
||||||
const begin = Date.now();
|
const begin = Date.now();
|
||||||
|
@ -37,24 +38,9 @@ class WebInputDeviceList extends AbstractDeviceList {
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
if('permissions' in navigator && 'query' in navigator.permissions) {
|
const result = await queryMediaPermissions("audio");
|
||||||
try {
|
if(typeof result === "boolean") {
|
||||||
const result = await navigator.permissions.query({ name: "microphone" });
|
this.setPermissionState(result ? "granted" : "denied");
|
||||||
switch (result.state) {
|
|
||||||
case "denied":
|
|
||||||
this.setPermissionState("denied");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "granted":
|
|
||||||
this.setPermissionState("granted");
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logWarn(LogCategory.GENERAL, tr("Failed to query for microphone permissions: %s"), error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,13 +13,15 @@ import * as log from "tc-shared/log";
|
||||||
import {LogCategory, logTrace} from "tc-shared/log";
|
import {LogCategory, logTrace} from "tc-shared/log";
|
||||||
import {Regex} from "tc-shared/ui/modal/ModalConnect";
|
import {Regex} from "tc-shared/ui/modal/ModalConnect";
|
||||||
import {AbstractCommandHandlerBoss} from "tc-shared/connection/AbstractCommandHandler";
|
import {AbstractCommandHandlerBoss} from "tc-shared/connection/AbstractCommandHandler";
|
||||||
import {VoiceConnection} from "../voice/VoiceHandler";
|
|
||||||
import {EventType} from "tc-shared/ui/frames/log/Definitions";
|
import {EventType} from "tc-shared/ui/frames/log/Definitions";
|
||||||
import {WrappedWebSocket} from "tc-backend/web/connection/WrappedWebSocket";
|
import {WrappedWebSocket} from "tc-backend/web/connection/WrappedWebSocket";
|
||||||
import {AbstractVoiceConnection} from "tc-shared/connection/VoiceConnection";
|
import {AbstractVoiceConnection} from "tc-shared/connection/VoiceConnection";
|
||||||
import {DummyVoiceConnection} from "tc-shared/connection/DummyVoiceConnection";
|
|
||||||
import {parseCommand} from "tc-backend/web/connection/CommandParser";
|
import {parseCommand} from "tc-backend/web/connection/CommandParser";
|
||||||
import {ServerAddress} from "tc-shared/tree/Server";
|
import {ServerAddress} from "tc-shared/tree/Server";
|
||||||
|
import {RTCConnection} from "tc-backend/web/rtc/Connection";
|
||||||
|
import {RtpVoiceConnection} from "tc-backend/web/rtc/voice/Connection";
|
||||||
|
import {RtpVideoConnection} from "tc-backend/web/rtc/video/Connection";
|
||||||
|
import {VideoConnection} from "tc-shared/connection/VideoConnection";
|
||||||
|
|
||||||
class ReturnListener<T> {
|
class ReturnListener<T> {
|
||||||
resolve: (value?: T | PromiseLike<T>) => void;
|
resolve: (value?: T | PromiseLike<T>) => void;
|
||||||
|
@ -44,8 +46,9 @@ export class ServerConnection extends AbstractServerConnection {
|
||||||
|
|
||||||
private _connection_state_listener: ConnectionStateListener;
|
private _connection_state_listener: ConnectionStateListener;
|
||||||
|
|
||||||
private dummyVoiceConnection: DummyVoiceConnection;
|
private rtcConnection: RTCConnection;
|
||||||
private voiceConnection: VoiceConnection;
|
private voiceConnection: RtpVoiceConnection;
|
||||||
|
private videoConnection: RtpVideoConnection;
|
||||||
|
|
||||||
private pingStatistics = {
|
private pingStatistics = {
|
||||||
thread_id: 0,
|
thread_id: 0,
|
||||||
|
@ -71,11 +74,9 @@ export class ServerConnection extends AbstractServerConnection {
|
||||||
this.commandHandlerBoss.register_handler(this.defaultCommandHandler);
|
this.commandHandlerBoss.register_handler(this.defaultCommandHandler);
|
||||||
this.command_helper.initialize();
|
this.command_helper.initialize();
|
||||||
|
|
||||||
if(!settings.static_global(Settings.KEY_DISABLE_VOICE, false)) {
|
this.rtcConnection = new RTCConnection(this);
|
||||||
this.voiceConnection = new VoiceConnection(this);
|
this.voiceConnection = new RtpVoiceConnection(this, this.rtcConnection);
|
||||||
} else {
|
this.videoConnection = new RtpVideoConnection(this.rtcConnection);
|
||||||
this.dummyVoiceConnection = new DummyVoiceConnection(this);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
@ -97,6 +98,7 @@ export class ServerConnection extends AbstractServerConnection {
|
||||||
}
|
}
|
||||||
this.returnListeners = undefined;
|
this.returnListeners = undefined;
|
||||||
|
|
||||||
|
this.rtcConnection.destroy();
|
||||||
this.command_helper.destroy();
|
this.command_helper.destroy();
|
||||||
|
|
||||||
this.defaultCommandHandler && this.commandHandlerBoss.unregister_handler(this.defaultCommandHandler);
|
this.defaultCommandHandler && this.commandHandlerBoss.unregister_handler(this.defaultCommandHandler);
|
||||||
|
@ -263,10 +265,10 @@ export class ServerConnection extends AbstractServerConnection {
|
||||||
this.sendData(JSON.stringify({
|
this.sendData(JSON.stringify({
|
||||||
type: "enable-raw-commands"
|
type: "enable-raw-commands"
|
||||||
}))
|
}))
|
||||||
this.start_handshake();
|
this.startHandshake();
|
||||||
}
|
}
|
||||||
|
|
||||||
private start_handshake() {
|
private startHandshake() {
|
||||||
this.updateConnectionState(ConnectionState.INITIALISING);
|
this.updateConnectionState(ConnectionState.INITIALISING);
|
||||||
this.client.log.log(EventType.CONNECTION_LOGIN, {});
|
this.client.log.log(EventType.CONNECTION_LOGIN, {});
|
||||||
this.handshakeHandler.initialize();
|
this.handshakeHandler.initialize();
|
||||||
|
@ -352,8 +354,6 @@ export class ServerConnection extends AbstractServerConnection {
|
||||||
this.doNextPing();
|
this.doNextPing();
|
||||||
this.updateConnectionState(ConnectionState.CONNECTED);
|
this.updateConnectionState(ConnectionState.CONNECTED);
|
||||||
}
|
}
|
||||||
} else if(json["type"] === "WebRTC") {
|
|
||||||
this.voiceConnection?.handleControlPacket(json);
|
|
||||||
} else if(json["type"] === "ping") {
|
} else if(json["type"] === "ping") {
|
||||||
this.sendData(JSON.stringify({
|
this.sendData(JSON.stringify({
|
||||||
type: 'pong',
|
type: 'pong',
|
||||||
|
@ -444,7 +444,11 @@ export class ServerConnection extends AbstractServerConnection {
|
||||||
}
|
}
|
||||||
|
|
||||||
getVoiceConnection(): AbstractVoiceConnection {
|
getVoiceConnection(): AbstractVoiceConnection {
|
||||||
return this.voiceConnection || this.dummyVoiceConnection;
|
return this.voiceConnection /* || this.dummyVoiceConnection; */
|
||||||
|
}
|
||||||
|
|
||||||
|
getVideoConnection(): VideoConnection {
|
||||||
|
return this.videoConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
command_handler_boss(): AbstractCommandHandlerBoss {
|
command_handler_boss(): AbstractCommandHandlerBoss {
|
||||||
|
|
14
web/app/hooks/Video.ts
Normal file
14
web/app/hooks/Video.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import * as loader from "tc-loader";
|
||||||
|
import {Stage} from "tc-loader";
|
||||||
|
import {setVideoDriver} from "tc-shared/video/VideoSource";
|
||||||
|
import {WebVideoDriver} from "tc-backend/web/media/Video";
|
||||||
|
|
||||||
|
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
||||||
|
priority: 10,
|
||||||
|
function: async () => {
|
||||||
|
const instance = new WebVideoDriver();
|
||||||
|
await instance.initialize();
|
||||||
|
setVideoDriver(instance);
|
||||||
|
},
|
||||||
|
name: "Video init"
|
||||||
|
});
|
|
@ -10,6 +10,7 @@ import "./hooks/ServerConnection";
|
||||||
import "./hooks/ExternalModal";
|
import "./hooks/ExternalModal";
|
||||||
import "./hooks/AudioRecorder";
|
import "./hooks/AudioRecorder";
|
||||||
import "./hooks/MenuBar";
|
import "./hooks/MenuBar";
|
||||||
|
import "./hooks/Video";
|
||||||
|
|
||||||
import "./UnloadHandler";
|
import "./UnloadHandler";
|
||||||
|
|
||||||
|
|
110
web/app/media/Stream.ts
Normal file
110
web/app/media/Stream.ts
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import {MediaStreamRequestResult} from "tc-shared/voice/RecorderBase";
|
||||||
|
import * as log from "tc-shared/log";
|
||||||
|
import {LogCategory, logWarn} from "tc-shared/log";
|
||||||
|
import {inputDeviceList} from "tc-backend/web/audio/RecorderDeviceList";
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
|
||||||
|
export type MediaStreamType = "audio" | "video";
|
||||||
|
|
||||||
|
export enum MediaPermissionStatus {
|
||||||
|
Unknown,
|
||||||
|
Granted,
|
||||||
|
Denied
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaStreamEvents {
|
||||||
|
notify_permissions_changed: { type: MediaStreamType, newState: MediaPermissionStatus },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mediaStreamEvents = new Registry<MediaStreamEvents>();
|
||||||
|
|
||||||
|
async function requestMediaStream0(constraints: MediaTrackConstraints, type: MediaStreamType, updateDeviceList: boolean) : Promise<MediaStreamRequestResult | MediaStream> {
|
||||||
|
const beginTimestamp = Date.now();
|
||||||
|
try {
|
||||||
|
log.info(LogCategory.AUDIO, tr("Requesting a %s stream for device %s in group %s"), type, constraints.deviceId, constraints.groupId);
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia(type === "audio" ? { audio: constraints } : { video: constraints });
|
||||||
|
|
||||||
|
if(updateDeviceList && inputDeviceList.getStatus() === "no-permissions") {
|
||||||
|
inputDeviceList.refresh().then(() => {}); /* added the then body to avoid a inspection warning... */
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream;
|
||||||
|
} catch(error) {
|
||||||
|
if('name' in error) {
|
||||||
|
if(error.name === "NotAllowedError") {
|
||||||
|
if(Date.now() - beginTimestamp < 250) {
|
||||||
|
log.warn(LogCategory.AUDIO, tr("Media stream request failed (System denied). Browser message: %o"), error.message);
|
||||||
|
return MediaStreamRequestResult.ESYSTEMDENIED;
|
||||||
|
} else {
|
||||||
|
log.warn(LogCategory.AUDIO, tr("Media stream request failed (No permissions). Browser message: %o"), error.message);
|
||||||
|
return MediaStreamRequestResult.ENOTALLOWED;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.warn(LogCategory.AUDIO, tr("Media stream request failed. Request resulted in error: %o: %o"), error.name, error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.warn(LogCategory.AUDIO, tr("Failed to initialize media stream (%o)"), error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaStreamRequestResult.EUNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* request permission for devices only one per time! */
|
||||||
|
let currentMediaStreamRequest: Promise<MediaStream | MediaStreamRequestResult>;
|
||||||
|
export async function requestMediaStream(deviceId: string, groupId: string | undefined, type: MediaStreamType) : Promise<MediaStream | MediaStreamRequestResult> {
|
||||||
|
/* wait for the current media stream requests to finish */
|
||||||
|
while(currentMediaStreamRequest) {
|
||||||
|
try {
|
||||||
|
await currentMediaStreamRequest;
|
||||||
|
} catch(error) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
const constrains: MediaTrackConstraints = {};
|
||||||
|
if(window.detectedBrowser?.name === "firefox") {
|
||||||
|
/*
|
||||||
|
* Firefox only allows to open one mic/video as well deciding whats the input device it.
|
||||||
|
* It does not respect the deviceId nor the groupId
|
||||||
|
*/
|
||||||
|
} else {
|
||||||
|
constrains.deviceId = deviceId;
|
||||||
|
constrains.groupId = groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
constrains.echoCancellation = true;
|
||||||
|
constrains.autoGainControl = true;
|
||||||
|
constrains.noiseSuppression = true;
|
||||||
|
|
||||||
|
const promise = (currentMediaStreamRequest = requestMediaStream0(constrains, type, true));
|
||||||
|
try {
|
||||||
|
return await currentMediaStreamRequest;
|
||||||
|
} finally {
|
||||||
|
if(currentMediaStreamRequest === promise)
|
||||||
|
currentMediaStreamRequest = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryMediaPermissions(type: MediaStreamType, changeListener?: (value: PermissionState) => void) : Promise<PermissionState> {
|
||||||
|
if('permissions' in navigator && 'query' in navigator.permissions) {
|
||||||
|
try {
|
||||||
|
const result = await navigator.permissions.query({ name: type === "audio" ? "microphone" : "camera" });
|
||||||
|
if(changeListener) {
|
||||||
|
result.addEventListener("change", () => {
|
||||||
|
changeListener(result.state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.state;
|
||||||
|
} catch (error) {
|
||||||
|
logWarn(LogCategory.GENERAL, tr("Failed to query for %s permissions: %s"), type, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "prompt";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopMediaStream(stream: MediaStream) {
|
||||||
|
stream.getVideoTracks().forEach(track => track.stop());
|
||||||
|
stream.getAudioTracks().forEach(track => track.stop());
|
||||||
|
if('stop' in stream) {
|
||||||
|
stream.stop();
|
||||||
|
}
|
||||||
|
}
|
254
web/app/media/Video.ts
Normal file
254
web/app/media/Video.ts
Normal file
|
@ -0,0 +1,254 @@
|
||||||
|
import {
|
||||||
|
VideoDevice,
|
||||||
|
VideoDriver,
|
||||||
|
VideoDriverEvents,
|
||||||
|
VideoPermissionStatus,
|
||||||
|
VideoSource
|
||||||
|
} from "tc-shared/video/VideoSource";
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
import {queryMediaPermissions, requestMediaStream, stopMediaStream} from "tc-backend/web/media/Stream";
|
||||||
|
import {MediaStreamRequestResult} from "tc-shared/voice/RecorderBase";
|
||||||
|
import {LogCategory, logError, logWarn} from "tc-shared/log";
|
||||||
|
|
||||||
|
function getStreamVideoDeviceId(stream: MediaStream) : string | undefined {
|
||||||
|
const track = stream.getVideoTracks()[0];
|
||||||
|
if(typeof track !== "object" || !("getCapabilities" in track)) { return undefined; }
|
||||||
|
return track.getCapabilities()?.deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebVideoDriver implements VideoDriver {
|
||||||
|
private readonly events: Registry<VideoDriverEvents>;
|
||||||
|
private currentPermissionStatus: VideoPermissionStatus;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.events = new Registry<VideoDriverEvents>();
|
||||||
|
this.currentPermissionStatus = VideoPermissionStatus.UserDenied;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setPermissionStatus(status: VideoPermissionStatus) {
|
||||||
|
if(this.currentPermissionStatus === status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldState = this.currentPermissionStatus;
|
||||||
|
this.currentPermissionStatus = status;
|
||||||
|
this.events.fire("notify_permissions_changed", { newStatus: status, oldStatus: oldState });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSystemPermissionState(state: PermissionState | undefined) {
|
||||||
|
switch(state) {
|
||||||
|
case "denied":
|
||||||
|
this.setPermissionStatus(VideoPermissionStatus.SystemDenied);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "prompt":
|
||||||
|
this.setPermissionStatus(VideoPermissionStatus.UserDenied);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "granted":
|
||||||
|
this.setPermissionStatus(VideoPermissionStatus.Granted);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
/* this will query the initial permission state */
|
||||||
|
if(await this.getDevices() === false) {
|
||||||
|
this.setPermissionStatus(VideoPermissionStatus.UserDenied);
|
||||||
|
} else {
|
||||||
|
this.setPermissionStatus(VideoPermissionStatus.Granted);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
if(window.detectedBrowser?.name === "firefox") {
|
||||||
|
/* We've to do a normal request every time we want to access your camera. */
|
||||||
|
this.setPermissionStatus(VideoPermissionStatus.Granted);
|
||||||
|
} else {
|
||||||
|
const permissionState = await queryMediaPermissions("video", newState => this.handleSystemPermissionState(newState));
|
||||||
|
await this.handleSystemPermissionState(permissionState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDevices(): Promise<VideoDevice[] | false> {
|
||||||
|
if(window.detectedBrowser?.name === "firefox") {
|
||||||
|
return [{
|
||||||
|
name: tr("Default Firefox device"),
|
||||||
|
id: "default"
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: Cache query response */
|
||||||
|
let devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
let hasPermissions = devices.findIndex(e => e.kind === "videoinput" && e.label !== "") !== -1 || devices.findIndex(e => e.kind === "videoinput") === -1;
|
||||||
|
|
||||||
|
if(!hasPermissions) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputDevices = devices.filter(e => e.kind === "videoinput");
|
||||||
|
/*
|
||||||
|
const oldDeviceList = this.devices;
|
||||||
|
this.devices = [];
|
||||||
|
|
||||||
|
let devicesAdded = 0;
|
||||||
|
for(const device of inputDevices) {
|
||||||
|
const oldIndex = oldDeviceList.findIndex(e => e.deviceId === device.deviceId);
|
||||||
|
if(oldIndex === -1) {
|
||||||
|
devicesAdded++;
|
||||||
|
} else {
|
||||||
|
oldDeviceList.splice(oldIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.devices.push({
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
driver: "WebAudio",
|
||||||
|
groupId: device.groupId,
|
||||||
|
name: device.label
|
||||||
|
});
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
return inputDevices.map(info => {
|
||||||
|
return {
|
||||||
|
id: info.deviceId,
|
||||||
|
name: info.label
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestPermissions(): Promise<VideoSource | boolean> {
|
||||||
|
const result = await requestMediaStream("default", undefined, "video");
|
||||||
|
if(result === MediaStreamRequestResult.ENOTALLOWED) {
|
||||||
|
this.setPermissionStatus(VideoPermissionStatus.UserDenied);
|
||||||
|
return false;
|
||||||
|
} else if(result === MediaStreamRequestResult.ESYSTEMDENIED) {
|
||||||
|
this.setPermissionStatus(VideoPermissionStatus.SystemDenied);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: May update the device list? */
|
||||||
|
this.setPermissionStatus(VideoPermissionStatus.Granted);
|
||||||
|
if(result instanceof MediaStream) {
|
||||||
|
let deviceId = getStreamVideoDeviceId(result);
|
||||||
|
if(deviceId === undefined) {
|
||||||
|
if(window.detectedBrowser?.name === "firefox") {
|
||||||
|
/*
|
||||||
|
* Firefox does not support "getCapabilities".
|
||||||
|
* Since FF also just support one device, we know how the device id;
|
||||||
|
*/
|
||||||
|
deviceId = "default";
|
||||||
|
} else {
|
||||||
|
/* We can't identify the underlying device. It's better to close than returning an unknown stream. */
|
||||||
|
stopMediaStream(result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = await this.getDevices();
|
||||||
|
const deviceIndex = devices === false ? -1 : devices.findIndex(e => e.id === deviceId);
|
||||||
|
|
||||||
|
return new WebVideoSource(deviceId, deviceIndex === -1 ? tr("Unknown source") : (devices[deviceIndex] as VideoDevice).name, result);
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getEvents(): Registry<VideoDriverEvents> {
|
||||||
|
return this.events;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPermissionStatus(): VideoPermissionStatus {
|
||||||
|
return this.currentPermissionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createVideoSource(id: string): Promise<VideoSource> {
|
||||||
|
const result = await requestMediaStream(id, undefined, "video");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If we've got denied of requesting a stream reset the state to not allowed.
|
||||||
|
* This also applies to Firefox since the user has to manually update the flag after that.
|
||||||
|
* Only the initial state for Firefox is and should be "Granted".
|
||||||
|
*/
|
||||||
|
if(result === MediaStreamRequestResult.ENOTALLOWED) {
|
||||||
|
this.setPermissionStatus(VideoPermissionStatus.UserDenied);
|
||||||
|
throw tr("Device access has been denied");
|
||||||
|
} else if(result === MediaStreamRequestResult.ESYSTEMDENIED) {
|
||||||
|
this.setPermissionStatus(VideoPermissionStatus.SystemDenied);
|
||||||
|
throw tr("Device access has been denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this.currentPermissionStatus !== VideoPermissionStatus.Granted) {
|
||||||
|
/* TODO: May update the device list? */
|
||||||
|
this.setPermissionStatus(VideoPermissionStatus.Granted);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(result instanceof MediaStream) {
|
||||||
|
const deviceId = getStreamVideoDeviceId(result);
|
||||||
|
if(deviceId === undefined) {
|
||||||
|
/* Do nothing. We've to trust that the given track origins from the requested id. */
|
||||||
|
} else if(deviceId !== id) {
|
||||||
|
logWarn(LogCategory.GENERAL, tr("Requested video source %s but received %s"), id, deviceId);
|
||||||
|
} else {
|
||||||
|
/* We're fine. We received the device we wanted. */
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = await this.getDevices();
|
||||||
|
const deviceIndex = devices === false ? -1 : devices.findIndex(e => e.id === deviceId);
|
||||||
|
|
||||||
|
return new WebVideoSource(id, deviceIndex === -1 ? tr("Unknown source") : (devices[deviceIndex] as VideoDevice).name, result);
|
||||||
|
} else {
|
||||||
|
throw tra("An unknown error happened while opening the device ({})", result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createScreenSource(): Promise<VideoSource> {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebVideoSource implements VideoSource {
|
||||||
|
private readonly deviceId: string;
|
||||||
|
private readonly displayName: string;
|
||||||
|
private readonly stream: MediaStream;
|
||||||
|
private referenceCount = 1;
|
||||||
|
|
||||||
|
constructor(deviceId: string, displayName: string, stream: MediaStream) {
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
this.displayName = displayName;
|
||||||
|
this.stream = stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
stopMediaStream(this.stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
getId(): string {
|
||||||
|
return this.deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getName(): string {
|
||||||
|
return this.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStream(): MediaStream {
|
||||||
|
return this.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
deref() {
|
||||||
|
this.referenceCount -= 1;
|
||||||
|
|
||||||
|
if(this.referenceCount === 0) {
|
||||||
|
this.destroy();
|
||||||
|
} else if(this.referenceCount < 0) {
|
||||||
|
logError(LogCategory.GENERAL, tr("Video source reference count went bellow zero! This indicates a critical system flaw."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ref() {
|
||||||
|
if(this.referenceCount <= 0) {
|
||||||
|
throw tr("the video stream has already been destroyed");
|
||||||
|
}
|
||||||
|
this.referenceCount++;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
765
web/app/rtc/Connection.ts
Normal file
765
web/app/rtc/Connection.ts
Normal file
|
@ -0,0 +1,765 @@
|
||||||
|
import {ServerConnection} from "tc-backend/web/connection/ServerConnection";
|
||||||
|
import {AbstractServerConnection, ServerCommand, ServerConnectionEvents} from "tc-shared/connection/ConnectionBase";
|
||||||
|
import {ConnectionState} from "tc-shared/ConnectionHandler";
|
||||||
|
import * as log from "tc-shared/log";
|
||||||
|
import {LogCategory, logDebug, logError, logTrace, logWarn} from "tc-shared/log";
|
||||||
|
import {AbstractCommandHandler} from "tc-shared/connection/AbstractCommandHandler";
|
||||||
|
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
|
||||||
|
import {tr} from "tc-shared/i18n/localize";
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
import {
|
||||||
|
RemoteRTPAudioTrack,
|
||||||
|
RemoteRTPTrackState,
|
||||||
|
RemoteRTPVideoTrack,
|
||||||
|
TrackClientInfo
|
||||||
|
} from "tc-backend/web/rtc/RemoteTrack";
|
||||||
|
import {SdpCompressor, SdpProcessor} from "tc-backend/web/rtc/SdpUtils";
|
||||||
|
|
||||||
|
const kSdpCompressionMode = 1;
|
||||||
|
|
||||||
|
class CommandHandler extends AbstractCommandHandler {
|
||||||
|
private readonly handle: RTCConnection;
|
||||||
|
private readonly sdpProcessor: SdpProcessor;
|
||||||
|
|
||||||
|
constructor(connection: AbstractServerConnection, handle: RTCConnection, sdpProcessor: SdpProcessor) {
|
||||||
|
super(connection);
|
||||||
|
this.handle = handle;
|
||||||
|
this.sdpProcessor = sdpProcessor;
|
||||||
|
this.ignore_consumed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_command(command: ServerCommand): boolean {
|
||||||
|
if(command.command === "notifyrtcsessiondescription") {
|
||||||
|
const data = command.arguments[0];
|
||||||
|
if(!this.handle["peer"]) {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Received remote %s without an active peer"), data.mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* webrtc-sdp somehow places some empty lines into the sdp */
|
||||||
|
let sdp = data.sdp.replace(/\r?\n\r?\n/g, "\n");
|
||||||
|
try {
|
||||||
|
sdp = SdpCompressor.decompressSdp(sdp, 1);
|
||||||
|
} catch (error) {
|
||||||
|
logError(LogCategory.WEBRTC, tr("Failed to decompress remote SDP: %o"), error);
|
||||||
|
this.handle["handleFatalError"](tr("Failed to decompress remote SDP"), 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(RTCConnection.kEnableSdpTrace) {
|
||||||
|
logTrace(LogCategory.WEBRTC, tr("Received remote %s:\n%s"), data.mode, data.sdp);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sdp = this.sdpProcessor.processIncomingSdp(sdp, data.mode);
|
||||||
|
} catch (error) {
|
||||||
|
logError(LogCategory.WEBRTC, tr("Failed to reprocess SDP %s: %o"), data.mode, error);
|
||||||
|
this.handle["handleFatalError"](tra("Failed to preprocess SDP {}", data.mode as string), 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(RTCConnection.kEnableSdpTrace) {
|
||||||
|
logTrace(LogCategory.WEBRTC, tr("Patched remote %s:\n%s"), data.mode, data.sdp);
|
||||||
|
}
|
||||||
|
if(data.mode === "answer") {
|
||||||
|
this.handle["peer"].setRemoteDescription({
|
||||||
|
sdp: sdp,
|
||||||
|
type: "answer"
|
||||||
|
}).catch(error => {
|
||||||
|
logError(LogCategory.WEBRTC, tr("Failed to set the remote description: %o"), error);
|
||||||
|
this.handle["handleFatalError"](tr("Failed to set the remote description (answer)"), 5000);
|
||||||
|
})
|
||||||
|
} else if(data.mode === "offer") {
|
||||||
|
this.handle["peer"].setRemoteDescription({
|
||||||
|
sdp: sdp,
|
||||||
|
type: "offer"
|
||||||
|
}).then(() => this.handle["peer"].createAnswer())
|
||||||
|
.then(answer => {
|
||||||
|
answer.sdp = this.sdpProcessor.processOutgoingSdp(answer.sdp, "answer");
|
||||||
|
answer.sdp = SdpCompressor.compressSdp(answer.sdp, kSdpCompressionMode);
|
||||||
|
|
||||||
|
return this.connection.send_command("rtcsessiondescribe", {
|
||||||
|
mode: "answer",
|
||||||
|
sdp: answer.sdp,
|
||||||
|
compression: kSdpCompressionMode
|
||||||
|
});
|
||||||
|
}).catch(error => {
|
||||||
|
logError(LogCategory.WEBRTC, tr("Failed to set the remote description and execute the renegotiation: %o"), error);
|
||||||
|
this.handle["handleFatalError"](tr("Failed to set the remote description (offer/renegotiation)"), 5000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logWarn(LogCategory.NETWORKING, tr("Received invalid mode for rtc session description (%s)."), data.mode);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else if(command.command === "notifyrtcstreamassignment") {
|
||||||
|
const data = command.arguments[0];
|
||||||
|
const ssrc = parseInt(data["streamid"]) >>> 0;
|
||||||
|
|
||||||
|
if(parseInt(data["sclid"])) {
|
||||||
|
this.handle["doMapStream"](ssrc, {
|
||||||
|
client_id: parseInt(data["sclid"]),
|
||||||
|
client_database_id: parseInt(data["scldbid"]),
|
||||||
|
client_name: data["sclname"],
|
||||||
|
client_unique_id: data["scluid"]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.handle["doMapStream"](ssrc, undefined);
|
||||||
|
}
|
||||||
|
} else if(command.command === "notifyrtcstateaudio") {
|
||||||
|
const data = command.arguments[0];
|
||||||
|
const state = parseInt(data["state"]);
|
||||||
|
const ssrc = parseInt(data["streamid"]) >>> 0;
|
||||||
|
|
||||||
|
if(state === 0) {
|
||||||
|
/* stream stopped */
|
||||||
|
this.handle["handleStreamState"](ssrc, 0, undefined);
|
||||||
|
} else if(state === 1) {
|
||||||
|
this.handle["handleStreamState"](ssrc, 1, {
|
||||||
|
client_id: parseInt(data["sclid"]),
|
||||||
|
client_database_id: parseInt(data["scldbid"]),
|
||||||
|
client_name: data["sclname"],
|
||||||
|
client_unique_id: data["scluid"]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Received unknown/invalid rtc track state: %d"), state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum RTPConnectionState {
|
||||||
|
DISCONNECTED,
|
||||||
|
CONNECTING,
|
||||||
|
CONNECTED,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
class InternalRemoteRTPAudioTrack extends RemoteRTPAudioTrack {
|
||||||
|
private muteTimeout;
|
||||||
|
|
||||||
|
constructor(ssrc: number, transceiver: RTCRtpTransceiver) {
|
||||||
|
super(ssrc, transceiver);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.handleTrackEnded();
|
||||||
|
super.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAssignment(info: TrackClientInfo | undefined) {
|
||||||
|
if(Object.isSimilar(this.currentAssignment, info)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentAssignment = info;
|
||||||
|
if(info) {
|
||||||
|
logDebug(LogCategory.WEBRTC, tr("Remote RTP audio track %d mounted to client %o"), this.getSsrc(), info);
|
||||||
|
this.setState(RemoteRTPTrackState.Bound);
|
||||||
|
} else {
|
||||||
|
logDebug(LogCategory.WEBRTC, tr("Remote RTP audio track %d has been unmounted."), this.getSsrc());
|
||||||
|
this.setState(RemoteRTPTrackState.Unbound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleStateNotify(state: number, info: TrackClientInfo | undefined) {
|
||||||
|
if(!this.currentAssignment) {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss info. Updating info."), this.getSsrc());
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateInfo = () => {
|
||||||
|
if(info.client_id !== this.currentAssignment.client_id) {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss matching client info. Expected client %d but received %d. Updating stream assignment."),
|
||||||
|
this.getSsrc(), this.currentAssignment.client_id, info.client_id);
|
||||||
|
this.currentAssignment = info;
|
||||||
|
/* TODO: Update the assignment via doMapStream */
|
||||||
|
} else if(info.client_unique_id !== this.currentAssignment.client_unique_id) {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss matching client info. Expected client %s but received %s. Updating stream assignment."),
|
||||||
|
this.getSsrc(), this.currentAssignment.client_id, info.client_id);
|
||||||
|
this.currentAssignment = info;
|
||||||
|
/* TODO: Update the assignment via doMapStream */
|
||||||
|
} else if(this.currentAssignment.client_name !== info.client_name) {
|
||||||
|
this.currentAssignment.client_name = info.client_name;
|
||||||
|
/* TODO: Notify name update */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
clearTimeout(this.muteTimeout);
|
||||||
|
this.muteTimeout = undefined;
|
||||||
|
if(state === 1) {
|
||||||
|
validateInfo();
|
||||||
|
this.shouldReplay = true;
|
||||||
|
if(this.gainNode) {
|
||||||
|
this.gainNode.gain.value = this.gain;
|
||||||
|
}
|
||||||
|
this.setState(RemoteRTPTrackState.Started);
|
||||||
|
} else {
|
||||||
|
/* There wil be no info present */
|
||||||
|
this.setState(RemoteRTPTrackState.Bound);
|
||||||
|
|
||||||
|
/* since we're might still having some jitter stuff */
|
||||||
|
this.muteTimeout = setTimeout(() => {
|
||||||
|
this.shouldReplay = false;
|
||||||
|
if(this.gainNode) {
|
||||||
|
this.gainNode.gain.value = 0;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InternalRemoteRTPVideoTrack extends RemoteRTPVideoTrack {
|
||||||
|
constructor(ssrc: number, transceiver: RTCRtpTransceiver) {
|
||||||
|
super(ssrc, transceiver);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.handleTrackEnded();
|
||||||
|
super.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAssignment(info: TrackClientInfo | undefined) {
|
||||||
|
if(Object.isSimilar(this.currentAssignment, info)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentAssignment = info;
|
||||||
|
if(info) {
|
||||||
|
logDebug(LogCategory.WEBRTC, tr("Remote RTP video track %d mounted to client %o"), this.getSsrc(), info);
|
||||||
|
this.setState(RemoteRTPTrackState.Bound);
|
||||||
|
} else {
|
||||||
|
logDebug(LogCategory.WEBRTC, tr("Remote RTP video track %d has been unmounted."), this.getSsrc());
|
||||||
|
this.setState(RemoteRTPTrackState.Unbound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleStateNotify(state: number, info: TrackClientInfo | undefined) {
|
||||||
|
if(!this.currentAssignment) {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss info. Updating info."), this.getSsrc());
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateInfo = () => {
|
||||||
|
if(info.client_id !== this.currentAssignment.client_id) {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss matching client info. Expected client %d but received %d. Updating stream assignment."),
|
||||||
|
this.getSsrc(), this.currentAssignment.client_id, info.client_id);
|
||||||
|
this.currentAssignment = info;
|
||||||
|
/* TODO: Update the assignment via doMapStream */
|
||||||
|
} else if(info.client_unique_id !== this.currentAssignment.client_unique_id) {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss matching client info. Expected client %s but received %s. Updating stream assignment."),
|
||||||
|
this.getSsrc(), this.currentAssignment.client_id, info.client_id);
|
||||||
|
this.currentAssignment = info;
|
||||||
|
/* TODO: Update the assignment via doMapStream */
|
||||||
|
} else if(this.currentAssignment.client_name !== info.client_name) {
|
||||||
|
this.currentAssignment.client_name = info.client_name;
|
||||||
|
/* TODO: Notify name update */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if(state === 1) {
|
||||||
|
validateInfo();
|
||||||
|
this.setState(RemoteRTPTrackState.Started);
|
||||||
|
} else {
|
||||||
|
/* There wil be no info present */
|
||||||
|
this.setState(RemoteRTPTrackState.Bound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RTCSourceTrackType = "audio" | "audio-whisper" | "video" | "video-screen";
|
||||||
|
export type RTCBroadcastableTrackType = Exclude<RTCSourceTrackType, "audio-whisper">;
|
||||||
|
const kRtcSourceTrackTypes: RTCSourceTrackType[] = ["audio", "audio-whisper", "video", "video-screen"];
|
||||||
|
|
||||||
|
function broadcastableTrackTypeToNumber(type: RTCBroadcastableTrackType) : number {
|
||||||
|
switch (type) {
|
||||||
|
case "video-screen":
|
||||||
|
return 3;
|
||||||
|
case "video":
|
||||||
|
return 2;
|
||||||
|
case "audio":
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
throw tr("invalid target type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemporaryRtpStream = {
|
||||||
|
createTimestamp: number,
|
||||||
|
timeoutId: number,
|
||||||
|
|
||||||
|
ssrc: number,
|
||||||
|
status: number | undefined,
|
||||||
|
info: TrackClientInfo | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RTCConnectionEvents {
|
||||||
|
notify_state_changed: { oldState: RTPConnectionState, newState: RTPConnectionState },
|
||||||
|
notify_audio_assignment_changed: { track: RemoteRTPAudioTrack, info: TrackClientInfo | undefined },
|
||||||
|
notify_video_assignment_changed: { track: RemoteRTPVideoTrack, info: TrackClientInfo | undefined },
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RTCConnection {
|
||||||
|
public static readonly kEnableSdpTrace = false;
|
||||||
|
private readonly events: Registry<RTCConnectionEvents>;
|
||||||
|
private readonly connection: ServerConnection;
|
||||||
|
private readonly commandHandler: CommandHandler;
|
||||||
|
private readonly sdpProcessor: SdpProcessor;
|
||||||
|
|
||||||
|
private connectionState: RTPConnectionState;
|
||||||
|
private failedReason: string;
|
||||||
|
|
||||||
|
private peer: RTCPeerConnection;
|
||||||
|
private localCandidateCount: number;
|
||||||
|
|
||||||
|
private currentTracks: {[T in RTCSourceTrackType]: MediaStreamTrack | undefined} = {
|
||||||
|
"audio-whisper": undefined,
|
||||||
|
"video-screen": undefined,
|
||||||
|
audio: undefined,
|
||||||
|
video: undefined
|
||||||
|
};
|
||||||
|
private currentTransceiver: {[T in RTCSourceTrackType]: RTCRtpTransceiver | undefined} = {
|
||||||
|
"audio-whisper": undefined,
|
||||||
|
"video-screen": undefined,
|
||||||
|
audio: undefined,
|
||||||
|
video: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
private remoteAudioTracks: {[key: number]: InternalRemoteRTPAudioTrack};
|
||||||
|
private remoteVideoTracks: {[key: number]: InternalRemoteRTPVideoTrack};
|
||||||
|
private temporaryStreams: {[key: number]: TemporaryRtpStream} = {};
|
||||||
|
|
||||||
|
constructor(connection: ServerConnection) {
|
||||||
|
this.events = new Registry<RTCConnectionEvents>();
|
||||||
|
this.connection = connection;
|
||||||
|
this.sdpProcessor = new SdpProcessor();
|
||||||
|
this.commandHandler = new CommandHandler(connection, this, this.sdpProcessor);
|
||||||
|
|
||||||
|
this.connection.command_handler_boss().register_handler(this.commandHandler);
|
||||||
|
this.reset(true);
|
||||||
|
|
||||||
|
this.connection.events.on("notify_connection_state_changed", event => this.handleConnectionStateChanged(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.connection.command_handler_boss().unregister_handler(this.commandHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnection() : ServerConnection {
|
||||||
|
return this.connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEvents() {
|
||||||
|
return this.events;
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnectionState() : RTPConnectionState {
|
||||||
|
return this.connectionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFailReason() : string {
|
||||||
|
return this.failedReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(updateConnectionState: boolean) {
|
||||||
|
if(this.peer) {
|
||||||
|
if(this.getConnection().connected()) {
|
||||||
|
this.getConnection().send_command("rtcsessionreset").catch(error => {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Failed to signal RTC session reset to server: %o"), error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.peer.close();
|
||||||
|
this.peer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(this.currentTransceiver).forEach(key => this.currentTransceiver[key] = undefined);
|
||||||
|
|
||||||
|
this.sdpProcessor.reset();
|
||||||
|
|
||||||
|
if(this.remoteAudioTracks) {
|
||||||
|
Object.values(this.remoteAudioTracks).forEach(track => track.destroy());
|
||||||
|
}
|
||||||
|
this.remoteAudioTracks = {};
|
||||||
|
|
||||||
|
if(this.remoteVideoTracks) {
|
||||||
|
Object.values(this.remoteVideoTracks).forEach(track => track.destroy());
|
||||||
|
}
|
||||||
|
this.remoteVideoTracks = {};
|
||||||
|
|
||||||
|
this.temporaryStreams = {};
|
||||||
|
this.localCandidateCount = 0;
|
||||||
|
|
||||||
|
if(updateConnectionState) {
|
||||||
|
this.updateConnectionState(RTPConnectionState.DISCONNECTED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setTrackSource(type: RTCSourceTrackType, source: MediaStreamTrack | null) {
|
||||||
|
switch (type) {
|
||||||
|
case "audio":
|
||||||
|
case "audio-whisper":
|
||||||
|
if(source && source.kind !== "audio") { throw tr("invalid track type"); }
|
||||||
|
break;
|
||||||
|
case "video":
|
||||||
|
case "video-screen":
|
||||||
|
if(source && source.kind !== "video") { throw tr("invalid track type"); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this.currentTracks[type] === source) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentTracks[type] = source;
|
||||||
|
await this.updateTracks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param type
|
||||||
|
* @throws a string on error
|
||||||
|
*/
|
||||||
|
public async startTrackBroadcast(type: RTCBroadcastableTrackType) : Promise<void> {
|
||||||
|
if(typeof this.currentTransceiver[type] !== "object") {
|
||||||
|
throw tr("missing transceiver");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.connection.send_command("rtcbroadcast", {
|
||||||
|
type: broadcastableTrackTypeToNumber(type),
|
||||||
|
ssrc: this.sdpProcessor.getLocalSsrcFromFromMediaId(this.currentTransceiver[type].mid)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(LogCategory.WEBRTC, tr("failed to start %s broadcast: %o"), type, error);
|
||||||
|
throw tr("failed to signal broadcast start");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public stopTrackBroadcast(type: RTCBroadcastableTrackType) {
|
||||||
|
this.connection.send_command("rtcbroadcast", {
|
||||||
|
type: broadcastableTrackTypeToNumber(type),
|
||||||
|
ssrc: 0
|
||||||
|
}).catch(error => {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Failed to signal track broadcast stop: %o"), error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateConnectionState(newState: RTPConnectionState) {
|
||||||
|
if(this.connectionState === newState) { return; }
|
||||||
|
|
||||||
|
const oldState = this.connectionState;
|
||||||
|
this.connectionState = newState;
|
||||||
|
this.events.fire("notify_state_changed", { oldState: oldState, newState: newState });
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleFatalError(error: string, retryThreshold: number) {
|
||||||
|
/* TODO: Reset for the server as well! */
|
||||||
|
this.reset(false);
|
||||||
|
this.failedReason = error;
|
||||||
|
this.updateConnectionState(RTPConnectionState.FAILED);
|
||||||
|
|
||||||
|
/* FIXME: Generate a log message! */
|
||||||
|
if(retryThreshold > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error("XXXX Retry");
|
||||||
|
this.doInitialSetup();
|
||||||
|
}, 5000);
|
||||||
|
/* TODO: Schedule a retry? */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static checkBrowserSupport() {
|
||||||
|
if(!window.RTCRtpSender || !RTCRtpSender.prototype) {
|
||||||
|
throw tr("Missing RTCRtpSender");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!RTCRtpSender.prototype.getParameters) {
|
||||||
|
throw tr("RTCRtpSender.getParameters");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!RTCRtpSender.prototype.replaceTrack) {
|
||||||
|
throw tr("RTCRtpSender.getParameters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enableDtx(_sender: RTCRtpSender) { }
|
||||||
|
|
||||||
|
private doInitialSetup() {
|
||||||
|
this.peer = new RTCPeerConnection({
|
||||||
|
bundlePolicy: "max-bundle",
|
||||||
|
rtcpMuxPolicy: "require",
|
||||||
|
iceServers: [{ urls: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
this.currentTransceiver["audio"] = this.peer.addTransceiver("audio", { sendEncodings: [{ }] });
|
||||||
|
this.enableDtx(this.currentTransceiver["audio"].sender);
|
||||||
|
|
||||||
|
this.currentTransceiver["audio-whisper"] = this.peer.addTransceiver("audio");
|
||||||
|
this.enableDtx(this.currentTransceiver["audio-whisper"].sender);
|
||||||
|
|
||||||
|
this.currentTransceiver["video"] = this.peer.addTransceiver("video");
|
||||||
|
this.currentTransceiver["video-screen"] = this.peer.addTransceiver("video");
|
||||||
|
|
||||||
|
/* add some other transceivers for later use */
|
||||||
|
for(let i = 0; i < 8; i++) {
|
||||||
|
this.peer.addTransceiver("audio");
|
||||||
|
}
|
||||||
|
for(let i = 0; i < 4; i++) {
|
||||||
|
this.peer.addTransceiver("video");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.peer.onicecandidate = event => this.handleIceCandidate(event.candidate);
|
||||||
|
this.peer.onicecandidateerror = event => this.handleIceCandidateError(event);
|
||||||
|
this.peer.oniceconnectionstatechange = () => this.handleIceConnectionStateChanged();
|
||||||
|
this.peer.onicegatheringstatechange = () => this.handleIceGatheringStateChanged();
|
||||||
|
|
||||||
|
this.peer.onsignalingstatechange = () => this.handleSignallingStateChanged();
|
||||||
|
this.peer.onconnectionstatechange = () => this.handlePeerConnectionStateChanged();
|
||||||
|
|
||||||
|
this.peer.ondatachannel = event => this.handleDataChannel(event.channel);
|
||||||
|
this.peer.ontrack = event => this.handleTrack(event);
|
||||||
|
|
||||||
|
/* FIXME: Remove this debug! */
|
||||||
|
(window as any).rtp = this;
|
||||||
|
|
||||||
|
this.updateConnectionState(RTPConnectionState.CONNECTING);
|
||||||
|
this.doInitialSetup0().catch(error => {
|
||||||
|
this.handleFatalError(tr("initial setup failed"), 5000);
|
||||||
|
logError(LogCategory.WEBRTC, tr("Connection setup failed: %o"), error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateTracks() {
|
||||||
|
for(const type of kRtcSourceTrackTypes) {
|
||||||
|
await this.currentTransceiver[type]?.sender.replaceTrack(this.currentTracks[type]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doInitialSetup0() {
|
||||||
|
RTCConnection.checkBrowserSupport();
|
||||||
|
|
||||||
|
const peer = this.peer;
|
||||||
|
await this.updateTracks();
|
||||||
|
const offer = await peer.createOffer({ iceRestart: false, offerToReceiveAudio: true, offerToReceiveVideo: true });
|
||||||
|
if(offer.type !== "offer") { throw tr("created ofer isn't of type offer"); }
|
||||||
|
if(this.peer !== peer) { return; }
|
||||||
|
|
||||||
|
if(RTCConnection.kEnableSdpTrace) {
|
||||||
|
logTrace(LogCategory.WEBRTC, tr("Generated initial local offer:\n%s"), offer.sdp);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
offer.sdp = this.sdpProcessor.processOutgoingSdp(offer.sdp, "offer");
|
||||||
|
logTrace(LogCategory.WEBRTC, tr("Patched initial local offer:\n%s"), offer.sdp);
|
||||||
|
} catch (error) {
|
||||||
|
logError(LogCategory.WEBRTC, tr("Failed to preprocess outgoing initial offer: %o"), error);
|
||||||
|
this.handleFatalError(tr("Failed to preprocess outgoing initial offer"), 10000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await peer.setLocalDescription(offer);
|
||||||
|
if(this.peer !== peer) { return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.connection.send_command("rtcsessiondescribe", {
|
||||||
|
mode: "offer",
|
||||||
|
sdp: offer.sdp
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if(this.peer !== peer) { return; }
|
||||||
|
if(error instanceof CommandResult) {
|
||||||
|
error = error.formattedMessage();
|
||||||
|
}
|
||||||
|
logWarn(LogCategory.VOICE, tr("Failed to initialize RTP connection: %o"), error);
|
||||||
|
throw tr("server failed to accept our offer");
|
||||||
|
}
|
||||||
|
if(this.peer !== peer) { return; }
|
||||||
|
|
||||||
|
this.peer.onnegotiationneeded = () => this.handleNegotiationNeeded();
|
||||||
|
/* Nothing left to do. Server should send a notifyrtcsessiondescription with mode answer */
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleConnectionStateChanged(event: ServerConnectionEvents["notify_connection_state_changed"]) {
|
||||||
|
if(event.newState === ConnectionState.CONNECTED) {
|
||||||
|
/* initialize rtc connection */
|
||||||
|
this.doInitialSetup();
|
||||||
|
} else {
|
||||||
|
this.reset(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleIceCandidate(candidate: RTCIceCandidate | undefined) {
|
||||||
|
if(candidate) {
|
||||||
|
this.localCandidateCount++;
|
||||||
|
|
||||||
|
const json = candidate.toJSON();
|
||||||
|
logTrace(LogCategory.WEBRTC, tr("Received ICE candidate %s"), json.candidate);
|
||||||
|
this.connection.send_command("rtcicecandidate", {
|
||||||
|
media_line: json.sdpMLineIndex,
|
||||||
|
candidate: json.candidate
|
||||||
|
}).catch(error => {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Failed to transmit local ICE candidate to server: %o"), error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if(this.localCandidateCount === 0) {
|
||||||
|
logError(LogCategory.WEBRTC, tr("Received local ICE candidate finish, without having any candidates."));
|
||||||
|
this.handleFatalError(tr("Failed to gather any ICE candidates"), 5000);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
logTrace(LogCategory.WEBRTC, tr("Received ICE candidate finish"));
|
||||||
|
}
|
||||||
|
this.connection.send_command("rtcicecandidate", { }).catch(error => {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Failed to transmit local ICE candidate finish to server: %o"), error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private handleIceCandidateError(event: RTCPeerConnectionIceErrorEvent) {
|
||||||
|
if(this.peer.iceGatheringState === "gathering") {
|
||||||
|
log.warn(LogCategory.WEBRTC, tr("Received error while gathering the ice candidates: %d/%s for %s (url: %s)"),
|
||||||
|
event.errorCode, event.errorText, event.hostCandidate, event.url);
|
||||||
|
} else {
|
||||||
|
log.trace(LogCategory.WEBRTC, tr("Ice candidate %s (%s) errored: %d/%s"),
|
||||||
|
event.url, event.hostCandidate, event.errorCode, event.errorText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private handleIceConnectionStateChanged() {
|
||||||
|
log.trace(LogCategory.WEBRTC, tr("ICE connection state changed to %s"), this.peer.iceConnectionState);
|
||||||
|
}
|
||||||
|
private handleIceGatheringStateChanged() {
|
||||||
|
log.trace(LogCategory.WEBRTC, tr("ICE gathering state changed to %s"), this.peer.iceGatheringState);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSignallingStateChanged() {
|
||||||
|
logTrace(LogCategory.WEBRTC, tr("Peer signalling state changed to %s"), this.peer.signalingState);
|
||||||
|
}
|
||||||
|
private handleNegotiationNeeded() {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Local peer needs negotiation, but we don't support client sideded negotiation."));
|
||||||
|
}
|
||||||
|
private handlePeerConnectionStateChanged() {
|
||||||
|
logTrace(LogCategory.WEBRTC, tr("Peer connection state changed to %s"), this.peer.connectionState);
|
||||||
|
switch (this.peer.connectionState) {
|
||||||
|
case "connecting":
|
||||||
|
this.updateConnectionState(RTPConnectionState.CONNECTING);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "connected":
|
||||||
|
this.updateConnectionState(RTPConnectionState.CONNECTED);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "failed":
|
||||||
|
case "closed":
|
||||||
|
case "disconnected":
|
||||||
|
case "new":
|
||||||
|
if(this.connectionState !== RTPConnectionState.FAILED) {
|
||||||
|
this.updateConnectionState(RTPConnectionState.DISCONNECTED);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDataChannel(_channel: RTCDataChannel) {
|
||||||
|
/* We're not doing anything with data channels */
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaseTemporaryStream(ssrc: number) : TemporaryRtpStream | undefined {
|
||||||
|
if(this.temporaryStreams[ssrc]) {
|
||||||
|
const stream = this.temporaryStreams[ssrc];
|
||||||
|
clearTimeout(stream.timeoutId);
|
||||||
|
stream.timeoutId = 0;
|
||||||
|
delete this.temporaryStreams[ssrc];
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTrack(event: RTCTrackEvent) {
|
||||||
|
const ssrc = this.sdpProcessor.getRemoteSsrcFromFromMediaId(event.transceiver.mid);
|
||||||
|
if(typeof ssrc !== "number") {
|
||||||
|
logError(LogCategory.WEBRTC, tr("Received track without knowing its ssrc. Ignoring track..."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempInfo = this.releaseTemporaryStream(ssrc);
|
||||||
|
if(event.track.kind === "audio") {
|
||||||
|
const track = new InternalRemoteRTPAudioTrack(ssrc, event.transceiver);
|
||||||
|
logDebug(LogCategory.WEBRTC, tr("Received remote audio track on ssrc %d"), ssrc);
|
||||||
|
if(tempInfo?.info !== undefined) {
|
||||||
|
track.handleAssignment(tempInfo.info);
|
||||||
|
this.events.fire("notify_audio_assignment_changed", {
|
||||||
|
info: tempInfo.info,
|
||||||
|
track: track
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(tempInfo?.status !== undefined) {
|
||||||
|
track.handleStateNotify(tempInfo.status, tempInfo.info);
|
||||||
|
}
|
||||||
|
this.remoteAudioTracks[ssrc] = track;
|
||||||
|
} else if(event.track.kind === "video") {
|
||||||
|
const track = new InternalRemoteRTPVideoTrack(ssrc, event.transceiver);
|
||||||
|
logDebug(LogCategory.WEBRTC, tr("Received remote video track on ssrc %d"), ssrc);
|
||||||
|
if(tempInfo?.info !== undefined) {
|
||||||
|
track.handleAssignment(tempInfo.info);
|
||||||
|
this.events.fire("notify_video_assignment_changed", {
|
||||||
|
info: tempInfo.info,
|
||||||
|
track: track
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(tempInfo?.status !== undefined) {
|
||||||
|
track.handleStateNotify(tempInfo.status, tempInfo.info);
|
||||||
|
}
|
||||||
|
this.remoteVideoTracks[ssrc] = track;
|
||||||
|
} else {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Received track with unknown kind '%s'."), event.track.kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrCreateTempStream(ssrc: number) : TemporaryRtpStream {
|
||||||
|
if(this.temporaryStreams[ssrc]) {
|
||||||
|
return this.temporaryStreams[ssrc];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempStream = this.temporaryStreams[ssrc] = {
|
||||||
|
ssrc: ssrc,
|
||||||
|
timeoutId: 0,
|
||||||
|
createTimestamp: Date.now(),
|
||||||
|
|
||||||
|
info: undefined,
|
||||||
|
status: undefined
|
||||||
|
};
|
||||||
|
tempStream.timeoutId = setTimeout(() => {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Received stream mapping for invalid stream which hasn't been signalled after 5 seconds (ssrc: %o)."), ssrc);
|
||||||
|
delete this.temporaryStreams[ssrc];
|
||||||
|
}, 5000);
|
||||||
|
return tempStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
private doMapStream(ssrc: number, target: TrackClientInfo | undefined) {
|
||||||
|
if(this.remoteAudioTracks[ssrc]) {
|
||||||
|
const track = this.remoteAudioTracks[ssrc];
|
||||||
|
track.handleAssignment(target);
|
||||||
|
this.events.fire("notify_audio_assignment_changed", {
|
||||||
|
info: target,
|
||||||
|
track: track
|
||||||
|
});
|
||||||
|
} else if(this.remoteVideoTracks[ssrc]) {
|
||||||
|
const track = this.remoteVideoTracks[ssrc];
|
||||||
|
track.handleAssignment(target);
|
||||||
|
this.events.fire("notify_video_assignment_changed", {
|
||||||
|
info: target,
|
||||||
|
track: track
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let tempStream = this.getOrCreateTempStream(ssrc);
|
||||||
|
tempStream.info = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleStreamState(ssrc: number, state: number, info: TrackClientInfo | undefined) {
|
||||||
|
if(this.remoteAudioTracks[ssrc]) {
|
||||||
|
const track = this.remoteAudioTracks[ssrc];
|
||||||
|
track.handleStateNotify(state, info);
|
||||||
|
} else if(this.remoteVideoTracks[ssrc]) {
|
||||||
|
const track = this.remoteVideoTracks[ssrc];
|
||||||
|
track.handleStateNotify(state, info);
|
||||||
|
} else {
|
||||||
|
let tempStream = this.getOrCreateTempStream(ssrc);
|
||||||
|
tempStream.info = info;
|
||||||
|
tempStream.status = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
200
web/app/rtc/RemoteTrack.ts
Normal file
200
web/app/rtc/RemoteTrack.ts
Normal file
|
@ -0,0 +1,200 @@
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
import {LogCategory, logWarn} from "tc-shared/log";
|
||||||
|
import {tr} from "tc-shared/i18n/localize";
|
||||||
|
import * as aplayer from "tc-backend/web/audio/player";
|
||||||
|
|
||||||
|
|
||||||
|
export interface TrackClientInfo {
|
||||||
|
client_id: number,
|
||||||
|
client_database_id: number,
|
||||||
|
client_unique_id: string,
|
||||||
|
client_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum RemoteRTPTrackState {
|
||||||
|
/** The track isn't bound to any client */
|
||||||
|
Unbound,
|
||||||
|
/** The track is bound to a client, but isn't replaying anything */
|
||||||
|
Bound,
|
||||||
|
/** The track is currently replaying something (inherits the Bound characteristics) */
|
||||||
|
Started,
|
||||||
|
/** The track has been destroyed */
|
||||||
|
Destroyed
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteRTPTrackEvents {
|
||||||
|
notify_state_changed: { oldState: RemoteRTPTrackState, newState: RemoteRTPTrackState }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface RTCRtpReceiver {
|
||||||
|
/* Works currently only for Chrome */
|
||||||
|
playoutDelayHint: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemoteRTPTrack {
|
||||||
|
protected readonly events: Registry<RemoteRTPTrackEvents>;
|
||||||
|
private readonly ssrc: number;
|
||||||
|
private readonly transceiver: RTCRtpTransceiver;
|
||||||
|
|
||||||
|
private currentState: RemoteRTPTrackState;
|
||||||
|
protected currentAssignment: TrackClientInfo;
|
||||||
|
|
||||||
|
constructor(ssrc: number, transceiver: RTCRtpTransceiver) {
|
||||||
|
this.events = new Registry<RemoteRTPTrackEvents>();
|
||||||
|
this.ssrc = ssrc;
|
||||||
|
this.transceiver = transceiver;
|
||||||
|
this.currentState = RemoteRTPTrackState.Unbound;
|
||||||
|
|
||||||
|
transceiver.receiver.playoutDelayHint = 0.06;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected destroy() {
|
||||||
|
this.events.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
getEvents() : Registry<RemoteRTPTrackEvents> {
|
||||||
|
return this.events;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState() : RemoteRTPTrackState {
|
||||||
|
return this.currentState;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSsrc() : number {
|
||||||
|
return this.ssrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTrack() : MediaStreamTrack {
|
||||||
|
return this.transceiver.receiver.track;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTransceiver() : RTCRtpTransceiver {
|
||||||
|
return this.transceiver;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentAssignment() : TrackClientInfo | undefined {
|
||||||
|
return this.currentAssignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setState(state: RemoteRTPTrackState) {
|
||||||
|
if(this.currentState === state) {
|
||||||
|
return;
|
||||||
|
} else if(this.currentState === RemoteRTPTrackState.Destroyed) {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Tried to change the track state for track %d from destroyed to %s."), this.getSsrc(), RemoteRTPTrackState[state]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldState = this.currentState;
|
||||||
|
this.currentState = state;
|
||||||
|
this.events.fire("notify_state_changed", { oldState: oldState, newState: state });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemoteRTPVideoTrack extends RemoteRTPTrack {
|
||||||
|
protected mediaStream: MediaStream;
|
||||||
|
|
||||||
|
constructor(ssrc: number, transceiver: RTCRtpTransceiver) {
|
||||||
|
super(ssrc, transceiver);
|
||||||
|
|
||||||
|
this.mediaStream = new MediaStream();
|
||||||
|
this.mediaStream.addTrack(transceiver.receiver.track);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMediaStream() : MediaStream {
|
||||||
|
return this.mediaStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected handleTrackEnded() {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemoteRTPAudioTrack extends RemoteRTPTrack {
|
||||||
|
protected htmlAudioNode: HTMLAudioElement;
|
||||||
|
protected mediaStream: MediaStream;
|
||||||
|
|
||||||
|
protected audioNode: MediaStreamAudioSourceNode;
|
||||||
|
protected gainNode: GainNode;
|
||||||
|
|
||||||
|
protected shouldReplay: boolean;
|
||||||
|
protected gain: number;
|
||||||
|
|
||||||
|
constructor(ssrc: number, transceiver: RTCRtpTransceiver) {
|
||||||
|
super(ssrc, transceiver);
|
||||||
|
this.gain = 0;
|
||||||
|
this.shouldReplay = false;
|
||||||
|
|
||||||
|
this.mediaStream = new MediaStream();
|
||||||
|
this.mediaStream.addTrack(transceiver.receiver.track);
|
||||||
|
|
||||||
|
this.htmlAudioNode = document.createElement("audio");
|
||||||
|
this.htmlAudioNode.srcObject = this.mediaStream;
|
||||||
|
this.htmlAudioNode.autoplay = true;
|
||||||
|
this.htmlAudioNode.muted = true;
|
||||||
|
this.htmlAudioNode.msRealTime = true;
|
||||||
|
|
||||||
|
/*
|
||||||
|
TODO: ontimeupdate may gives us a hint whatever we're still replaying audio or not
|
||||||
|
for(let key in this.htmlAudioNode) {
|
||||||
|
if(!key.startsWith("on")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.htmlAudioNode[key] = () => console.log("AudioElement %d: %s", this.getSsrc(), key);
|
||||||
|
this.htmlAudioNode.ontimeupdate = () => {
|
||||||
|
console.log("AudioElement %d: Time update. Current time: %d", this.getSsrc(), this.htmlAudioNode.currentTime, this.htmlAudioNode.buffered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
aplayer.on_ready(() => {
|
||||||
|
const audioContext = aplayer.context();
|
||||||
|
this.audioNode = audioContext.createMediaStreamSource(this.mediaStream);
|
||||||
|
this.gainNode = audioContext.createGain();
|
||||||
|
|
||||||
|
this.gainNode.gain.value = this.shouldReplay ? this.gain : 0;
|
||||||
|
|
||||||
|
this.audioNode.connect(this.gainNode);
|
||||||
|
this.gainNode.connect(audioContext.destination);
|
||||||
|
});
|
||||||
|
|
||||||
|
const track = transceiver.receiver.track;
|
||||||
|
track.onended = () => this.handleTrackEnded();
|
||||||
|
/* Audio tracks do not fire muted/unmuted events */
|
||||||
|
}
|
||||||
|
|
||||||
|
protected handleTrackEnded() {
|
||||||
|
const track = this.getTransceiver().receiver.track;
|
||||||
|
track.onended = undefined;
|
||||||
|
|
||||||
|
this.htmlAudioNode?.remove();
|
||||||
|
this.htmlAudioNode = undefined;
|
||||||
|
|
||||||
|
this.mediaStream = undefined;
|
||||||
|
this.setState(RemoteRTPTrackState.Destroyed);
|
||||||
|
}
|
||||||
|
|
||||||
|
getGain() : GainNode | undefined {
|
||||||
|
return this.gainNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGain(value: number) {
|
||||||
|
this.gain = value;
|
||||||
|
|
||||||
|
if(this.gainNode) {
|
||||||
|
this.gainNode.gain.value = this.shouldReplay ? this.gain : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutes this track until the next setGain(..) call or a new sequence begins (state update)
|
||||||
|
*/
|
||||||
|
abortCurrentReplay() {
|
||||||
|
if(this.gainNode) {
|
||||||
|
this.gainNode.gain.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
189
web/app/rtc/SdpUtils.ts
Normal file
189
web/app/rtc/SdpUtils.ts
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
import * as sdpTransform from "sdp-transform";
|
||||||
|
import {MediaDescription} from "sdp-transform";
|
||||||
|
import {guid} from "tc-shared/crypto/uid";
|
||||||
|
|
||||||
|
interface SdpCodec {
|
||||||
|
payload: number;
|
||||||
|
codec: string;
|
||||||
|
rate?: number;
|
||||||
|
encoding?: number;
|
||||||
|
fmtp?: { [key: string]: string | number },
|
||||||
|
rtcpFb?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/* These MUST be the payloads used by the remote as well */
|
||||||
|
const OPUS_VOICE_PAYLOAD_TYPE = 111;
|
||||||
|
const OPUS_MUSIC_PAYLOAD_TYPE = 112;
|
||||||
|
const VP8_PAYLOAD_TYPE = 96;
|
||||||
|
|
||||||
|
type SdpMedia = {
|
||||||
|
type: string;
|
||||||
|
port: number;
|
||||||
|
protocol: string;
|
||||||
|
payloads?: string;
|
||||||
|
} & MediaDescription;
|
||||||
|
|
||||||
|
export class SdpProcessor {
|
||||||
|
private static readonly kAudioCodecs: SdpCodec[] = [
|
||||||
|
{
|
||||||
|
/* Opus Mono/Opus Voice */
|
||||||
|
payload: OPUS_VOICE_PAYLOAD_TYPE,
|
||||||
|
codec: "opus",
|
||||||
|
rate: 48000,
|
||||||
|
encoding: 2,
|
||||||
|
fmtp: { minptime: 1, maxptime: 20, useinbandfec: 1, stereo: 1 },
|
||||||
|
rtcpFb: [ "transport-cc" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
/* Opus Stereo/Opus Music */
|
||||||
|
payload: OPUS_MUSIC_PAYLOAD_TYPE,
|
||||||
|
codec: "opus",
|
||||||
|
rate: 48000,
|
||||||
|
encoding: 2,
|
||||||
|
fmtp: { minptime: 1, maxptime: 20, useinbandfec: 1, stereo: 1 },
|
||||||
|
rtcpFb: [ "transport-cc" ]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly kVideoCodecs: SdpCodec[] = [
|
||||||
|
{
|
||||||
|
payload: VP8_PAYLOAD_TYPE,
|
||||||
|
codec: "VP8",
|
||||||
|
rate: 90000,
|
||||||
|
rtcpFb: [ "nack", "nack pli", "ccm fir", "transport-cc" ]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
private rtpRemoteChannelMapping: {[key: string]: number};
|
||||||
|
private rtpLocalChannelMapping: {[key: string]: number};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.rtpRemoteChannelMapping = {};
|
||||||
|
this.rtpLocalChannelMapping = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemoteSsrcFromFromMediaId(mediaId: string) : number | undefined {
|
||||||
|
return this.rtpRemoteChannelMapping[mediaId];
|
||||||
|
}
|
||||||
|
|
||||||
|
getLocalSsrcFromFromMediaId(mediaId: string) : number | undefined {
|
||||||
|
return this.rtpLocalChannelMapping[mediaId];
|
||||||
|
}
|
||||||
|
|
||||||
|
processIncomingSdp(sdpString: string, _mode: "offer" | "answer") : string {
|
||||||
|
const sdp = sdpTransform.parse(sdpString);
|
||||||
|
this.rtpRemoteChannelMapping = SdpProcessor.generateRtpSSrcMapping(sdp);
|
||||||
|
return sdpTransform.write(sdp);
|
||||||
|
}
|
||||||
|
|
||||||
|
processOutgoingSdp(sdpString: string, mode: "offer" | "answer") : string {
|
||||||
|
const sdp = sdpTransform.parse(sdpString);
|
||||||
|
|
||||||
|
/* apply the "root" fingerprint to each media, FF fix */
|
||||||
|
if(sdp.fingerprint) {
|
||||||
|
sdp.media.forEach(media => media.fingerprint = sdp.fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* remove the FID groups for video (We don't support that) */
|
||||||
|
for(const media of sdp.media) {
|
||||||
|
if(!media.ssrcGroups) { continue; }
|
||||||
|
for(const group of media.ssrcGroups.slice()) {
|
||||||
|
if(group.semantics === "FID") {
|
||||||
|
/* Keep the first ssrc which is the primary source. The other is for FID */
|
||||||
|
const ids = group.ssrcs.split(" ").map(ssrc => parseInt(ssrc) >>> 0).slice(1);
|
||||||
|
media.ssrcs = media.ssrcs.filter(ssrc => ids.indexOf(parseInt(ssrc.id as string) >>> 0) === -1);
|
||||||
|
media.ssrcGroups.remove(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rtpLocalChannelMapping = SdpProcessor.generateRtpSSrcMapping(sdp);
|
||||||
|
|
||||||
|
SdpProcessor.patchLocalCodecs(sdp);
|
||||||
|
|
||||||
|
return sdpTransform.write(sdp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static generateRtpSSrcMapping(sdp: sdpTransform.SessionDescription) {
|
||||||
|
const mapping = {};
|
||||||
|
for(let media of sdp.media) {
|
||||||
|
if(typeof media.mid === "undefined") {
|
||||||
|
throw tra("missing media id for line {}", sdp.media.indexOf(media));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* every ssrc MUST have a cname */
|
||||||
|
const ssrcs = (media.ssrcs || []).filter(e => e.attribute === "cname");
|
||||||
|
ssrcs.forEach(ssrc => {
|
||||||
|
if(typeof mapping[media.mid] === "undefined") {
|
||||||
|
mapping[media.mid] = ssrc.id as number >>> 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static patchLocalCodecs(sdp: sdpTransform.SessionDescription) {
|
||||||
|
for(let media of sdp.media) {
|
||||||
|
if(media.type !== "video" && media.type !== "audio") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
media.fmtp = [];
|
||||||
|
media.rtp = [];
|
||||||
|
media.rtcpFb = [];
|
||||||
|
media.rtcpFbTrrInt = [];
|
||||||
|
|
||||||
|
for(let codec of (media.type === "audio" ? this.kAudioCodecs : this.kVideoCodecs)) {
|
||||||
|
media.rtp.push({
|
||||||
|
payload: codec.payload,
|
||||||
|
codec: codec.codec,
|
||||||
|
encoding: codec.encoding,
|
||||||
|
rate: codec.rate
|
||||||
|
});
|
||||||
|
|
||||||
|
codec.rtcpFb?.forEach(fb => media.rtcpFb.push({
|
||||||
|
payload: codec.payload,
|
||||||
|
type: fb
|
||||||
|
}));
|
||||||
|
|
||||||
|
if(codec.fmtp && Object.keys(codec.fmtp).length > 0) {
|
||||||
|
media.fmtp.push({
|
||||||
|
payload: codec.payload,
|
||||||
|
config: Object.keys(codec.fmtp).map(e => e + "=" + codec.fmtp[e]).join(";")
|
||||||
|
});
|
||||||
|
media.maxptime = media.fmtp["maxptime"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
media.payloads = media.rtp.map(e => e.payload).join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace SdpCompressor {
|
||||||
|
export function decompressSdp(sdp: string, mode: number) : string {
|
||||||
|
if(mode === 0) {
|
||||||
|
return sdp;
|
||||||
|
} else if(mode === 1) {
|
||||||
|
/* TODO! */
|
||||||
|
return sdp;
|
||||||
|
} else {
|
||||||
|
throw tr("unsupported compression mode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compressSdp(sdp: string, mode: number) : string {
|
||||||
|
if(mode === 0) {
|
||||||
|
return sdp;
|
||||||
|
} else if(mode === 1) {
|
||||||
|
/* TODO! */
|
||||||
|
return sdp;
|
||||||
|
} else {
|
||||||
|
throw tr("unsupported compression mode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
241
web/app/rtc/video/Connection.ts
Normal file
241
web/app/rtc/video/Connection.ts
Normal file
|
@ -0,0 +1,241 @@
|
||||||
|
import {
|
||||||
|
VideoBroadcastState,
|
||||||
|
VideoBroadcastType,
|
||||||
|
VideoClient,
|
||||||
|
VideoConnection,
|
||||||
|
VideoConnectionEvent,
|
||||||
|
VideoConnectionStatus
|
||||||
|
} from "tc-shared/connection/VideoConnection";
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
import {VideoSource} from "tc-shared/video/VideoSource";
|
||||||
|
import {RTCConnection, RTCConnectionEvents, RTPConnectionState} from "tc-backend/web/rtc/Connection";
|
||||||
|
import {LogCategory, logDebug, logError, logWarn} from "tc-shared/log";
|
||||||
|
import {Settings, settings} from "tc-shared/settings";
|
||||||
|
import {RtpVideoClient} from "tc-backend/web/rtc/video/VideoClient";
|
||||||
|
import {tr} from "tc-shared/i18n/localize";
|
||||||
|
|
||||||
|
type VideoBroadcast = {
|
||||||
|
readonly source: VideoSource;
|
||||||
|
state: VideoBroadcastState,
|
||||||
|
failedReason: string | undefined,
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RtpVideoConnection implements VideoConnection {
|
||||||
|
private readonly rtcConnection: RTCConnection;
|
||||||
|
private readonly events: Registry<VideoConnectionEvent>;
|
||||||
|
private readonly listenerClientMoved;
|
||||||
|
private readonly listenerRtcStateChanged;
|
||||||
|
private connectionState: VideoConnectionStatus;
|
||||||
|
|
||||||
|
private broadcasts: {[T in VideoBroadcastType]: VideoBroadcast} = {
|
||||||
|
camera: undefined,
|
||||||
|
screen: undefined
|
||||||
|
};
|
||||||
|
private registeredClients: {[key: number]: RtpVideoClient} = {};
|
||||||
|
|
||||||
|
constructor(rtcConnection: RTCConnection) {
|
||||||
|
this.rtcConnection = rtcConnection;
|
||||||
|
this.events = new Registry<VideoConnectionEvent>();
|
||||||
|
this.setConnectionState(VideoConnectionStatus.Disconnected);
|
||||||
|
|
||||||
|
this.listenerClientMoved = this.rtcConnection.getConnection().command_handler_boss().register_explicit_handler("notifyclientmoved", event => {
|
||||||
|
const localClientId = this.rtcConnection.getConnection().client.getClientId();
|
||||||
|
for(const data of event.arguments) {
|
||||||
|
if(parseInt(data["clid"]) === localClientId) {
|
||||||
|
if(settings.static_global(Settings.KEY_STOP_VIDEO_ON_SWITCH)) {
|
||||||
|
this.stopBroadcasting("camera", true);
|
||||||
|
this.stopBroadcasting("screen", true);
|
||||||
|
} else {
|
||||||
|
/* The server stops broadcasting by default, we've to reenable it */
|
||||||
|
this.restartBroadcast("screen");
|
||||||
|
this.restartBroadcast("camera");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.listenerRtcStateChanged = this.rtcConnection.getEvents().on("notify_state_changed", event => this.handleRtcConnectionStateChanged(event));
|
||||||
|
|
||||||
|
/* TODO: Screen share?! */
|
||||||
|
this.rtcConnection.getEvents().on("notify_video_assignment_changed", event => this.handleVideoAssignmentChanged("camera", event));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setConnectionState(state: VideoConnectionStatus) {
|
||||||
|
if(this.connectionState === state) { return; }
|
||||||
|
const oldState = this.connectionState;
|
||||||
|
this.connectionState = state;
|
||||||
|
this.events.fire("notify_status_changed", { oldState: oldState, newState: state });
|
||||||
|
}
|
||||||
|
|
||||||
|
private restartBroadcast(type: VideoBroadcastType) {
|
||||||
|
if(!this.broadcasts[type]?.active) { return; }
|
||||||
|
const broadcast = this.broadcasts[type];
|
||||||
|
|
||||||
|
if(broadcast.state !== VideoBroadcastState.Initializing) {
|
||||||
|
const oldState = broadcast.state;
|
||||||
|
broadcast.state = VideoBroadcastState.Initializing;
|
||||||
|
this.events.fire("notify_local_broadcast_state_changed", { oldState: oldState, newState: VideoBroadcastState.Initializing, broadcastType: type });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rtcConnection.startTrackBroadcast(type === "camera" ? "video" : "video-screen").then(() => {
|
||||||
|
if(!broadcast.active) { return; }
|
||||||
|
|
||||||
|
const oldState = broadcast.state;
|
||||||
|
broadcast.state = VideoBroadcastState.Running;
|
||||||
|
this.events.fire("notify_local_broadcast_state_changed", { oldState: oldState, newState: VideoBroadcastState.Initializing, broadcastType: type });
|
||||||
|
logDebug(LogCategory.VIDEO, tr("Successfully restarted video broadcast of type %s"), type);
|
||||||
|
}).catch(error => {
|
||||||
|
if(!broadcast.active) { return; }
|
||||||
|
logWarn(LogCategory.VIDEO, tr("Failed to restart video broadcast %s: %o"), type, error);
|
||||||
|
this.stopBroadcasting(type, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.listenerClientMoved();
|
||||||
|
this.listenerRtcStateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
getEvents(): Registry<VideoConnectionEvent> {
|
||||||
|
return this.events;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): VideoConnectionStatus {
|
||||||
|
return this.connectionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBroadcastingState(type: VideoBroadcastType): VideoBroadcastState {
|
||||||
|
return this.broadcasts[type] ? this.broadcasts[type].state : VideoBroadcastState.Stopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBroadcastingSource(type: VideoBroadcastType): VideoSource | undefined {
|
||||||
|
return this.broadcasts[type]?.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
isBroadcasting(type: VideoBroadcastType) {
|
||||||
|
return typeof this.broadcasts[type] !== "undefined";
|
||||||
|
}
|
||||||
|
|
||||||
|
async startBroadcasting(type: VideoBroadcastType, source: VideoSource) : Promise<void> {
|
||||||
|
if(this.broadcasts[type]) {
|
||||||
|
this.stopBroadcasting(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoTracks = source.getStream().getVideoTracks();
|
||||||
|
if(videoTracks.length === 0) {
|
||||||
|
throw tr("missing video stream track");
|
||||||
|
}
|
||||||
|
|
||||||
|
const broadcast = this.broadcasts[type] = {
|
||||||
|
source: source.ref(),
|
||||||
|
state: VideoBroadcastState.Initializing as VideoBroadcastState,
|
||||||
|
failedReason: undefined,
|
||||||
|
active: true
|
||||||
|
};
|
||||||
|
this.events.fire("notify_local_broadcast_state_changed", { oldState: VideoBroadcastState.Stopped, newState: VideoBroadcastState.Initializing, broadcastType: type });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.rtcConnection.setTrackSource(type === "camera" ? "video" : "video-screen", videoTracks[0]);
|
||||||
|
} catch (error) {
|
||||||
|
this.stopBroadcasting(type);
|
||||||
|
logError(LogCategory.WEBRTC, tr("Failed to setup video track for broadcast %s: %o"), type, error);
|
||||||
|
throw tr("failed to initialize video track");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!broadcast.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.rtcConnection.startTrackBroadcast(type === "camera" ? "video" : "video-screen");
|
||||||
|
} catch (error) {
|
||||||
|
this.stopBroadcasting(type);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!broadcast.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast.state = VideoBroadcastState.Running;
|
||||||
|
this.events.fire("notify_local_broadcast_state_changed", { oldState: VideoBroadcastState.Initializing, newState: VideoBroadcastState.Running, broadcastType: type });
|
||||||
|
}
|
||||||
|
|
||||||
|
stopBroadcasting(type: VideoBroadcastType, skipRtcStop?: boolean) {
|
||||||
|
if(!skipRtcStop) {
|
||||||
|
this.rtcConnection.stopTrackBroadcast(type === "camera" ? "video" : "video-screen");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rtcConnection.setTrackSource(type === "camera" ? "video" : "video-screen", null).then(undefined);
|
||||||
|
if(this.broadcasts[type]) {
|
||||||
|
const broadcast = this.broadcasts[type];
|
||||||
|
const oldState = this.broadcasts[type].state;
|
||||||
|
this.broadcasts[type].active = false;
|
||||||
|
this.broadcasts[type] = undefined;
|
||||||
|
broadcast.source.deref();
|
||||||
|
|
||||||
|
this.events.fire("notify_local_broadcast_state_changed", { oldState: oldState, newState: VideoBroadcastState.Stopped, broadcastType: type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerVideoClient(clientId: number) {
|
||||||
|
if(typeof this.registeredClients[clientId] !== "undefined") {
|
||||||
|
debugger;
|
||||||
|
throw tr("a video client with this id has already been registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.registeredClients[clientId] = new RtpVideoClient(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
registeredVideoClients(): VideoClient[] {
|
||||||
|
return Object.values(this.registeredClients);
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterVideoClient(client: VideoClient) {
|
||||||
|
const clientId = client.getClientId();
|
||||||
|
if(this.registeredClients[clientId] !== client) {
|
||||||
|
debugger;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.registeredClients[clientId].destroy();
|
||||||
|
delete this.registeredClients[clientId];
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRtcConnectionStateChanged(event: RTCConnectionEvents["notify_state_changed"]) {
|
||||||
|
switch (event.newState) {
|
||||||
|
case RTPConnectionState.CONNECTED:
|
||||||
|
this.setConnectionState(VideoConnectionStatus.Connected);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RTPConnectionState.CONNECTING:
|
||||||
|
this.setConnectionState(VideoConnectionStatus.Connecting);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RTPConnectionState.DISCONNECTED:
|
||||||
|
this.setConnectionState(VideoConnectionStatus.Disconnected);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RTPConnectionState.FAILED:
|
||||||
|
this.setConnectionState(VideoConnectionStatus.Failed);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleVideoAssignmentChanged(type: VideoBroadcastType, event: RTCConnectionEvents["notify_video_assignment_changed"]) {
|
||||||
|
const oldClient = Object.values(this.registeredClients).find(client => client.getRtpTrack(type) === event.track);
|
||||||
|
if(oldClient) {
|
||||||
|
oldClient.setRtpTrack(type, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(event.info) {
|
||||||
|
const newClient = this.registeredClients[event.info.client_id];
|
||||||
|
if(newClient) {
|
||||||
|
newClient.setRtpTrack(type, event.track);
|
||||||
|
} else {
|
||||||
|
logWarn(LogCategory.VIDEO, tr("Received video track assignment for unknown video client (%o)."), event.info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
99
web/app/rtc/video/VideoClient.ts
Normal file
99
web/app/rtc/video/VideoClient.ts
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
import {
|
||||||
|
VideoBroadcastState,
|
||||||
|
VideoBroadcastType,
|
||||||
|
VideoClient,
|
||||||
|
VideoClientEvents
|
||||||
|
} from "tc-shared/connection/VideoConnection";
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
import {RemoteRTPTrackState, RemoteRTPVideoTrack} from "tc-backend/web/rtc/RemoteTrack";
|
||||||
|
import {LogCategory, logWarn} from "tc-shared/log";
|
||||||
|
|
||||||
|
export class RtpVideoClient implements VideoClient {
|
||||||
|
private readonly clientId: number;
|
||||||
|
private readonly events: Registry<VideoClientEvents>;
|
||||||
|
|
||||||
|
private readonly listenerTrackStateChanged: {[T in VideoBroadcastType]} = {
|
||||||
|
screen: event => this.handleTrackStateChanged("screen", event.newState),
|
||||||
|
camera: event => this.handleTrackStateChanged("camera", event.newState)
|
||||||
|
};
|
||||||
|
|
||||||
|
private currentTrack: {[T in VideoBroadcastType]: RemoteRTPVideoTrack} = {
|
||||||
|
camera: undefined,
|
||||||
|
screen: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
private trackStates: {[T in VideoBroadcastType]: VideoBroadcastState} = {
|
||||||
|
camera: VideoBroadcastState.Stopped,
|
||||||
|
screen: VideoBroadcastState.Stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(clientId: number) {
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.events = new Registry<VideoClientEvents>();
|
||||||
|
}
|
||||||
|
|
||||||
|
getClientId(): number {
|
||||||
|
return this.clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEvents(): Registry<VideoClientEvents> {
|
||||||
|
return this.events;
|
||||||
|
}
|
||||||
|
|
||||||
|
getVideoStream(broadcastType: VideoBroadcastType): MediaStream {
|
||||||
|
return this.currentTrack[broadcastType]?.getMediaStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
getVideoState(broadcastType: VideoBroadcastType): VideoBroadcastState {
|
||||||
|
return this.trackStates[broadcastType];
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.setRtpTrack("camera", undefined);
|
||||||
|
this.setRtpTrack("screen", undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRtpTrack(type: VideoBroadcastType) : RemoteRTPVideoTrack | undefined {
|
||||||
|
return this.currentTrack[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
setRtpTrack(type: VideoBroadcastType, track: RemoteRTPVideoTrack | undefined) {
|
||||||
|
if(this.currentTrack[type]) {
|
||||||
|
this.currentTrack[type].getEvents().off("notify_state_changed", this.listenerTrackStateChanged[type]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentTrack[type] = track;
|
||||||
|
if(this.currentTrack[type]) {
|
||||||
|
this.currentTrack[type].getEvents().on("notify_state_changed", this.listenerTrackStateChanged[type]);
|
||||||
|
this.handleTrackStateChanged(type, this.currentTrack[type].getState());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setBroadcastState(type: VideoBroadcastType, state: VideoBroadcastState) {
|
||||||
|
if(this.trackStates[type] === state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldState = this.trackStates[type];
|
||||||
|
this.trackStates[type] = state;
|
||||||
|
this.events.fire("notify_broadcast_state_changed", { broadcastType: type, oldState: oldState, newState: state });
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTrackStateChanged(type: VideoBroadcastType, newState: RemoteRTPTrackState) {
|
||||||
|
switch (newState) {
|
||||||
|
case RemoteRTPTrackState.Bound:
|
||||||
|
case RemoteRTPTrackState.Unbound:
|
||||||
|
this.setBroadcastState(type, VideoBroadcastState.Stopped);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RemoteRTPTrackState.Started:
|
||||||
|
this.setBroadcastState(type, VideoBroadcastState.Running);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RemoteRTPTrackState.Destroyed:
|
||||||
|
logWarn(LogCategory.VIDEO, tr("Received new track state 'Destroyed' which should never happen."));
|
||||||
|
this.setBroadcastState(type, VideoBroadcastState.Stopped);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
362
web/app/rtc/voice/Connection.ts
Normal file
362
web/app/rtc/voice/Connection.ts
Normal file
|
@ -0,0 +1,362 @@
|
||||||
|
import {
|
||||||
|
AbstractVoiceConnection,
|
||||||
|
VoiceConnectionStatus,
|
||||||
|
WhisperSessionInitializer
|
||||||
|
} from "tc-shared/connection/VoiceConnection";
|
||||||
|
import {RecorderProfile} from "tc-shared/voice/RecorderProfile";
|
||||||
|
import {VoiceClient} from "tc-shared/voice/VoiceClient";
|
||||||
|
import {WhisperSession, WhisperSessionState, WhisperTarget} from "tc-shared/voice/VoiceWhisper";
|
||||||
|
import {RTCConnection, RTCConnectionEvents, RTPConnectionState} from "tc-backend/web/rtc/Connection";
|
||||||
|
import {AbstractServerConnection} from "tc-shared/connection/ConnectionBase";
|
||||||
|
import {VoicePlayerState} from "tc-shared/voice/VoicePlayer";
|
||||||
|
import * as log from "tc-shared/log";
|
||||||
|
import {LogCategory, logError, logTrace, logWarn} from "tc-shared/log";
|
||||||
|
import * as aplayer from "../../audio/player";
|
||||||
|
import {tr} from "tc-shared/i18n/localize";
|
||||||
|
import {RtpVoiceClient} from "tc-backend/web/rtc/voice/VoiceClient";
|
||||||
|
import {InputConsumerType} from "tc-shared/voice/RecorderBase";
|
||||||
|
|
||||||
|
export class RtpVoiceConnection extends AbstractVoiceConnection {
|
||||||
|
private readonly rtcConnection: RTCConnection;
|
||||||
|
|
||||||
|
private readonly listenerRtcAudioAssignment;
|
||||||
|
private readonly listenerRtcStateChanged;
|
||||||
|
private listenerClientMoved;
|
||||||
|
|
||||||
|
private connectionState: VoiceConnectionStatus;
|
||||||
|
private localFailedReason: string;
|
||||||
|
|
||||||
|
private localAudioDestination: MediaStreamAudioDestinationNode;
|
||||||
|
private currentAudioSourceNode: AudioNode;
|
||||||
|
private currentAudioSource: RecorderProfile;
|
||||||
|
|
||||||
|
private voiceClients: RtpVoiceClient[] = [];
|
||||||
|
|
||||||
|
private currentlyReplayingVoice: boolean = false;
|
||||||
|
private readonly voiceClientStateChangedEventListener;
|
||||||
|
private readonly whisperSessionStateChangedEventListener;
|
||||||
|
|
||||||
|
|
||||||
|
constructor(connection: AbstractServerConnection, rtcConnection: RTCConnection) {
|
||||||
|
super(connection);
|
||||||
|
|
||||||
|
this.rtcConnection = rtcConnection;
|
||||||
|
this.voiceClientStateChangedEventListener = this.handleVoiceClientStateChange.bind(this);
|
||||||
|
|
||||||
|
this.rtcConnection.getEvents().on("notify_audio_assignment_changed",
|
||||||
|
this.listenerRtcAudioAssignment = event => this.handleAudioAssignmentChanged(event));
|
||||||
|
|
||||||
|
this.rtcConnection.getEvents().on("notify_state_changed",
|
||||||
|
this.listenerRtcStateChanged = event => this.handleRtcConnectionStateChanged(event));
|
||||||
|
|
||||||
|
this.listenerClientMoved = this.rtcConnection.getConnection().command_handler_boss().register_explicit_handler("notifyclientmoved", event => {
|
||||||
|
const localClientId = this.rtcConnection.getConnection().client.getClientId();
|
||||||
|
for(const data of event.arguments) {
|
||||||
|
if(parseInt(data["clid"]) === localClientId) {
|
||||||
|
/* TODO: Error handling if we failed to start */
|
||||||
|
this.rtcConnection.startTrackBroadcast("audio");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
this.setConnectionState(VoiceConnectionStatus.Disconnected);
|
||||||
|
aplayer.on_ready(() => {
|
||||||
|
this.localAudioDestination = aplayer.context().createMediaStreamDestination();
|
||||||
|
if(this.currentAudioSourceNode) {
|
||||||
|
this.currentAudioSourceNode.connect(this.localAudioDestination);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if(this.listenerClientMoved) {
|
||||||
|
this.listenerClientMoved();
|
||||||
|
this.listenerClientMoved = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rtcConnection.getEvents().off("notify_audio_assignment_changed", this.listenerRtcAudioAssignment);
|
||||||
|
this.rtcConnection.getEvents().off("notify_state_changed", this.listenerRtcStateChanged);
|
||||||
|
|
||||||
|
this.acquireVoiceRecorder(undefined, true).catch(error => {
|
||||||
|
log.warn(LogCategory.VOICE, tr("Failed to release voice recorder: %o"), error);
|
||||||
|
}).then(() => {
|
||||||
|
for(const client of Object.values(this.voiceClients)) {
|
||||||
|
client.abortReplay();
|
||||||
|
}
|
||||||
|
this.voiceClients = undefined;
|
||||||
|
this.currentAudioSource = undefined;
|
||||||
|
});
|
||||||
|
if(Object.keys(this.voiceClients).length !== 0) {
|
||||||
|
logWarn(LogCategory.AUDIO, tr("Voice connection will be destroyed, but some voice clients are still left (%d)."), Object.keys(this.voiceClients).length);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
const whisperSessions = Object.keys(this.whisperSessions);
|
||||||
|
whisperSessions.forEach(session => this.whisperSessions[session].destroy());
|
||||||
|
this.whisperSessions = {};
|
||||||
|
*/
|
||||||
|
this.events.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnectionState(): VoiceConnectionStatus {
|
||||||
|
return this.connectionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFailedMessage(): string {
|
||||||
|
return this.localFailedReason || this.rtcConnection.getFailReason();
|
||||||
|
}
|
||||||
|
|
||||||
|
voiceRecorder(): RecorderProfile {
|
||||||
|
return this.currentAudioSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquireVoiceRecorder(recorder: RecorderProfile | undefined, enforce?: boolean): Promise<void> {
|
||||||
|
if(this.currentAudioSource === recorder && !enforce) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this.currentAudioSource) {
|
||||||
|
this.currentAudioSourceNode?.disconnect(this.localAudioDestination);
|
||||||
|
this.currentAudioSourceNode = undefined;
|
||||||
|
|
||||||
|
this.currentAudioSource.callback_unmount = undefined;
|
||||||
|
await this.currentAudioSource.unmount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* unmount our target recorder */
|
||||||
|
await recorder?.unmount();
|
||||||
|
|
||||||
|
this.handleRecorderStop();
|
||||||
|
this.currentAudioSource = recorder;
|
||||||
|
|
||||||
|
if(recorder) {
|
||||||
|
recorder.current_handler = this.connection.client;
|
||||||
|
|
||||||
|
recorder.callback_unmount = this.handleRecorderUnmount.bind(this);
|
||||||
|
recorder.callback_start = this.handleRecorderStart.bind(this);
|
||||||
|
recorder.callback_stop = this.handleRecorderStop.bind(this);
|
||||||
|
|
||||||
|
recorder.callback_input_initialized = async input => {
|
||||||
|
await input.setConsumer({
|
||||||
|
type: InputConsumerType.NODE,
|
||||||
|
callbackDisconnect: node => {
|
||||||
|
this.currentAudioSourceNode = undefined;
|
||||||
|
node.disconnect(this.localAudioDestination);
|
||||||
|
},
|
||||||
|
callbackNode: node => {
|
||||||
|
this.currentAudioSourceNode = node;
|
||||||
|
if(this.localAudioDestination) {
|
||||||
|
node.connect(this.localAudioDestination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if(recorder.input) {
|
||||||
|
recorder.callback_input_initialized(recorder.input);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!recorder.input || recorder.input.isFiltered()) {
|
||||||
|
this.handleRecorderStop();
|
||||||
|
} else {
|
||||||
|
this.handleRecorderStart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.events.fire("notify_recorder_changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRecorderStop() {
|
||||||
|
const chandler = this.connection.client;
|
||||||
|
const ch = chandler.getClient();
|
||||||
|
if(ch) ch.speaking = false;
|
||||||
|
|
||||||
|
if(!chandler.connected)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if(chandler.isMicrophoneMuted())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
log.info(LogCategory.VOICE, tr("Local voice ended"));
|
||||||
|
|
||||||
|
this.rtcConnection.setTrackSource("audio", null).catch(error => {
|
||||||
|
logError(LogCategory.AUDIO, tr("Failed to set current audio track: %o"), error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRecorderStart() {
|
||||||
|
const chandler = this.connection.client;
|
||||||
|
if(chandler.isMicrophoneMuted()) {
|
||||||
|
log.warn(LogCategory.VOICE, tr("Received local voice started event, even thou we're muted!"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(LogCategory.VOICE, tr("Local voice started"));
|
||||||
|
|
||||||
|
const ch = chandler.getClient();
|
||||||
|
if(ch) ch.speaking = true;
|
||||||
|
this.rtcConnection.setTrackSource("audio", this.localAudioDestination.stream.getAudioTracks()[0])
|
||||||
|
.catch(error => {
|
||||||
|
logError(LogCategory.AUDIO, tr("Failed to set current audio track: %o"), error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRecorderUnmount() {
|
||||||
|
log.info(LogCategory.VOICE, "Lost recorder!");
|
||||||
|
this.currentAudioSource = undefined;
|
||||||
|
this.acquireVoiceRecorder(undefined, true); /* we can ignore the promise because we should finish this directly */
|
||||||
|
}
|
||||||
|
|
||||||
|
isReplayingVoice(): boolean {
|
||||||
|
return this.currentlyReplayingVoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
decodingSupported(codec: number): boolean {
|
||||||
|
return codec === 4 || codec === 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
encodingSupported(codec: number): boolean {
|
||||||
|
return codec === 4 || codec === 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEncoderCodec(): number {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
setEncoderCodec(codec: number) { }
|
||||||
|
|
||||||
|
|
||||||
|
availableVoiceClients(): VoiceClient[] {
|
||||||
|
return Object.values(this.voiceClients);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerVoiceClient(clientId: number) {
|
||||||
|
if(typeof this.voiceClients[clientId] !== "undefined") {
|
||||||
|
throw tr("voice client already registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new RtpVoiceClient(clientId);
|
||||||
|
this.voiceClients[clientId] = client;
|
||||||
|
client.events.on("notify_state_changed", this.voiceClientStateChangedEventListener);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterVoiceClient(client: VoiceClient) {
|
||||||
|
if(!(client instanceof RtpVoiceClient))
|
||||||
|
throw "Invalid client type";
|
||||||
|
|
||||||
|
console.error("Destroy voice client %d", client.getClientId());
|
||||||
|
client.events.off("notify_state_changed", this.voiceClientStateChangedEventListener);
|
||||||
|
delete this.voiceClients[client.getClientId()];
|
||||||
|
client.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAllVoiceReplays() {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
getWhisperSessionInitializer(): WhisperSessionInitializer | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWhisperSessionInitializer(initializer: WhisperSessionInitializer | undefined) {
|
||||||
|
}
|
||||||
|
|
||||||
|
getWhisperSessions(): WhisperSession[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getWhisperTarget(): WhisperTarget | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropWhisperSession(session: WhisperSession) {
|
||||||
|
}
|
||||||
|
|
||||||
|
startWhisper(target: WhisperTarget): Promise<void> {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopWhisper() { }
|
||||||
|
|
||||||
|
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_later("notify_voice_replay_state_change", { replaying: replaying });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private setConnectionState(state: VoiceConnectionStatus) {
|
||||||
|
if(this.connectionState === state)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const oldState = this.connectionState;
|
||||||
|
this.connectionState = state;
|
||||||
|
this.events.fire("notify_connection_status_changed", { newStatus: state, oldStatus: oldState });
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRtcConnectionStateChanged(event: RTCConnectionEvents["notify_state_changed"]) {
|
||||||
|
switch (event.newState) {
|
||||||
|
case RTPConnectionState.CONNECTED:
|
||||||
|
this.rtcConnection.startTrackBroadcast("audio").then(() => {
|
||||||
|
logTrace(LogCategory.VOICE, tr("Local audio broadcasting has been started successfully"));
|
||||||
|
this.setConnectionState(VoiceConnectionStatus.Connected);
|
||||||
|
}).catch(error => {
|
||||||
|
logError(LogCategory.VOICE, tr("Failed to start voice audio broadcasting: %o"), error);
|
||||||
|
this.localFailedReason = tr("Failed to start audio broadcasting");
|
||||||
|
this.setConnectionState(VoiceConnectionStatus.Failed);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RTPConnectionState.CONNECTING:
|
||||||
|
this.setConnectionState(VoiceConnectionStatus.Connecting);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RTPConnectionState.DISCONNECTED:
|
||||||
|
this.setConnectionState(VoiceConnectionStatus.Disconnected);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RTPConnectionState.FAILED:
|
||||||
|
this.localFailedReason = undefined;
|
||||||
|
this.setConnectionState(VoiceConnectionStatus.Failed);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAudioAssignmentChanged(event: RTCConnectionEvents["notify_audio_assignment_changed"]) {
|
||||||
|
const oldClient = Object.values(this.voiceClients).find(client => client.getRtpTrack() === event.track);
|
||||||
|
if(oldClient) {
|
||||||
|
oldClient.setRtpTrack(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(event.info) {
|
||||||
|
const newClient = this.voiceClients[event.info.client_id];
|
||||||
|
if(newClient) {
|
||||||
|
newClient.setRtpTrack(event.track);
|
||||||
|
} else {
|
||||||
|
logWarn(LogCategory.AUDIO, tr("Received audio track assignment for unknown voice client (%o)."), event.info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
98
web/app/rtc/voice/VoiceClient.ts
Normal file
98
web/app/rtc/voice/VoiceClient.ts
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
import {VoiceClient} from "tc-shared/voice/VoiceClient";
|
||||||
|
import {VoicePlayerEvents, VoicePlayerLatencySettings, VoicePlayerState} from "tc-shared/voice/VoicePlayer";
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
import {LogCategory, logWarn} from "tc-shared/log";
|
||||||
|
import {RemoteRTPAudioTrack, RemoteRTPTrackState} from "tc-backend/web/rtc/RemoteTrack";
|
||||||
|
|
||||||
|
export class RtpVoiceClient implements VoiceClient {
|
||||||
|
readonly events: Registry<VoicePlayerEvents>;
|
||||||
|
private readonly listenerTrackStateChanged;
|
||||||
|
private readonly clientId: number;
|
||||||
|
|
||||||
|
private volume: number;
|
||||||
|
private currentState: VoicePlayerState;
|
||||||
|
private currentRtpTrack: RemoteRTPAudioTrack;
|
||||||
|
|
||||||
|
constructor(clientId: number) {
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.listenerTrackStateChanged = event => this.handleTrackStateChanged(event.newState);
|
||||||
|
this.events = new Registry<VoicePlayerEvents>();
|
||||||
|
this.currentState = VoicePlayerState.STOPPED;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.events.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
getClientId(): number {
|
||||||
|
return this.clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
abortReplay() {
|
||||||
|
this.currentRtpTrack?.abortCurrentReplay();
|
||||||
|
this.setState(VoicePlayerState.STOPPED);
|
||||||
|
}
|
||||||
|
|
||||||
|
flushBuffer() { /* not possible */ }
|
||||||
|
|
||||||
|
getState(): VoicePlayerState {
|
||||||
|
return this.currentState;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setState(state: VoicePlayerState) {
|
||||||
|
if(this.currentState === state) { return; }
|
||||||
|
const oldState = this.currentState;
|
||||||
|
this.currentState = state;
|
||||||
|
this.events.fire("notify_state_changed", { oldState: oldState, newState: state });
|
||||||
|
}
|
||||||
|
|
||||||
|
getVolume(): number {
|
||||||
|
return this.volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(volume: number) {
|
||||||
|
this.volume = volume;
|
||||||
|
this.currentRtpTrack?.setGain(volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLatencySettings(): Readonly<VoicePlayerLatencySettings> {
|
||||||
|
return { minBufferTime: 0, maxBufferTime: 0 };
|
||||||
|
}
|
||||||
|
resetLatencySettings() { }
|
||||||
|
setLatencySettings(settings: VoicePlayerLatencySettings) { }
|
||||||
|
|
||||||
|
setRtpTrack(track: RemoteRTPAudioTrack | undefined) {
|
||||||
|
if(this.currentRtpTrack) {
|
||||||
|
this.currentRtpTrack.setGain(0);
|
||||||
|
this.currentRtpTrack.getEvents().off("notify_state_changed", this.listenerTrackStateChanged);
|
||||||
|
}
|
||||||
|
this.currentRtpTrack = track;
|
||||||
|
if(this.currentRtpTrack) {
|
||||||
|
this.currentRtpTrack.setGain(this.volume);
|
||||||
|
this.currentRtpTrack.getEvents().on("notify_state_changed", this.listenerTrackStateChanged);
|
||||||
|
this.handleTrackStateChanged(this.currentRtpTrack.getState());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getRtpTrack() {
|
||||||
|
return this.currentRtpTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTrackStateChanged(newState: RemoteRTPTrackState) {
|
||||||
|
switch (newState) {
|
||||||
|
case RemoteRTPTrackState.Bound:
|
||||||
|
case RemoteRTPTrackState.Unbound:
|
||||||
|
this.setState(VoicePlayerState.STOPPED);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RemoteRTPTrackState.Started:
|
||||||
|
this.setState(VoicePlayerState.PLAYING);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RemoteRTPTrackState.Destroyed:
|
||||||
|
logWarn(LogCategory.AUDIO, tr("Received new track state 'Destroyed' which should never happen."));
|
||||||
|
this.setState(VoicePlayerState.STOPPED);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
0
web/app/rtc/voice/WhisperClient.ts
Normal file
0
web/app/rtc/voice/WhisperClient.ts
Normal file
|
@ -85,7 +85,7 @@ function initializeFaviconController(events: Registry<FaviconEvents>) {
|
||||||
icon = currentHandler.getClient().getStatusIcon();
|
icon = currentHandler.getClient().getStatusIcon();
|
||||||
}
|
}
|
||||||
|
|
||||||
events.fire_async("notify_icon", { icon: icon })
|
events.fire_later("notify_icon", { icon: icon })
|
||||||
};
|
};
|
||||||
|
|
||||||
setCurrentHandler(server_connections.active_connection());
|
setCurrentHandler(server_connections.active_connection());
|
||||||
|
|
|
@ -4,7 +4,6 @@ import * as aplayer from "../audio/player";
|
||||||
import {ServerConnection} from "../connection/ServerConnection";
|
import {ServerConnection} from "../connection/ServerConnection";
|
||||||
import {RecorderProfile} from "tc-shared/voice/RecorderProfile";
|
import {RecorderProfile} from "tc-shared/voice/RecorderProfile";
|
||||||
import {VoiceClientController} from "./VoiceClient";
|
import {VoiceClientController} from "./VoiceClient";
|
||||||
import {settings, ValuedSettingsKey} from "tc-shared/settings";
|
|
||||||
import {tr} from "tc-shared/i18n/localize";
|
import {tr} from "tc-shared/i18n/localize";
|
||||||
import {
|
import {
|
||||||
AbstractVoiceConnection,
|
AbstractVoiceConnection,
|
||||||
|
@ -27,24 +26,12 @@ import {VoiceClient} from "tc-shared/voice/VoiceClient";
|
||||||
import {WebWhisperSession} from "tc-backend/web/voice/VoiceWhisper";
|
import {WebWhisperSession} from "tc-backend/web/voice/VoiceWhisper";
|
||||||
import {VoicePlayerState} from "tc-shared/voice/VoicePlayer";
|
import {VoicePlayerState} from "tc-shared/voice/VoicePlayer";
|
||||||
|
|
||||||
export enum VoiceEncodeType {
|
|
||||||
JS_ENCODE,
|
|
||||||
NATIVE_ENCODE
|
|
||||||
}
|
|
||||||
|
|
||||||
const KEY_VOICE_CONNECTION_TYPE: ValuedSettingsKey<number> = {
|
|
||||||
key: "voice_connection_type",
|
|
||||||
valueType: "number",
|
|
||||||
defaultValue: VoiceEncodeType.NATIVE_ENCODE
|
|
||||||
};
|
|
||||||
|
|
||||||
type CancelableWhisperTarget = WhisperTarget & { canceled: boolean };
|
type CancelableWhisperTarget = WhisperTarget & { canceled: boolean };
|
||||||
|
|
||||||
export class VoiceConnection extends AbstractVoiceConnection {
|
export class VoiceConnection extends AbstractVoiceConnection {
|
||||||
readonly connection: ServerConnection;
|
readonly connection: ServerConnection;
|
||||||
|
|
||||||
private readonly serverConnectionStateListener;
|
private readonly serverConnectionStateListener;
|
||||||
private connectionType: VoiceEncodeType = VoiceEncodeType.NATIVE_ENCODE;
|
|
||||||
private connectionState: VoiceConnectionStatus;
|
private connectionState: VoiceConnectionStatus;
|
||||||
private failedConnectionMessage: string;
|
private failedConnectionMessage: string;
|
||||||
|
|
||||||
|
@ -81,8 +68,6 @@ export class VoiceConnection extends AbstractVoiceConnection {
|
||||||
this.connectionState = VoiceConnectionStatus.Disconnected;
|
this.connectionState = VoiceConnectionStatus.Disconnected;
|
||||||
|
|
||||||
this.connection = connection;
|
this.connection = connection;
|
||||||
this.connectionType = settings.static_global(KEY_VOICE_CONNECTION_TYPE, this.connectionType);
|
|
||||||
|
|
||||||
this.connection.events.on("notify_connection_state_changed",
|
this.connection.events.on("notify_connection_state_changed",
|
||||||
this.serverConnectionStateListener = this.handleServerConnectionStateChanged.bind(this));
|
this.serverConnectionStateListener = this.handleServerConnectionStateChanged.bind(this));
|
||||||
|
|
||||||
|
@ -172,6 +157,7 @@ export class VoiceConnection extends AbstractVoiceConnection {
|
||||||
}
|
}
|
||||||
|
|
||||||
private startVoiceBridge() {
|
private startVoiceBridge() {
|
||||||
|
return; /* We're not doing this currently */
|
||||||
if(!aplayer.initialized()) {
|
if(!aplayer.initialized()) {
|
||||||
logDebug(LogCategory.VOICE, tr("Audio player isn't initialized yet. Waiting for it to initialize."));
|
logDebug(LogCategory.VOICE, tr("Audio player isn't initialized yet. Waiting for it to initialize."));
|
||||||
if(!this.awaitingAudioInitialize) {
|
if(!this.awaitingAudioInitialize) {
|
||||||
|
@ -420,7 +406,7 @@ export class VoiceConnection extends AbstractVoiceConnection {
|
||||||
|
|
||||||
if(this.currentlyReplayingVoice !== replaying) {
|
if(this.currentlyReplayingVoice !== replaying) {
|
||||||
this.currentlyReplayingVoice = replaying;
|
this.currentlyReplayingVoice = replaying;
|
||||||
this.events.fire_async("notify_voice_replay_state_change", { replaying: replaying });
|
this.events.fire_later("notify_voice_replay_state_change", { replaying: replaying });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -545,13 +531,3 @@ export class VoiceConnection extends AbstractVoiceConnection {
|
||||||
this.voiceBridge?.stopWhispering();
|
this.voiceBridge?.stopWhispering();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* funny fact that typescript dosn't find this */
|
|
||||||
declare global {
|
|
||||||
interface RTCPeerConnection {
|
|
||||||
addStream(stream: MediaStream): void;
|
|
||||||
getLocalStreams(): MediaStream[];
|
|
||||||
getStreamById(streamId: string): MediaStream | null;
|
|
||||||
removeStream(stream: MediaStream): void;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -43,8 +43,8 @@ export class NativeWebRTCVoiceBridge extends WebRTCVoiceBridge {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected initializeRtpConnection(connection: RTCPeerConnection) {
|
protected initializeRtpConnection(connection: RTCPeerConnection) {
|
||||||
connection.addStream(this.localVoiceDestinationNode.stream);
|
connection.addTrack(this.localVoiceDestinationNode.stream.getAudioTracks()[0]);
|
||||||
connection.addStream(this.localWhisperDestinationNode.stream);
|
connection.addTrack(this.localWhisperDestinationNode.stream.getAudioTracks()[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected handleVoiceDataChannelMessage(message: MessageEvent) {
|
protected handleVoiceDataChannelMessage(message: MessageEvent) {
|
||||||
|
|
Loading…
Add table
Reference in a new issue