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) => ( +
+

{connection.id}

+
+ ))} +
+
+ ); } 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()],