diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..054d599
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,5 @@
+{
+ "tabWidth": 2,
+ "useTabs": false,
+ "printWidth": 120
+}
diff --git a/index.html b/index.html
index e0d1c84..e8f0d21 100644
--- a/index.html
+++ b/index.html
@@ -1,13 +1,12 @@
-
-
-
-
- Vite + React + TS
-
-
-
-
-
+
+
+
+ TS5-OBS-Overlay
+
+
+
+
+
diff --git a/public/vite.svg b/public/vite.svg
deleted file mode 100644
index e7b8dfb..0000000
--- a/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/App.tsx b/src/App.tsx
index 61ef270..a57cbdb 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,5 +1,65 @@
import "@styles/App.scss";
+import { TS5Connection } from "./teamspeak5Handler";
+import { IChannel, IClient, IConnection } from "interfaces/teamspeak";
+import { useEffect, useState } from "react";
export default function App() {
- return <>>;
+ const [clients, setClients] = useState([]);
+ const [channels, setChannels] = useState([]);
+ const [connections, setConnections] = useState([]);
+
+ useEffect(() => {
+ const tsConnection: TS5Connection = new TS5Connection(5899, setConnections, setChannels, setClients);
+ tsConnection.connect();
+ }, []);
+
+ useEffect(() => {
+ console.log("====================================");
+ }, [clients, channels, connections]);
+
+ // debug view of lists
+ return (
+
+
+
Channels {channels.length}
+ {channels.map((channel) => (
+
+
+
+ {channel.id} {channel.properties.name}
+
+
+
+ {clients.map((client) => {
+ if (client.channel?.id === channel.id) {
+ return (
+
+ {client.id} {client.properties.nickname}
+
+ );
+ }
+ })}
+
+ ))}
+
+
+
Clients {clients.length}
+ {clients.map((client) => (
+
+
+ {client.id} {client.properties.nickname}
+
+
+ ))}
+
+
+
Connections {connections.length}
+ {connections.map((connection) => (
+
+ ))}
+
+
+ );
}
diff --git a/src/assets/react.svg b/src/assets/react.svg
deleted file mode 100644
index 6c87de9..0000000
--- a/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/interfaces/teamspeak.ts b/src/interfaces/teamspeak.ts
new file mode 100644
index 0000000..711d9b9
--- /dev/null
+++ b/src/interfaces/teamspeak.ts
@@ -0,0 +1,270 @@
+
+export interface TS5MessageHandlerOptions {
+ handleAuthMessage?: (data: IAuthMessage) => void;
+ handleClientMoved?: (data: IClientMovedMessage) => void;
+ handleClientPropertiesUpdate?: (data: IClientPropertiesUpdatedMessage) => void;
+ handleTalkStatusChanged?: (data: ITalkStatusChangedMessage) => void;
+ handleClientSelfPropertyUpdated?: (data: IClientSelfPropertyUpdatedMessage) => void;
+}
+
+export interface IAuthSenderPayload {
+ type: "auth";
+ payload: {
+ identifier: string;
+ version: string;
+ name: string;
+ description: string;
+ content: {
+ apiKey: string;
+ };
+ };
+}
+
+export interface IClient {
+ id: number;
+ channel: IChannel;
+ properties: IClientProperties;
+}
+
+export interface IChannel {
+ id: number;
+ connection: IConnection;
+ order: string;
+ parentId: string;
+ properties: IChannelProperties;
+}
+
+export interface IConnection {
+ channelInfos: IChannelInfos;
+ clientId: number;
+ clientInfos: IClientInfo[];
+ id: number;
+ properties: IConnectionProperties;
+}
+
+
+export interface IChannelProperties {
+ bannerGfxUrl: string;
+ bannerMode: number;
+ codec: number;
+ codecIsUnencrypted: boolean;
+ codecLatencyFactor: number;
+ codecQuality: number;
+ deleteDelay: number;
+ description: string;
+ flagAreSubscribed: boolean;
+ flagDefault: boolean;
+ flagMaxclientsUnlimited: boolean;
+ flagMaxfamilyclientsInherited: boolean;
+ flagMaxfamilyclientsUnlimited: boolean;
+ flagPassword: boolean;
+ flagPermanent: boolean;
+ flagSemiPermanent: boolean;
+ forcedSilence: boolean;
+ iconId: number;
+ maxclients: number;
+ maxfamilyclients: number;
+ name: string;
+ namePhonetic: string;
+ neededTalkPower: number;
+ order: string;
+ permissionHints: number;
+ storageQuota: number;
+ topic: string;
+ uniqueIdentifier: string;
+}
+
+
+export interface IChannelInfos {
+ rootChannels: IChannel[];
+ subChannels: { [key: number]: IChannel[] };
+}
+
+export interface IClientInfo {
+ channelId: number;
+ id: number;
+ properties: IClientProperties;
+}
+
+export interface IClientProperties {
+ away: boolean;
+ awayMessage: string;
+ badges: string;
+ channelGroupId: string;
+ channelGroupInheritedChannelId: string;
+ country: string;
+ created: number;
+ databaseId: string;
+ defaultChannel: string;
+ defaultChannelPassword: string;
+ defaultToken: string;
+ description: string;
+ flagAvatar: string;
+ flagTalking: boolean;
+ iconId: number;
+ idleTime: number;
+ inputDeactivated: boolean;
+ inputHardware: boolean;
+ inputMuted: boolean;
+ integrations: string;
+ isChannelCommander: boolean;
+ isMuted: boolean;
+ isPrioritySpeaker: boolean;
+ isRecording: boolean;
+ isTalker: boolean;
+ lastConnected: number;
+ metaData: string;
+ monthBytesDownloaded: number;
+ monthBytesUploaded: number;
+ myteamspeakAvatar: string;
+ myteamspeakId: string;
+ neededServerQueryViewPower: number;
+ nickname: string;
+ nicknamePhonetic: string;
+ outputHardware: boolean;
+ outputMuted: boolean;
+ outputOnlyMuted: boolean;
+ permissionHints: number;
+ platform: string;
+ serverGroups: string;
+ serverPassword: string;
+ signedBadges: string;
+ talkPower: number;
+ talkRequest: number;
+ talkRequestMsg: string;
+ totalBytesDownloaded: number;
+ totalBytesUploaded: number;
+ totalConnections: number;
+ type: number;
+ uniqueIdentifier: string;
+ unreadMessages: number;
+ userTag: string;
+ version: string;
+ volumeModificator: number;
+}
+
+export interface IConnectionProperties {
+ antiFloodPointsNeededCommandBlock: number;
+ antiFloodPointsNeededIpBlock: number;
+ antiFloodPointsNeededPluginBlock: number;
+ antiFloodPointsTickReduce: number;
+ askForPrivilegeKeyAfterNickname: boolean;
+ askForPrivilegeKeyForChannelCreation: boolean;
+ askForPrivilegeKeyForModify: boolean;
+ awayMessage: string;
+ badges: string;
+ channelGroupId: string;
+ channelGroupInheritedChannelId: string;
+ clientType: number;
+ connectionBandwidthReceived: number;
+ connectionBandwidthSent: number;
+ connectionClientIp: string;
+ connectionConnectedTime: number;
+ connectionFiletransferBandwidthReceived: number;
+ connectionFiletransferBandwidthSent: number;
+ connectionPacketloss: number;
+ connectionPing: number;
+ connectionPacketsReceived: number;
+ connectionPacketsSent: number;
+ connectionPort: number;
+ connectionQueryBandwidthReceived: number;
+ connectionQueryBandwidthSent: number;
+ connectionServerIp: string;
+ connectionServerPort: number;
+ connectionThrottleBandwidthReceived: number;
+ connectionThrottleBandwidthSent: number;
+ country: string;
+ created: number;
+ defaultChannel: string;
+ defaultChannelPassword: string;
+ defaultServerGroup: string;
+ defaultToken: string;
+ flagAvatar: string;
+ iconId: number;
+ inputHardware: boolean;
+ inputMuted: boolean;
+ isChannelCommander: boolean;
+ isMuted: boolean;
+ isPrioritySpeaker: boolean;
+ isRecording: boolean;
+ isTalker: boolean;
+ isTts: boolean;
+ metaData: string;
+ monthBytesDownloaded: number;
+ monthBytesUploaded: number;
+ myteamspeakAvatar: string;
+ myteamspeakId: string;
+ neededServerQueryViewPower: number;
+ nickname: string;
+ nicknamePhonetic: string;
+ outputHardware: boolean;
+ outputMuted: boolean;
+ outputOnlyMuted: boolean;
+ permissionHints: number;
+ platform: string;
+ serverPassword: string;
+ signedBadges: string;
+ talkPower: number;
+ talkRequest: number;
+ talkRequestMsg: string;
+ totalBytesDownloaded: number;
+ totalBytesUploaded: number;
+ totalConnections: number;
+ type: number;
+ uniqueIdentifier: string;
+ unreadMessages: number;
+ userTag: string;
+ version: string;
+ volumeModificator: number;
+}
+
+
+export interface IClientPropertiesUpdatedMessage {
+ type: "clientPropertiesUpdated";
+ payload: {
+ clientId: number;
+ connectionId: number;
+ properties: IClientProperties;
+ };
+}
+
+export interface IClientMovedMessage {
+ type: "clientMoved";
+ payload: {
+ properties: IClientProperties;
+ clientId: number;
+ connectionId: number;
+ newChannelId: number;
+ oldChannelId: number;
+ type: number;
+ visibility: number;
+ };
+}
+
+export interface ITalkStatusChangedMessage {
+ type: "talkStatusChanged";
+ payload: {
+ clientId: number;
+ connectionId: number;
+ isWhisper: boolean;
+ status: number;
+ };
+}
+
+export interface IClientSelfPropertyUpdatedMessage {
+ type: "clientSelfPropertyUpdated";
+ payload: {
+ connectionId: number;
+ flag: string;
+ newValue: boolean;
+ oldValue: boolean;
+ };
+}
+
+export interface IAuthMessage {
+ type: "auth";
+ payload: {
+ apiKey: string;
+ connections: IConnection[];
+ };
+}
diff --git a/src/teamspeak5Handler.ts b/src/teamspeak5Handler.ts
new file mode 100644
index 0000000..e0d773f
--- /dev/null
+++ b/src/teamspeak5Handler.ts
@@ -0,0 +1,358 @@
+import { IAuthMessage, IAuthSenderPayload, IChannel, IClient, IClientInfo, IClientMovedMessage, IClientPropertiesUpdatedMessage, IClientSelfPropertyUpdatedMessage, IConnection, ITalkStatusChangedMessage } from "interfaces/teamspeak";
+
+
+// Establish connection to TS5 client
+// Main class
+export class TS5Connection {
+ ws: WebSocket; // Websocket connection to TS5 client
+ dataHandler: TS5DataHandler; // Handles data/lists and states
+ messageHandler: TS5MessageHandler; // Handles messages received from TS5 client
+
+ constructor(
+ // Port of TS5 client
+ remoteAppPort: number,
+
+ // State setters for dataHandler
+ setConnections: React.Dispatch>,
+ setChannels: React.Dispatch>,
+ setClients: React.Dispatch>
+ ) {
+ // Create websocket connection to TS5 client
+ this.ws = new WebSocket(`ws://localhost:${remoteAppPort}`);
+
+ // Create dataHandler and messageHandler
+ this.dataHandler = new TS5DataHandler(setConnections, setChannels, setClients);
+ this.messageHandler = new TS5MessageHandler(this.ws, this.dataHandler);
+ }
+
+
+ // Connect to TS5 client
+ connect() {
+ console.log('Connecting to TS5 client...');
+
+ // Create authentication payload
+ const initalPayload: IAuthSenderPayload = {
+ type: "auth",
+ payload: {
+ identifier: "de.tealfire.obs",
+ version: "1.0.0",
+ name: "TS5 OBS Overlay",
+ description: "A OBS overlay for TS5 by DerTyp876",
+ content: {
+ apiKey: localStorage.getItem("apiKey") || "",
+ },
+ },
+ };
+
+ this.ws.onopen = () => {
+ // Send authentication payload to TS5 client
+ console.log("Sending auth payload...")
+ this.ws.send(JSON.stringify(initalPayload));
+ };
+
+ // Handle messages received from TS5 client
+ // See TS5MessageHandler class
+ this.ws.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+
+ switch (data.type) {
+ case "auth":
+ this.messageHandler.handleAuthMessage(data);
+ break;
+ case "clientMoved":
+ this.messageHandler.handleClientMovedMessage(data);
+ break;
+ case "clientPropertiesUpdated":
+ this.messageHandler.handleClientPropertiesUpdatedMessage(data);
+ break;
+ case "talkStatusChanged":
+ this.messageHandler.handleTalkStatusChangedMessage(data);
+ break;
+ case "serverPropertiesUpdated":
+ this.ws.close();
+ break;
+ case "clientSelfPropertyUpdated":
+ this.messageHandler.handleClientSelfPropertyUpdatedMessage(data);
+ break;
+ default:
+ console.log(`No handler for event type: ${data.type}`);
+ break;
+ }
+ };
+ }
+}
+
+class TS5DataHandler {
+ // Local lists of connections, channels and clients
+ // These lists are used to keep track of the data independent of the App.tsx state
+ localConnections: IConnection[];
+ localChannels: IChannel[];
+ localClients: IClient[];
+
+ // State setters for App.tsx
+ setConnections: React.Dispatch>;
+ setChannels: React.Dispatch>;
+ setClients: React.Dispatch>;
+
+ constructor(
+ // State setters for App.tsx
+ setConnections: React.Dispatch>,
+ setChannels: React.Dispatch>,
+ setClients: React.Dispatch>
+ ) {
+ this.setConnections = setConnections;
+ this.setChannels = setChannels;
+ this.setClients = setClients;
+
+ this.localConnections = [];
+ this.localChannels = [];
+ this.localClients = [];
+ }
+
+ // Update App.tsx states
+ private updateConnectionsState() {
+ this.setConnections([...this.localConnections]);
+ }
+
+ private updateChannelsState() {
+ this.setChannels([...this.localChannels]);
+ }
+
+ private updateClientsState() {
+ this.setClients([...this.localClients]);
+ }
+
+ // Add data to local lists and update states
+ addConnection(connection: IConnection) {
+ console.log("Adding connection...", connection)
+
+ const existingConnection: IConnection | undefined = this.localConnections.find((localConnection: IConnection) => localConnection.id === connection.id);
+
+ if (existingConnection == undefined) {
+ this.localConnections.push(connection);
+ this.updateConnectionsState();
+ console.log("Connection added")
+ } else {
+ console.log("Connection already exists")
+ }
+ }
+
+ addChannel(channel: IChannel) {
+ console.log("Adding channel...", channel)
+ const existingChannel: IChannel | undefined = this.localChannels.find((localChannel: IChannel) => localChannel.id === channel.id && localChannel.connection.id === channel.connection.id);
+
+ if (existingChannel == undefined) {
+ this.localChannels.push(channel);
+ this.updateChannelsState();
+ console.log("Channel added")
+ } else {
+ console.log("Channel already exists")
+ }
+ }
+
+ addClient(client: IClient) {
+ console.log("Adding client...", client)
+ const existingClient: IClient | undefined = this.localClients.find((localClient: IClient) => localClient.id === client.id && localClient.channel?.connection.id === client.channel?.connection.id);
+
+ if (existingClient == undefined) {
+ this.localClients.push(client);
+ this.updateClientsState();
+ console.log("Client added")
+ } else {
+ console.log("Client already exists")
+ }
+ }
+
+ // Update data in local lists and update states
+ updateConnection(connection: IConnection) {
+ console.log("Updating connection...", connection)
+ const existingConnection: IConnection | undefined = this.localConnections.find((localConnection: IConnection) => localConnection.id === connection.id);
+
+ if (existingConnection !== undefined) {
+ this.localConnections[this.localConnections.indexOf(existingConnection)] = connection;
+ this.updateConnectionsState();
+ console.log("Connection updated")
+ } else {
+ console.log("Connection does not exist")
+ }
+ }
+
+ updateChannel(channel: IChannel) {
+ console.log("Updating channel...", channel)
+ const existingChannel: IChannel | undefined = this.localChannels.find((localChannel: IChannel) => localChannel.id === channel.id && localChannel.connection.id === channel.connection.id);
+
+ if (existingChannel !== undefined) {
+ this.localChannels[this.localChannels.indexOf(existingChannel)] = channel;
+ this.updateChannelsState();
+ console.log("Channel updated")
+ } else {
+ console.log("Channel does not exist")
+ }
+ }
+
+ updateClient(client: IClient) {
+ console.log("Updating client...", client)
+ const existingClient: IClient | undefined = this.localClients.find((localClient: IClient) => localClient.id === client.id && localClient.channel?.connection.id === client.channel?.connection.id);
+
+ if (existingClient !== undefined) {
+ this.localClients[this.localClients.indexOf(existingClient)] = client;
+ this.updateClientsState();
+ console.log("Client updated")
+ } else {
+ console.log("Client does not exist")
+ }
+ }
+
+ // Remove data from local lists and update states
+ removeConnection(connection: IConnection) {
+ console.log("Removing connection...", connection)
+ const existingConnection: IConnection | undefined = this.localConnections.find((localConnection: IConnection) => localConnection.id === connection.id);
+
+ if (existingConnection !== undefined) {
+ this.localConnections.splice(this.localConnections.indexOf(existingConnection), 1);
+ this.updateConnectionsState();
+ console.log("Connection removed")
+ } else {
+ console.log("Connection does not exist")
+ }
+ }
+
+ removeChannel(channel: IChannel) {
+ console.log("Removing channel...", channel)
+ const existingChannel: IChannel | undefined = this.localChannels.find((localChannel: IChannel) => localChannel.id === channel.id && localChannel.connection.id === channel.connection.id);
+
+ if (existingChannel !== undefined) {
+ this.localChannels.splice(this.localChannels.indexOf(existingChannel), 1);
+ this.updateChannelsState();
+ console.log("Channel removed")
+ } else {
+ console.log("Channel does not exist")
+ }
+ }
+
+ removeClient(client: IClient) {
+ console.log("Removing client...", client)
+ const existingClient: IClient | undefined = this.localClients.find((localClient: IClient) => localClient.id === client.id && localClient.channel?.connection.id === client.channel?.connection.id);
+
+ if (existingClient !== undefined) {
+ this.localClients.splice(this.localClients.indexOf(existingClient), 1);
+ this.updateClientsState();
+ console.log("Client removed")
+ } else {
+ console.log("Client does not exist")
+ }
+ }
+
+ // Helper functions
+ getChannelById(id: number, connectionId: number): IChannel | undefined {
+ return this.localChannels.find((channel: IChannel) => channel.id === id && channel.connection?.id === connectionId);
+ }
+
+ getClientById(id: number, connectionId: number): IClient | undefined {
+ return this.localClients.find((client: IClient) => client.id === id && client.channel?.connection.id === connectionId);
+ }
+
+
+
+}
+
+// Handle incoming messages from TS5 client
+class TS5MessageHandler {
+ ws: WebSocket;
+ dataHandler: TS5DataHandler;
+
+ constructor(ws: WebSocket, dataHandler: TS5DataHandler) {
+ this.ws = ws;
+ this.dataHandler = dataHandler;
+ }
+
+
+ // This message is sent by the TS5 server when the client is connected
+ // It contains the initial data
+ handleAuthMessage(data: IAuthMessage) {
+ console.log("handleAuthMessage", data);
+
+ localStorage.setItem("apiKey", data.payload.apiKey);
+
+ // Process auth payload and add initial data
+ data.payload.connections.forEach((connection: IConnection) => {
+ this.dataHandler.addConnection(connection);
+
+ // Add channels
+ connection.channelInfos.rootChannels.forEach((channel: IChannel) => {
+ this.dataHandler.addChannel({ ...channel, connection: connection });
+
+ if (connection.channelInfos.subChannels !== null && channel.id in connection.channelInfos.subChannels) {
+ connection.channelInfos.subChannels[channel.id].forEach((subChannel: IChannel) => {
+ this.dataHandler.addChannel(subChannel);
+ });
+ }
+ });
+
+ // Add clients
+ connection.clientInfos.forEach((clientInfo: IClientInfo) => {
+ const clientChannel: IChannel | undefined = this.dataHandler.getChannelById(clientInfo.channelId, connection.id);
+
+ if (clientChannel !== undefined) {
+ this.dataHandler.addClient({
+ id: clientInfo.id,
+ channel: { ...clientChannel, connection: connection },
+ properties: clientInfo.properties,
+ });
+ }
+ });
+ });
+ }
+
+ // This message is sent by the TS5 server when a client moves a channel OR joins/leaves the server
+ handleClientMovedMessage(data: IClientMovedMessage) {
+ console.log("handleClientMoved", data);
+
+ const client: IClient | undefined = this.dataHandler.getClientById(data.payload.clientId, data.payload.connectionId);
+
+ const newChannel: IChannel | undefined = this.dataHandler.getChannelById(data.payload.newChannelId, data.payload.connectionId);
+ if (newChannel === undefined) return;
+
+ if (client !== undefined) { // Client already exists
+
+ if (+data.payload.newChannelId == 0) { // Client left
+ console.log("---> Client left")
+ this.dataHandler.removeClient(client);
+ return;
+ }
+
+ // Client moved
+ console.log("---> Client moved")
+
+ this.dataHandler.updateClient({
+ ...client,
+ channel: newChannel,
+ });
+
+ } else { // Client does not exist
+ // Client joined
+ console.log("---> New Client joined")
+ this.dataHandler.addClient(
+ {
+ id: data.payload.clientId,
+ channel: newChannel,
+ properties: data.payload.properties,
+ }
+ );
+ }
+ }
+
+ handleClientPropertiesUpdatedMessage(data: IClientPropertiesUpdatedMessage) {
+ // console.log("handleClientPropertiesUpdate", data);
+ }
+ handleTalkStatusChangedMessage(data: ITalkStatusChangedMessage) {
+ //console.log("handleTalkStatusChanged", data);
+ console.log(this.dataHandler.localConnections);
+ console.log(this.dataHandler.localChannels);
+ console.log(this.dataHandler.localClients);
+
+ }
+ handleClientSelfPropertyUpdatedMessage(data: IClientSelfPropertyUpdatedMessage) {
+ // console.log("handleClientSelfPropertyUpdated", data);
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index d77378e..5a4c722 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -12,7 +12,9 @@
"@/*": ["/src/*"],
"@components/*": ["/src/components/*"],
"@assets/*": ["/src/assets/*"],
- "@styles/*": ["/src/styles/*"]
+ "@styles/*": ["/src/styles/*"],
+ "@utils/*": ["/src/utils/*"],
+ "@interfaces/*": ["/src/interfaces/*"]
},
/* Bundler mode */
diff --git a/vite.config.ts b/vite.config.ts
index 513d115..192d4f1 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -10,6 +10,8 @@ export default defineConfig({
"@components": "/src/components",
"@styles": "/src/styles",
"@assets": "/src/assets",
+ "@interfaces": "/src/interfaces",
+ "@utils": "/src/utils",
},
},
plugins: [react()],