From 9a7049ec44039550a9c1c652adeaaaef4a0ec704 Mon Sep 17 00:00:00 2001 From: PxlLoewe <72106766+PxlLoewe@users.noreply.github.com> Date: Mon, 17 Mar 2025 00:41:07 -0700 Subject: [PATCH] completed Audio frontend --- apps/dispatch-server/routes/livekit.ts | 8 +- .../socket-events/connect-dispatch.ts | 2 + .../_components/ToggleTalkButton.tsx | 32 -- .../(dispatch)/_components/navbar/Navbar.tsx | 14 +- .../_components/navbar/_components/Audio.tsx | 226 +++++-------- .../{ => navbar/_components}/Connection.tsx | 4 +- apps/dispatch/app/_store/audioStore.ts | 82 +++++ apps/dispatch/app/_store/connectionStore.ts | 6 +- apps/dispatch/app/_store/useTalkStore.ts | 11 - apps/dispatch/app/helpers/cn.ts | 6 + .../app/helpers/liveKitEventHandler.ts | 46 +++ apps/dispatch/package.json | 1 + apps/dispatch/tsconfig.json | 1 + apps/hub/tsconfig.json | 1 + apps/mediasoup-server/.d.ts | 8 - apps/mediasoup-server/.env.example | 4 - apps/mediasoup-server/index.ts | 31 -- .../modules/mediasoup/Channel.ts | 317 ------------------ .../modules/mediasoup/config.ts | 51 --- .../modules/mediasoup/worker.ts | 21 -- apps/mediasoup-server/modules/redis.ts | 8 - .../modules/socketJWTmiddleware.ts | 28 -- apps/mediasoup-server/nodemon.json | 5 - apps/mediasoup-server/package.json | 33 -- apps/mediasoup-server/socket-events/sfu.ts | 158 --------- apps/mediasoup-server/tsconfig.json | 11 - package-lock.json | 15 +- test/livekit.yaml | 11 - 28 files changed, 252 insertions(+), 889 deletions(-) delete mode 100644 apps/dispatch/app/(dispatch)/_components/ToggleTalkButton.tsx rename apps/dispatch/app/(dispatch)/_components/{ => navbar/_components}/Connection.tsx (95%) create mode 100644 apps/dispatch/app/_store/audioStore.ts delete mode 100644 apps/dispatch/app/_store/useTalkStore.ts create mode 100644 apps/dispatch/app/helpers/cn.ts create mode 100644 apps/dispatch/app/helpers/liveKitEventHandler.ts delete mode 100644 apps/mediasoup-server/.d.ts delete mode 100644 apps/mediasoup-server/.env.example delete mode 100644 apps/mediasoup-server/index.ts delete mode 100644 apps/mediasoup-server/modules/mediasoup/Channel.ts delete mode 100644 apps/mediasoup-server/modules/mediasoup/config.ts delete mode 100644 apps/mediasoup-server/modules/mediasoup/worker.ts delete mode 100644 apps/mediasoup-server/modules/redis.ts delete mode 100644 apps/mediasoup-server/modules/socketJWTmiddleware.ts delete mode 100644 apps/mediasoup-server/nodemon.json delete mode 100644 apps/mediasoup-server/package.json delete mode 100644 apps/mediasoup-server/socket-events/sfu.ts delete mode 100644 apps/mediasoup-server/tsconfig.json delete mode 100644 test/livekit.yaml diff --git a/apps/dispatch-server/routes/livekit.ts b/apps/dispatch-server/routes/livekit.ts index 3e6c33a2..93b9b803 100644 --- a/apps/dispatch-server/routes/livekit.ts +++ b/apps/dispatch-server/routes/livekit.ts @@ -5,12 +5,12 @@ if (!process.env.LIVEKIT_API_KEY) throw new Error("LIVEKIT_API_KEY not set"); if (!process.env.LIVEKIT_API_SECRET) throw new Error("LIVEKIT_API_SECRET not set"); -const createToken = async () => { +const createToken = async (roomName: string) => { // If this room doesn't exist, it'll be automatically created when the first // participant joins - const roomName = "quickstart-room"; // Identifier to be used for participant. // It's available as LocalParticipant.identity with livekit-client SDK + // TODO: Move function to dispatch nextjs app as API route to use authentication of nextAuth const participantName = "quickstart-username" + Math.random().toString(36).substring(7); @@ -31,8 +31,10 @@ const createToken = async () => { const router = Router(); router.get("/token", async (req, res) => { + const roomName = req.query.roomName as string; + console.log("roomName", roomName); res.send({ - token: await createToken(), + token: await createToken(roomName), }); }); diff --git a/apps/dispatch-server/socket-events/connect-dispatch.ts b/apps/dispatch-server/socket-events/connect-dispatch.ts index 168be727..c7171c3e 100644 --- a/apps/dispatch-server/socket-events/connect-dispatch.ts +++ b/apps/dispatch-server/socket-events/connect-dispatch.ts @@ -14,6 +14,8 @@ export const handle = console.log("User connected to dispatch server"); await pubClient.json.set(`dispatchers:${socket.id}`, "$", { logoffTime, + loginTime: new Date().toISOString(), + lastSeen: new Date().toISOString(), selectedZone, userId, }); diff --git a/apps/dispatch/app/(dispatch)/_components/ToggleTalkButton.tsx b/apps/dispatch/app/(dispatch)/_components/ToggleTalkButton.tsx deleted file mode 100644 index 91cab11f..00000000 --- a/apps/dispatch/app/(dispatch)/_components/ToggleTalkButton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; -import { useTalkStore } from "../../_store/useTalkStore"; - -export const ToggleTalkButton = () => { - const { isTalking, toggleTalking } = useTalkStore(); - - return ( - - ); -}; diff --git a/apps/dispatch/app/(dispatch)/_components/navbar/Navbar.tsx b/apps/dispatch/app/(dispatch)/_components/navbar/Navbar.tsx index f043dec7..b0e4ce03 100644 --- a/apps/dispatch/app/(dispatch)/_components/navbar/Navbar.tsx +++ b/apps/dispatch/app/(dispatch)/_components/navbar/Navbar.tsx @@ -1,11 +1,10 @@ "use client"; -import { ToggleTalkButton } from "../ToggleTalkButton"; import Link from "next/link"; -import { Connection } from "../Connection"; +import { Connection } from "./_components/Connection"; import { ThemeSwap } from "./_components/ThemeSwap"; -import { useState } from "react"; import { Audio } from "./_components/Audio"; +import { useState } from "react"; export default function Navbar() { const [isDark, setIsDark] = useState(false); @@ -25,14 +24,7 @@ export default function Navbar() { VAR Leitstelle V2
- +
diff --git a/apps/dispatch/app/(dispatch)/_components/navbar/_components/Audio.tsx b/apps/dispatch/app/(dispatch)/_components/navbar/_components/Audio.tsx index 695dc952..583887bc 100644 --- a/apps/dispatch/app/(dispatch)/_components/navbar/_components/Audio.tsx +++ b/apps/dispatch/app/(dispatch)/_components/navbar/_components/Audio.tsx @@ -1,159 +1,111 @@ "use client"; import { useEffect, useState } from "react"; +import { useConnectionStore } from "_store/connectionStore"; import { - LocalParticipant, - LocalTrackPublication, - Participant, - RemoteParticipant, - RemoteTrack, - RemoteTrackPublication, - Room, - RoomEvent, - Track, - VideoPresets, -} from "livekit-client"; -import { connectionStore } from "../../../../_store/connectionStore"; + Circle, + Disc, + Mic, + PlugZap, + ShieldQuestion, + Signal, + SignalLow, + SignalMedium, + WifiOff, + ZapOff, +} from "lucide-react"; +import { useAudioStore } from "_store/audioStore"; +import { cn } from "helpers/cn"; +import { ConnectionQuality } from "livekit-client"; + +const ROOMS = ["LST_01", "LST_02", "LST_03", "LST_04", "LST_05"]; export const Audio = () => { - const connection = connectionStore(); - const [token, setToken] = useState(""); - const [room, setRoom] = useState(null); - - useEffect(() => { - const fetchToken = async () => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL}/livekit/token`, - ); - const data = await response.json(); - setToken(data.token); - }; - - fetchToken(); - }, []); + const connection = useConnectionStore(); + const { + isTalking, + toggleTalking, + connect, + state, + connectionQuality, + disconnect, + remoteParticipants, + room, + } = useAudioStore(); + const [selectedRoom, setSelectedRoom] = useState("LST_01"); useEffect(() => { const joinRoom = async () => { if (!connection.isConnected) return; - if (!token) return; - if (!process.env.NEXT_PUBLIC_LIVEKIT_URL) - return console.error("NEXT_PUBLIC_LIVEKIT_URL not set"); - console.log("Connecting to room", { - token, - url: process.env.NEXT_PUBLIC_LIVEKIT_URL, - }); - const url = "ws://localhost:7880"; - /* const token = - "eyJhbGciOiJIUzI1NiJ9.eyJ2aWRlbyI6eyJyb29tSm9pbiI6dHJ1ZSwicm9vbSI6InF1aWNrc3RhcnQtcm9vbSJ9LCJpc3MiOiJBUElBbnNHZHRkWXAySG8iLCJleHAiOjE3NDIxNjAwOTIsIm5iZiI6MCwic3ViIjoicXVpY2tzdGFydC11c2VybmFtZSJ9.ih7my6oXby6yYBfzt5LXHKN5WZU9exIqS8CdpRJRLQI"; */ - console.log("Connecting to room", { token, url }); - const room = new Room({ - // automatically manage subscribed video quality - adaptiveStream: true, - - // optimize publishing bandwidth and CPU for published tracks - dynacast: true, - - // default capture settings - videoCaptureDefaults: { - resolution: VideoPresets.h720.resolution, - }, - }); - - // pre-warm connection, this can be called as early as your page is loaded - room.prepareConnection(url, token); - - // set up event listeners - room - .on(RoomEvent.TrackSubscribed, handleTrackSubscribed) - .on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed) - .on(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakerChange) - .on(RoomEvent.Disconnected, handleDisconnect) - .on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished); - - // connect to room - await room.connect(url, token); - console.log("connected to room", room.name); - - // publish local camera and mic tracks - await room.localParticipant.enableCameraAndMicrophone(); - - function handleTrackSubscribed( - track: RemoteTrack, - publication: RemoteTrackPublication, - participant: RemoteParticipant, - ) { - if ( - track.kind === Track.Kind.Video || - track.kind === Track.Kind.Audio - ) { - // attach it to a new HTMLVideoElement or HTMLAudioElement - const element = track.attach(); - } - } - - function handleTrackUnsubscribed( - track: RemoteTrack, - publication: RemoteTrackPublication, - participant: RemoteParticipant, - ) { - // remove tracks from all attached elements - track.detach(); - } - - function handleLocalTrackUnpublished( - publication: LocalTrackPublication, - participant: LocalParticipant, - ) { - // when local tracks are ended, update UI to remove them from rendering - publication.track?.detach(); - } - - function handleActiveSpeakerChange(speakers: Participant[]) { - // show UI indicators when participant is speaking - } - - function handleDisconnect() { - console.log("disconnected from room"); - } - setRoom(room); + if (state === "connected") return; + connect(selectedRoom); }; joinRoom(); return () => { - room?.disconnect(); + disconnect(); }; - }, [token, connection.isConnected]); + }, [connection.isConnected]); return ( <> -
- - - - -
1
-
- -
+
+ + + {state === "connected" && ( +
+ + {connectionQuality === ConnectionQuality.Excellent && } + {connectionQuality === ConnectionQuality.Good && } + {connectionQuality === ConnectionQuality.Poor && } + {connectionQuality === ConnectionQuality.Lost && } + {connectionQuality === ConnectionQuality.Unknown && ( + + )} +
+ {remoteParticipants} +
+
+
    + {ROOMS.map((r) => ( +
  • + +
  • + ))} +
+
+ )} +
); }; diff --git a/apps/dispatch/app/(dispatch)/_components/Connection.tsx b/apps/dispatch/app/(dispatch)/_components/navbar/_components/Connection.tsx similarity index 95% rename from apps/dispatch/app/(dispatch)/_components/Connection.tsx rename to apps/dispatch/app/(dispatch)/_components/navbar/_components/Connection.tsx index 6862e894..2b672764 100644 --- a/apps/dispatch/app/(dispatch)/_components/Connection.tsx +++ b/apps/dispatch/app/(dispatch)/_components/navbar/_components/Connection.tsx @@ -1,12 +1,12 @@ "use client"; import { useSession } from "next-auth/react"; -import { connectionStore } from "../../_store/connectionStore"; +import { useConnectionStore } from "../../../../_store/connectionStore"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; export const ConnectionBtn = () => { const modalRef = useRef(null); - const connection = connectionStore((state) => state); + const connection = useConnectionStore((state) => state); const [form, setForm] = useState({ logoffTime: "", selectedZone: "LST_01", diff --git a/apps/dispatch/app/_store/audioStore.ts b/apps/dispatch/app/_store/audioStore.ts new file mode 100644 index 00000000..eb747b26 --- /dev/null +++ b/apps/dispatch/app/_store/audioStore.ts @@ -0,0 +1,82 @@ +import { + handleActiveSpeakerChange, + handleDisconnect, + handleLocalTrackUnpublished, + handleTrackSubscribed, + handleTrackUnsubscribed, +} from "helpers/liveKitEventHandler"; +import { ConnectionQuality, Room, RoomEvent } from "livekit-client"; +import { create } from "zustand"; + +let interval: NodeJS.Timeout; + +type TalkState = { + isTalking: boolean; + state: "connecting" | "connected" | "disconnected"; + connectionQuality: ConnectionQuality; + remoteParticipants: number; + toggleTalking: () => void; + + connect: (roomName: string) => void; + disconnect: () => void; + room: Room | null; +}; +const getToken = async (roomName: string) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL}/livekit/token?roomName=${roomName}`, + ); + const data = await response.json(); + return data.token; +}; + +export const useAudioStore = create((set, get) => ({ + isTalking: false, + state: "disconnected", + remoteParticipants: 0, + connectionQuality: ConnectionQuality.Unknown, + room: null, + toggleTalking: () => set((state) => ({ isTalking: !state.isTalking })), + connect: async (roomName) => { + const connectedRoom = get().room; + if (interval) clearInterval(interval); + if (connectedRoom) { + connectedRoom.disconnect(); + connectedRoom.removeAllListeners(); + } + set({ state: "connecting" }); + const url = process.env.NEXT_PUBLIC_LIVEKIT_URL; + if (!url) return console.error("NEXT_PUBLIC_LIVEKIT_URL not set"); + const token = await getToken(roomName); + const room = new Room(); + await room.prepareConnection(url, token); + room + // Connection events + .on(RoomEvent.Connected, () => { + set({ state: "connected", room }); + }) + .on(RoomEvent.Disconnected, () => { + set({ state: "disconnected" }); + + handleDisconnect(); + }) + .on(RoomEvent.ConnectionQualityChanged, (connectionQuality) => + set({ connectionQuality }), + ) + + // Track events + .on(RoomEvent.TrackSubscribed, handleTrackSubscribed) + .on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed) + .on(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakerChange) + .on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished); + await room.connect(url, token); + interval = setInterval(() => { + set({ + remoteParticipants: + room.numParticipants === 0 ? 0 : room.numParticipants - 1, // Unreliable and delayed + }); + }, 500); + }, + disconnect: () => { + get().room?.disconnect(); + }, +})); diff --git a/apps/dispatch/app/_store/connectionStore.ts b/apps/dispatch/app/_store/connectionStore.ts index 22719f1b..c1b8f15d 100644 --- a/apps/dispatch/app/_store/connectionStore.ts +++ b/apps/dispatch/app/_store/connectionStore.ts @@ -12,7 +12,7 @@ interface ConnectionStore { disconnect: () => void; } -export const connectionStore = create((set) => ({ +export const useConnectionStore = create((set) => ({ isConnected: false, selectedZone: "LST_01", connect: async (uid, selectedZone, logoffTime) => @@ -34,8 +34,8 @@ export const connectionStore = create((set) => ({ })); socket.on("connect", () => { - connectionStore.setState({ isConnected: true }); + useConnectionStore.setState({ isConnected: true }); }); socket.on("disconnect", () => { - connectionStore.setState({ isConnected: false }); + useConnectionStore.setState({ isConnected: false }); }); diff --git a/apps/dispatch/app/_store/useTalkStore.ts b/apps/dispatch/app/_store/useTalkStore.ts deleted file mode 100644 index 6403d57b..00000000 --- a/apps/dispatch/app/_store/useTalkStore.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { create } from "zustand"; - -type TalkState = { - isTalking: boolean; - toggleTalking: () => void; -}; - -export const useTalkStore = create((set) => ({ - isTalking: false, - toggleTalking: () => set((state) => ({ isTalking: !state.isTalking })), -})); diff --git a/apps/dispatch/app/helpers/cn.ts b/apps/dispatch/app/helpers/cn.ts new file mode 100644 index 00000000..737e5944 --- /dev/null +++ b/apps/dispatch/app/helpers/cn.ts @@ -0,0 +1,6 @@ +import clsx, { ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export const cn = (...inputs: ClassValue[]) => { + return twMerge(clsx(inputs)); +}; diff --git a/apps/dispatch/app/helpers/liveKitEventHandler.ts b/apps/dispatch/app/helpers/liveKitEventHandler.ts new file mode 100644 index 00000000..f2e6cf42 --- /dev/null +++ b/apps/dispatch/app/helpers/liveKitEventHandler.ts @@ -0,0 +1,46 @@ +import { + LocalParticipant, + LocalTrackPublication, + Participant, + RemoteParticipant, + RemoteTrack, + RemoteTrackPublication, + Track, +} from "livekit-client"; + +export const handleTrackSubscribed = ( + track: RemoteTrack, + publication: RemoteTrackPublication, + participant: RemoteParticipant, +) => { + if (track.kind === Track.Kind.Video || track.kind === Track.Kind.Audio) { + // attach it to a new HTMLVideoElement or HTMLAudioElement + const element = track.attach(); + element.play(); + } +}; + +export const handleTrackUnsubscribed = ( + track: RemoteTrack, + publication: RemoteTrackPublication, + participant: RemoteParticipant, +) => { + // remove tracks from all attached elements + track.detach(); +}; + +export const handleLocalTrackUnpublished = ( + publication: LocalTrackPublication, + participant: LocalParticipant, +) => { + // when local tracks are ended, update UI to remove them from rendering + publication.track?.detach(); +}; + +export const handleActiveSpeakerChange = (speakers: Participant[]) => { + // show UI indicators when participant is speaking +}; + +export const handleDisconnect = () => { + console.log("disconnected from room"); +}; diff --git a/apps/dispatch/package.json b/apps/dispatch/package.json index de6d72d2..53258c9a 100644 --- a/apps/dispatch/package.json +++ b/apps/dispatch/package.json @@ -18,6 +18,7 @@ "@tailwindcss/postcss": "^4.0.14", "leaflet": "^1.9.4", "livekit-client": "^2.9.7", + "lucide-react": "^0.482.0", "next": "^15.1.0", "next-auth": "^4.24.11", "postcss": "^8.5.1", diff --git a/apps/dispatch/tsconfig.json b/apps/dispatch/tsconfig.json index c01618df..ac447e7e 100644 --- a/apps/dispatch/tsconfig.json +++ b/apps/dispatch/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "@repo/typescript-config/nextjs.json", "compilerOptions": { + "baseUrl": "./app", "plugins": [ { "name": "next" diff --git a/apps/hub/tsconfig.json b/apps/hub/tsconfig.json index 14e65d00..137a4340 100644 --- a/apps/hub/tsconfig.json +++ b/apps/hub/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "@repo/typescript-config/nextjs.json", "compilerOptions": { + "baseUrl": "./app", "plugins": [ { "name": "next" diff --git a/apps/mediasoup-server/.d.ts b/apps/mediasoup-server/.d.ts deleted file mode 100644 index 8b3f9cfb..00000000 --- a/apps/mediasoup-server/.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare module "next-auth/jwt" { - interface JWT { - uid: string; - firstname: string; - lastname: string; - email: string; - } -} diff --git a/apps/mediasoup-server/.env.example b/apps/mediasoup-server/.env.example deleted file mode 100644 index 3ed5e2cd..00000000 --- a/apps/mediasoup-server/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -PORT=3002 -REDIS_HOST=localhost -REDIS_PORT=6379 -DISPATCH_APP_TOKEN= \ No newline at end of file diff --git a/apps/mediasoup-server/index.ts b/apps/mediasoup-server/index.ts deleted file mode 100644 index 95d3e29c..00000000 --- a/apps/mediasoup-server/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import "dotenv/config"; -import express from "express"; -import { createServer } from "http"; -import { Server } from "socket.io"; -import { createAdapter } from "@socket.io/redis-adapter"; -import { jwtMiddleware } from "modules/socketJWTmiddleware"; -import { pubClient, subClient } from "modules/redis"; - -const app = express(); -const server = createServer(app); - -pubClient.keys("dispatchers*").then((keys) => { - if (!keys) return; - keys.forEach(async (key) => { - await pubClient.json.del(key); - }); -}); - -const io = new Server(server, { - adapter: createAdapter(pubClient, subClient), - cors: {}, -}); - -io.use(jwtMiddleware); - -io.on("connection", (socket) => { - console.log("User conencted to mediasoup server"); -}); -server.listen(process.env.PORT, () => { - console.log(`Server running on port ${process.env.PORT}`); -}); diff --git a/apps/mediasoup-server/modules/mediasoup/Channel.ts b/apps/mediasoup-server/modules/mediasoup/Channel.ts deleted file mode 100644 index 079b69a5..00000000 --- a/apps/mediasoup-server/modules/mediasoup/Channel.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { Worker } from 'mediasoup/node/lib/Worker'; -import { types as MediasoupTypes } from 'mediasoup'; -import logger from 'modules/winston/logger'; -import { Router } from 'mediasoup/node/lib/Router'; -import { DtlsParameters, WebRtcTransport } from 'mediasoup/node/lib/WebRtcTransport'; -import { Socket } from 'socket.io'; -import { UserDocument } from 'models/user'; -import { MediaKind, RtpCapabilities, RtpParameters } from 'mediasoup/node/lib/RtpParameters'; -import { EventEmitter } from 'stream'; -import { ServerTransportParams } from '@common/types/mediasoup'; -import { ConsumerOptions } from 'mediasoup-client/lib/Consumer'; -import { routerOptions, webRtcTransportConfig } from './config'; - -/* * - * Represents a Voice-Channel: all transports, Producers and Consumers are managed using this Class - * Does NOT manages events for when a user joins/ leaves the channel - */ - -export class MediasoupChannel extends EventEmitter { - worker: Worker; - - router: Router | undefined; - - transports: WebRtcTransport[] = []; - - producers: MediasoupTypes.Producer[] = []; - - consumers: MediasoupTypes.Consumer[] = []; - - constructor(worker: Worker) { - super(); - this.worker = worker; - this.init(); - } - - async init() { - this.router = await this.worker.createRouter(/* routerOptions */ routerOptions); - } - - handleSocket(socket: Socket, user: UserDocument) { - if (!this.router || !this.worker) { - logger.warn('Rejected user connection: channel not yet initialized', { system: 'mediasoup' }); - return socket.emit('error-message', { error: 'channel not yet initialized' }); - } - if (this.producers.length >= Number(process.env.MAX_VOICE_CONNECTIONS)) { - logger.warn('Rejected user connection: to many connections', { system: 'mediasoup' }); - return socket.emit('error-message', { error: 'to many connections', system: 'mediasoup' }); - } - socket.emit('sfu-ready'); - - socket.on('get-rtp-capabilities', (callback) => { - const { rtpCapabilities } = this.router!; - - callback(rtpCapabilities); - }); - - socket.on('create-webrtc-transport', async (callback) => { - try { - const transport = await this.createWebRtcTransport(); - this.transports.push(transport); - - // how to delete unused transports which never connected to a client? - // For now: - socket.once('disconnect', () => { - transport.close(); - this.transports = this.transports.filter((t) => t.id !== transport.id); - }); - - // send the parameters for the created transport back to the client - // https://mediasoup.org/documentation/v3/mediasoup-client/api/#TransportOptions - callback({ - id: transport.id, - iceParameters: transport.iceParameters, - iceCandidates: transport.iceCandidates, - dtlsParameters: transport.dtlsParameters - } as ServerTransportParams); - } catch (err) { - const error = err as Error; - callback({ - error: error.message - }); - } - }); - - socket.on( - 'transport-connect', - async ({ dtlsParameters, transportId }: { dtlsParameters: DtlsParameters; transportId: string }) => { - try { - const transport = this.transports.find((t) => t.id === transportId); - - transport?.on('@close', () => { - this.transports = this.transports.filter((t) => t.id !== transportId); - }); - - if (!transport) return; - await transport.connect({ dtlsParameters }); - } catch (error) { - logger.warn('cannot connect transport', { service: 'mediasoup' }); - socket.emit('error-message', { error: (error as Error).message }); - } - } - ); - - socket.on( - 'transport-produce', - async ( - { - kind, - rtpParameters, - transportId - }: { kind: MediaKind; rtpParameters: RtpParameters; transportId: string }, - callback - ) => { - try { - const transport = this.transports.find((t) => t.id === transportId); - if (!transport) throw Error('transport not found'); - const producer = await transport.produce({ - kind, - rtpParameters, - paused: true - }); - // DEBUG - this.producers.push(producer); - this.emit('new-producer', { producerId: producer.id, user: user.getPublicUser() }); - - producer.appData.user = user.getPublicUser(); - producer.observer.on('pause', () => { - this.emit('producer-paused', { producerId: producer.id }); - }); - - producer.observer.on('resume', () => { - this.emit('producer-resumed', { producerId: producer.id }); - }); - - producer.on('transportclose', () => { - this.producers = this.producers.filter((p) => p.id !== producer.id); - this.emit('producer-closed', { producerId: producer.id }); - producer.close(); - }); - - // Send back to the client the Producer's id - callback({ - id: producer.id - }); - } catch (err) { - logger.warn(`Error while creating Producer on Transport! ${err}`, { service: 'mediasoup' }); - const error = err as Error; - callback({ error: error.message }); - } - } - ); - - socket.on( - 'transport-consume', - async ( - { - rtpCapabilities, - producerId, - transportId - }: { - rtpCapabilities: RtpCapabilities; - producerId: string; - transportId: string; - }, - callback - ) => { - try { - const transport = this.transports.find((t) => t.id === transportId); - if (!transport) { - socket.disconnect(); - throw Error('transport not found'); - } - - // check if the router can consume the specified producer - if ( - this.router!.canConsume({ - producerId, - rtpCapabilities - }) - ) { - // transport can now consume and return a consumer - const producer = this.producers.find((p) => p.id === producerId); - - if (!producer) { - socket.disconnect(); - throw Error(`producer ${producerId} not found`); - } - const consumer = await transport.consume({ - producerId, - rtpCapabilities, - appData: { user, producerId }, - paused: producer.paused // Important: otherwise remote video stays black, no audio - }); - - this.consumers.push(consumer); - - // Cannot detect when consumer is closed dirrectly - // Assuming that: - // 1. when producer is closed, consumer is also closed - // 2. when transport is closed, consumer is also closed - // 2. and when socket is closed, consumer is also closed - consumer.on('transportclose', () => { - consumer.close(); - }); - - consumer.observer.on('close', () => { - this.consumers = this.consumers.filter((c) => c.id !== consumer.id); - }); - - // Bad, because it adds a listener for each consumer - /* socket.once('disconnect', () => { - consumer.close(); - this.consumers = this.consumers.filter((c) => c.id !== consumer.id); - }); */ - - consumer.on('producerclose', () => { - consumer.close(); - }); - - // from the consumer extract the following params - // to send back to the Client - const consumerOptions: ConsumerOptions = { - id: consumer.id, - producerId, - kind: consumer.kind, - rtpParameters: consumer.rtpParameters, - appData: { user } - }; - - // send the parameters to the client - callback({ consumerOptions }); - } else { - logger.warn(`Cannot consume producer with id: ${producerId}`, { service: 'mediasoup' }); - callback({ - params: { - error: true - } - }); - } - } catch (err) { - const error = err as Error; - logger.error(`error while creating consumer ${error}`, { service: 'Mediasoup' }); - callback({ - error - }); - } - } - ); - - socket.on('consumer-resume', async ({ consumerId }: { consumerId: string }, resumeCallback) => { - logger.silly(`Resuming consumer with id ${consumerId}`, { service: 'mediasoup' }); - const consumer = this.consumers.find((c) => c.id === consumerId); - if (!consumer) return resumeCallback({ error: 'consumer not found' }); - await consumer.resume(); - if (typeof resumeCallback === 'function') { - resumeCallback(); - } - return true; - }); - socket.on('consumer-pause', async ({ consumerId }: { consumerId: string }, pauseCallback) => { - logger.silly(`Pausing consumer with id ${consumerId}`, { service: 'mediasoup' }); - const consumer = this.consumers.find((c) => c.id === consumerId); - if (!consumer) return pauseCallback({ error: 'consumer not found' }); - await consumer.pause(); - if (typeof pauseCallback === 'function') { - pauseCallback(); - } - return true; - }); - socket.on( - 'producer-resume', - async ({ producerId, source }: { producerId: string; source: string }, resumeCallback) => { - logger.silly(`Resuming producer with id ${producerId}`, { service: 'mediasoup' }); - - const producer = this.producers.find((p) => p.id === producerId); - if (!producer) return resumeCallback({ error: 'producer not found' }); - if (source === 'admin') { - this.producers.forEach((p) => p.pause()); - this.emit('producer-pausing-forced', { user: user.getPublicUser() }); - } - await producer.resume(); - if (resumeCallback) { - resumeCallback(); - } - return true; - } - ); - socket.on('producer-pause', async ({ producerId }: { producerId: string }, pauseCallback) => { - logger.silly(`Pausing producer with id ${producerId}`, { service: 'mediasoup' }); - const producer = this.producers.find((p) => p.id === producerId); - if (!producer) return pauseCallback({ error: 'producer not found' }); - await producer.pause(); - - if (typeof pauseCallback === 'function') { - pauseCallback(); - } - return true; - }); - - return true; - } - - async createWebRtcTransport(): Promise { - // https://mediasoup.org/documentation/v3/mediasoup/api/#router-createWebRtcTransport - const transport = await this.router!.createWebRtcTransport(webRtcTransportConfig); - transport.on('dtlsstatechange', (dtlsState) => { - // Not reliable - logger.silly('transport dtlsstatechange', { dtlsState, service: 'mediasoup' }); - if (dtlsState === 'closed') { - transport.close(); - this.transports = this.transports.filter((t) => t.id !== transport.id); - } - }); - - return transport; - } -} diff --git a/apps/mediasoup-server/modules/mediasoup/config.ts b/apps/mediasoup-server/modules/mediasoup/config.ts deleted file mode 100644 index 452acced..00000000 --- a/apps/mediasoup-server/modules/mediasoup/config.ts +++ /dev/null @@ -1,51 +0,0 @@ -import os from 'os'; -import { types as mediasoupTypes } from 'mediasoup'; - -export const workerSettings: mediasoupTypes.WorkerSettings = { - rtcMinPort: 2000, - rtcMaxPort: 2300, - logLevel: 'debug', - logTags: ['info', 'ice', 'dtls', 'rtp', 'srtp', 'rtcp', 'message'] -}; - -export const routerOptions: mediasoupTypes.RouterOptions = { - mediaCodecs: [ - { - kind: 'audio', - mimeType: 'audio/opus', - clockRate: 48000, - channels: 2 - }, - { - kind: 'video', - mimeType: 'video/VP8', - clockRate: 90000, - parameters: { - 'x-google-start-bitrate': 1000 - } - } - ] -}; - -export const webRtcTransportConfig: mediasoupTypes.WebRtcTransportOptions = { - // https://mediasoup.org/documentation/v3/mediasoup/api/#WebRtcTransportOptions - listenInfos: [ - { - protocol: 'tcp', - ip: '0.0.0.0', - announcedIp: process.env.MEDIASOUP_ANOUNCE_IP // public ip - }, - { - protocol: 'udp', - ip: '0.0.0.0', - announcedIp: process.env.MEDIASOUP_ANOUNCE_IP // public ip - } - ], - enableUdp: true, - enableTcp: true, - preferUdp: true -}; - -export default { - numWorkers: Object.keys(os.cpus()).length -}; diff --git a/apps/mediasoup-server/modules/mediasoup/worker.ts b/apps/mediasoup-server/modules/mediasoup/worker.ts deleted file mode 100644 index 8b7419c4..00000000 --- a/apps/mediasoup-server/modules/mediasoup/worker.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createWorker as mediasoupCreateWorker } from 'mediasoup'; -import logger from 'modules/winston/logger'; -import { workerSettings } from './config'; - -/** - * * For now, each channel uses its own worker - * ! Do not create more Workers than the number of CPU-Cores - */ - -export const createWorker = async () => { - const worker = await mediasoupCreateWorker(workerSettings); - logger.info(`Mediasoup worker created`, { service: 'mediasoup' }); - - worker.on('died', (error) => { - // This implies something serious happened, so kill the application - logger.error(`Mediasoup worker crashed! ${error}`, { error, service: 'mediasoup' }); - setTimeout(() => process.exit(1), 2000); // exit in 2 seconds - }); - - return worker; -}; diff --git a/apps/mediasoup-server/modules/redis.ts b/apps/mediasoup-server/modules/redis.ts deleted file mode 100644 index 02151d4a..00000000 --- a/apps/mediasoup-server/modules/redis.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createClient } from "redis"; - -export const pubClient = createClient(); -export const subClient = pubClient.duplicate(); - -Promise.all([pubClient.connect(), subClient.connect()]).then(() => { - console.log("Redis connected"); -}); diff --git a/apps/mediasoup-server/modules/socketJWTmiddleware.ts b/apps/mediasoup-server/modules/socketJWTmiddleware.ts deleted file mode 100644 index 6ec74573..00000000 --- a/apps/mediasoup-server/modules/socketJWTmiddleware.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ExtendedError, Server, Socket } from "socket.io"; -import { prisma } from "@repo/db"; -if (!process.env.DISPATCH_APP_TOKEN) - throw new Error("DISPATCH_APP_TOKEN is not defined"); - -export const jwtMiddleware = async ( - socket: Socket, - next: (err?: ExtendedError) => void, -) => { - try { - const { uid } = socket.handshake.auth; - if (!uid) return new Error("Authentication error"); - /* const token = socket.handshake.auth?.token; - if (!token) return new Error("Authentication error"); - const decoded = jwt.verify(token, process.env.DISPATCH_APP_TOKEN!); */ - // socket.data.userId = decoded.; // User ID lokal speichern - const user = await prisma.user.findUniqueOrThrow({ - where: { id: uid }, - }); - - socket.data.user = user; - - next(); - } catch (err) { - console.error(err); - next(new Error("Authentication error")); - } -}; diff --git a/apps/mediasoup-server/nodemon.json b/apps/mediasoup-server/nodemon.json deleted file mode 100644 index 8bb27568..00000000 --- a/apps/mediasoup-server/nodemon.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "watch": ["."], - "ext": "ts", - "exec": "tsx index.ts" -} \ No newline at end of file diff --git a/apps/mediasoup-server/package.json b/apps/mediasoup-server/package.json deleted file mode 100644 index b5a32169..00000000 --- a/apps/mediasoup-server/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "mediasoup-server", - "exports": { - "helpers": "./helper" - }, - "scripts": { - "dev": "nodemon", - "build": "tsc" - }, - "devDependencies": { - "@repo/db": "*", - "@repo/typescript-config": "*", - "@types/express": "^5.0.0", - "@types/node": "^22.13.5", - "@types/nodemailer": "^6.4.17", - "concurrently": "^9.1.2", - "typescript": "latest" - }, - "dependencies": { - "@react-email/components": "^0.0.33", - "@redis/json": "^1.0.7", - "@socket.io/redis-adapter": "^8.3.0", - "axios": "^1.7.9", - "cron": "^4.1.0", - "dotenv": "^16.4.7", - "express": "^4.21.2", - "jsonwebtoken": "^9.0.2", - "nodemailer": "^6.10.0", - "react": "^19.0.0", - "redis": "^4.7.0", - "socket.io": "^4.8.1" - } -} diff --git a/apps/mediasoup-server/socket-events/sfu.ts b/apps/mediasoup-server/socket-events/sfu.ts deleted file mode 100644 index 7cf78f4b..00000000 --- a/apps/mediasoup-server/socket-events/sfu.ts +++ /dev/null @@ -1,158 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Worker } from 'mediasoup/node/lib/types'; -import { UserDocument } from 'models/user'; -import { MediasoupChannel } from 'modules/mediasoup/Channel'; -import { createWorker } from 'modules/mediasoup/worker'; -import { Socket } from 'socket.io'; -import { User } from '@common/types/user'; -import { EventEmitter } from 'stream'; -import { setMemberNickname } from 'modules/bot/bot'; -import PilotController from './pilot'; - -interface SfuClient { - socket: Socket; - channelId: string; - user: UserDocument; -} - -interface ISfuController { - worker: Worker[]; - observer: EventEmitter; - clients: Map; - channel: Map; - init: () => void; - handle: (channelId: string, socket: Socket, user: UserDocument) => void; - disconnectUser: (userId: string) => void; -} - -const SfuController: ISfuController = { - clients: new Map(), - channel: new Map(), - observer: new EventEmitter(), - worker: [], - disconnectUser: async (userId: string) => { - SfuController.clients.forEach((client) => { - if (client.user._id.toString() === userId) { - client.socket.emit('error-message', { error: 'DISCONNECTED_BY_ADMIN' }); - - client.socket.disconnect(); - SfuController.clients.delete(client.socket.id); - } - }); - }, - init: async () => { - // Setup Worker and Channel - // Scalable! - SfuController.worker = [ - await createWorker(), - await createWorker(), - await createWorker(), - await createWorker(), - await createWorker() - ]; - SfuController.channel.set('1', new MediasoupChannel(SfuController.worker[0])); // LST_VAR_RD_01 - SfuController.channel.set('2', new MediasoupChannel(SfuController.worker[1])); // LST_VAR_RD_02 - SfuController.channel.set('3', new MediasoupChannel(SfuController.worker[2])); // LST_VAR_RD_03 - SfuController.channel.set('4', new MediasoupChannel(SfuController.worker[3])); // LST_VAR_RD_04 - SfuController.channel.set('x', new MediasoupChannel(SfuController.worker[4])); // LST_VAR_RESERVE - - // setup listends for new connections, producer-close event is handled by Channel - SfuController.channel.forEach((channel, channelId) => { - channel.on('producer-pausing-forced', ({ user }: { user: User.PublicUser }) => { - SfuController.clients.forEach((client) => { - if (client.channelId === channelId && client.user._id.toString() !== user.id) { - client.socket.emit('producer-pausing-forced', { user }); - } - }); - }); - channel.on('new-producer', ({ producerId, user }) => { - SfuController.clients.forEach((client) => { - if (client.channelId === channelId) { - client.socket.emit('new-producer', { producerId, user }); - } - }); - }); - channel.on('producer-closed', ({ producerId }) => { - SfuController.clients.forEach((client) => { - if (client.channelId === channelId) { - client.socket.emit('producer-closed', { producerId }); - } - }); - }); - channel.on('producer-paused', ({ producerId }) => { - SfuController.clients.forEach((client) => { - if (client.channelId === channelId) { - client.socket.emit('producer-paused', { producerId }); - } - }); - }); - channel.on('producer-resumed', ({ producerId }) => { - SfuController.clients.forEach((client) => { - if (client.channelId === channelId) { - client.socket.emit('producer-resumed', { producerId }); - } - }); - }); - }); - }, - handle: (channelId, socket, user) => { - const channel = SfuController.channel.get(channelId); - if (!channel) { - socket.emit('error-message', { error: 'invalid channel id' }); - return; - } - - // check for double connections - if ( - Array.from(SfuController.clients.values()).find( - (client) => client.user._id.toString() === user._id.toString() - ) && - process.env.ALLOW_DOUBLE_CONNECTION === 'false' - ) { - socket.emit('error-message', { error: 'DOUBLE_CONNECTION' }); - return; - } - - SfuController.clients.set(socket.id, { socket, channelId, user }); - - // Update Discord username for dispatcher - SfuController.observer.emit('channel-changed', channelId, user); - - const userInPilot = PilotController.clients.get(user._id.toString()); - - if (userInPilot) { - userInPilot.voiceChannel = channelId; - PilotController.clients.set(user._id.toString(), userInPilot); - PilotController.observer.emit('stations-changed'); - } - - channel.handleSocket(socket, user); - - socket.on('get-producers', (getPcb) => { - getPcb(channel.producers.map((p) => ({ producerId: p.id, user: p.appData.user, paused: p.paused }))); - }); - socket.on('disconnect', () => { - socket.removeAllListeners(); - SfuController.clients.delete(socket.id); - const userInPilotDisconnect = PilotController.clients.get(user._id.toString()); - - if (userInPilotDisconnect) { - userInPilotDisconnect.voiceChannel = undefined; - PilotController.clients.set(user._id.toString(), userInPilotDisconnect); - PilotController.observer.emit('stations-changed'); - } - }); - socket.on('set-should-transmit', (eventData: { shouldTransmit: boolean; source: string }) => { - SfuController.clients.forEach((client) => { - if (client.user._id.toString() === user._id.toString()) { - client.socket.emit('set-should-transmit', eventData); - } - }); - }); - } -}; - -SfuController.init(); - -export default SfuController; diff --git a/apps/mediasoup-server/tsconfig.json b/apps/mediasoup-server/tsconfig.json deleted file mode 100644 index ad90dfe0..00000000 --- a/apps/mediasoup-server/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "@repo/typescript-config/base.json", - "compilerOptions": { - "outDir": "dist", - "allowImportingTsExtensions": false, - "baseUrl": ".", - "jsx": "react" - }, - "include": ["**/*.ts", "./index.ts"], - "exclude": ["node_modules", "dist"] -} diff --git a/package-lock.json b/package-lock.json index a65d94d6..0489b47d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@tailwindcss/postcss": "^4.0.14", "leaflet": "^1.9.4", "livekit-client": "^2.9.7", + "lucide-react": "^0.482.0", "next": "^15.1.0", "next-auth": "^4.24.11", "postcss": "^8.5.1", @@ -76,6 +77,15 @@ "typescript": "latest" } }, + "apps/dispatch/node_modules/lucide-react": { + "version": "0.482.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.482.0.tgz", + "integrity": "sha512-XM8PzHzSrg8ATmmO+fzf+JyYlVVdQnJjuyLDj2p4V2zEtcKeBNAqAoJIGFv1x2HSBa7kT8gpYUxwdQ0g7nypfw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "apps/docs": { "version": "0.1.0", "dependencies": { @@ -162,6 +172,7 @@ } }, "apps/mediasoup-server": { + "extraneous": true, "dependencies": { "@react-email/components": "^0.0.33", "@redis/json": "^1.0.7", @@ -10159,10 +10170,6 @@ "node": ">= 0.6" } }, - "node_modules/mediasoup-server": { - "resolved": "apps/mediasoup-server", - "link": true - }, "node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", diff --git a/test/livekit.yaml b/test/livekit.yaml deleted file mode 100644 index 7c78d3f4..00000000 --- a/test/livekit.yaml +++ /dev/null @@ -1,11 +0,0 @@ -port: 7880 -rtc: - udp_port: 7882 - tcp_port: 7881 - use_external_ip: false - enable_loopback_candidate: false -keys: - APIAnsGdtdYp2Ho: tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC -logging: - json: false - level: info