This commit is contained in:
nocnico
2025-03-19 19:44:00 +01:00
12 changed files with 215 additions and 51 deletions

View File

@@ -1,7 +1,9 @@
NEXTAUTH_SECRET=
NEXTAUTH_COOKIE_PREFIX=
NEXTAUTH_SECRET=dispatch
NEXTAUTH_COOKIE_PREFIX=DISPATCH
NEXT_PUBLIC_DISPATCH_SERVER_URL=http://localhost:3002
NEXTAUTH_URL=http://localhost:3001
NEXT_PUBLIC_PUBLIC_URL=http://localhost:3001
NEXT_PUBLIC_HUB_URL=http://localhost:3000
NEXT_PUBLIC_PUBLIC_URL=http://localhost:3001
NEXT_PUBLIC_SERVICE_ID=1
NEXT_PUBLIC_DISPATCH_SERVER_URL=http://localhost:3002
DATABASE_URL=postgresql://persistant-data:persistant-data-pw@localhost:5432/var
NEXT_PUBLIC_LIVEKIT_URL=ws://localhost:7880

View File

@@ -6,6 +6,7 @@ import {
Disc,
Mic,
PlugZap,
ServerCrash,
ShieldQuestion,
Signal,
SignalLow,
@@ -16,8 +17,7 @@ import {
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"];
import { ROOMS } from "data/livekitRooms";
export const Audio = () => {
const connection = useConnectionStore();
@@ -30,6 +30,7 @@ export const Audio = () => {
disconnect,
remoteParticipants,
room,
message,
} = useAudioStore();
const [selectedRoom, setSelectedRoom] = useState<string>("LST_01");
@@ -50,16 +51,21 @@ export const Audio = () => {
return (
<>
<div className="bg-base-200 rounded-box flex items-center gap-2 p-1">
{state === "error" && (
<div className="h-4 flex items-center">{message}</div>
)}
<button
onClick={() => {
if (state === "connected") toggleTalking();
if (state === "disconnected") connect(selectedRoom);
if (state === "error" || state === "disconnected")
connect(selectedRoom);
}}
className={cn(
"btn btn-sm btn-soft border-none hover:bg-inherit",
!isTalking && "bg-transparent hover:bg-sky-400/20",
isTalking && "bg-red-500 hover:bg-red-600",
state === "disconnected" && "bg-red-500 hover:bg-red-500",
state === "error" && "bg-red-500 hover:bg-red-500",
state === "connecting" &&
"bg-yellow-500 hover:bg-yellow-500 cursor-default",
)}
@@ -67,6 +73,7 @@ export const Audio = () => {
{state === "connected" && <Mic className="w-5 h-5" />}
{state === "disconnected" && <WifiOff className="w-5 h-5" />}
{state === "connecting" && <PlugZap className="w-5 h-5" />}
{state === "error" && <ServerCrash className="w-5 h-5" />}
</button>
{state === "connected" && (
@@ -112,6 +119,20 @@ export const Audio = () => {
</button>
</li>
))}
<li>
<button
className="btn btn-sm btn-ghost text-left flex items-center justify-start gap-2 relative"
onClick={() => {
disconnect();
}}
>
<WifiOff
className="text-error text-sm absolute left-2"
width={15}
/>
<span className="flex-1 text-center">Disconnect</span>
</button>
</li>
</ul>
</details>
)}

View File

@@ -12,7 +12,8 @@ let interval: NodeJS.Timeout;
type TalkState = {
isTalking: boolean;
state: "connecting" | "connected" | "disconnected";
state: "connecting" | "connected" | "disconnected" | "error";
message: string | null;
connectionQuality: ConnectionQuality;
remoteParticipants: number;
toggleTalking: () => void;
@@ -21,60 +22,73 @@ type TalkState = {
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 getToken = async () => {
const response = await fetch(`/api/livekit/token`);
const data = await response.json();
return data.token;
};
export const useAudioStore = create<TalkState>((set, get) => ({
isTalking: false,
message: null,
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();
try {
// Clean old room
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();
if (!token) throw new Error("Fehlende Berechtigung");
console.log("Token: ", token);
const room = new Room();
await room.prepareConnection(url, token);
room
// Connection events
.on(RoomEvent.Connected, () => {
set({ state: "connected", room, message: null });
})
.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);
} catch (error: Error | unknown) {
console.error("Error occured: ", error);
if (error instanceof Error) {
set({ state: "error", message: error.message });
} else {
set({ state: "error", message: "Unknown error" });
}
}
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();

View File

@@ -0,0 +1,44 @@
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { ROOMS } from "data/livekitRooms";
import { AccessToken } from "livekit-server-sdk";
import { prisma } from "../../../../../../packages/database/prisma/client";
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");
export const GET = async () => {
const session = await getServerSession();
console.log(session?.user.permissions);
if (!session)
return Response.json({ message: "Unauthorized" }, { status: 401 });
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
});
console.log(user);
if (!user || !user.permissions.includes("AUDIO"))
return Response.json({ message: "Missing permissions" }, { status: 401 });
const participantName = user.publicId;
const at = new AccessToken(
process.env.LIVEKIT_API_KEY,
process.env.LIVEKIT_API_SECRET,
{
identity: participantName,
// Token to expire after 10 minutes
ttl: "1d",
},
);
ROOMS.forEach((roomName) => {
at.addGrant({ roomJoin: true, room: roomName, canPublish: true });
});
const token = await at.toJwt();
return Response.json({ token });
};

View File

@@ -0,0 +1 @@
export const ROOMS = ["LST_01", "LST_02", "LST_03", "LST_04", "LST_05"];

View File

@@ -18,6 +18,7 @@
"@tailwindcss/postcss": "^4.0.14",
"leaflet": "^1.9.4",
"livekit-client": "^2.9.7",
"livekit-server-sdk": "^2.10.2",
"lucide-react": "^0.482.0",
"next": "^15.1.0",
"next-auth": "^4.24.11",