moved to /dispatch and fixed Voice chat

This commit is contained in:
PxlLoewe
2025-04-15 19:43:06 -07:00
parent d0c779b66e
commit fd4be984b0
35 changed files with 658 additions and 49 deletions

View File

@@ -22,8 +22,8 @@ type TalkState = {
disconnect: () => void;
room: Room | null;
};
const getToken = async () => {
const response = await fetch(`/api/livekit/token`);
const getToken = async (roomName: string) => {
const response = await fetch(`/api/livekit/token?roomName=${roomName}`);
const data = await response.json();
return data.token;
};
@@ -37,6 +37,8 @@ export const useAudioStore = create<TalkState>((set, get) => ({
room: null,
toggleTalking: () => set((state) => ({ isTalking: !state.isTalking })),
connect: async (roomName) => {
set({ state: "connecting" });
console.log("Connecting to room: ", roomName);
try {
// Clean old room
const connectedRoom = get().room;
@@ -46,14 +48,12 @@ export const useAudioStore = create<TalkState>((set, get) => ({
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();
const token = await getToken(roomName);
if (!token) throw new Error("Fehlende Berechtigung");
console.log("Token: ", token);
const room = new Room();
const room = new Room({});
await room.prepareConnection(url, token);
room
// Connection events
@@ -75,6 +75,8 @@ export const useAudioStore = create<TalkState>((set, get) => ({
.on(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakerChange)
.on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished);
await room.connect(url, token);
console.log(room);
set({ room });
interval = setInterval(() => {
set({
remoteParticipants:

View File

@@ -1,6 +1,6 @@
import { create } from "zustand";
import { ChatMessage } from "@repo/db";
import { socket } from "(dispatch)/socket";
import { socket } from "dispatch/socket";
interface ChatStore {
ownId: null | string;

View File

@@ -1,5 +1,5 @@
import { create } from "zustand";
import { socket } from "../(dispatch)/socket";
import { socket } from "../dispatch/socket";
interface ConnectionStore {
isConnected: boolean;
@@ -12,7 +12,7 @@ interface ConnectionStore {
disconnect: () => void;
}
export const useConnectionStore = create<ConnectionStore>((set) => ({
export const useDispatchConnectionStore = create<ConnectionStore>((set) => ({
isConnected: false,
selectedZone: "LST_01",
connect: async (uid, selectedZone, logoffTime) =>
@@ -34,8 +34,8 @@ export const useConnectionStore = create<ConnectionStore>((set) => ({
}));
socket.on("connect", () => {
useConnectionStore.setState({ isConnected: true });
useDispatchConnectionStore.setState({ isConnected: true });
});
socket.on("disconnect", () => {
useConnectionStore.setState({ isConnected: false });
useDispatchConnectionStore.setState({ isConnected: false });
});

View File

@@ -1,5 +1,5 @@
import { create } from "zustand";
import { socket } from "../(dispatch)/socket";
import { socket } from "../dispatch/socket";
export const stationStore = create((set) => {
return {

View File

@@ -1,13 +1,19 @@
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { ROOMS } from "data/livekitRooms";
import { ROOMS } from "_data/livekitRooms";
import { AccessToken } from "livekit-server-sdk";
import { prisma } from "../../../../../../packages/database/prisma/client";
import { NextRequest } from "next/server";
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 () => {
export const GET = async (request: NextRequest) => {
const roomName = request.nextUrl.searchParams.get("roomName");
if (!roomName)
return Response.json({ message: "Missing roomName" }, { status: 400 });
const session = await getServerSession();
if (!session)
@@ -32,8 +38,12 @@ export const GET = async () => {
ttl: "1d",
},
);
ROOMS.forEach((roomName) => {
at.addGrant({ roomJoin: true, room: roomName, canPublish: true });
at.addGrant({
room: roomName,
roomJoin: true,
canPublish: true,
canSubscribe: true,
});
const token = await at.toJwt();

View File

@@ -2,11 +2,11 @@
import "leaflet/dist/leaflet.css";
import { useMapStore } from "_store/mapStore";
import { MapContainer } from "react-leaflet";
import { BaseMaps } from "(dispatch)/_components/map/BaseMaps";
import { ContextMenu } from "(dispatch)/_components/map/ContextMenu";
import { MissionMarkers } from "(dispatch)/_components/map/MissionMarkers";
import { SearchElements } from "(dispatch)/_components/map/SearchElements";
import { AircraftMarker } from "(dispatch)/_components/map/AircraftMarker";
import { BaseMaps } from "dispatch/_components/map/BaseMaps";
import { ContextMenu } from "dispatch/_components/map/ContextMenu";
import { MissionMarkers } from "dispatch/_components/map/MissionMarkers";
import { SearchElements } from "dispatch/_components/map/SearchElements";
import { AircraftMarker } from "dispatch/_components/map/AircraftMarker";
export default ({}) => {
const { map } = useMapStore();

View File

@@ -84,7 +84,6 @@ export const SearchElements = () => {
);
};
console.log("searchPopupElement", searchPopupElement, searchPopup);
return (
<>
{searchElements.map((element) => {

View File

@@ -0,0 +1,142 @@
"use client";
import { useEffect, useState } from "react";
import { useDispatchConnectionStore } from "_store/connectionStore";
import {
Disc,
Mic,
PlugZap,
ServerCrash,
ShieldQuestion,
Signal,
SignalLow,
SignalMedium,
WifiOff,
ZapOff,
} from "lucide-react";
import { useAudioStore } from "_store/audioStore";
import { cn } from "helpers/cn";
import { ConnectionQuality } from "livekit-client";
import { ROOMS } from "_data/livekitRooms";
export const Audio = () => {
const connection = useDispatchConnectionStore();
const {
isTalking,
toggleTalking,
connect,
state,
connectionQuality,
disconnect,
remoteParticipants,
room,
message,
} = useAudioStore();
const [selectedRoom, setSelectedRoom] = useState<string>("LST_01");
useEffect(() => {
const joinRoom = async () => {
if (!connection.isConnected) return;
if (state === "connected") return;
connect(selectedRoom);
};
joinRoom();
return () => {
disconnect();
};
}, [connection.isConnected]);
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 === "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",
)}
>
{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" && (
<details className="dropdown relative z-[1050]">
<summary className="dropdown btn btn-ghost flex items-center gap-1">
{connectionQuality === ConnectionQuality.Excellent && (
<Signal className="w-5 h-5" />
)}
{connectionQuality === ConnectionQuality.Good && (
<SignalMedium className="w-5 h-5" />
)}
{connectionQuality === ConnectionQuality.Poor && (
<SignalLow className="w-5 h-5" />
)}
{connectionQuality === ConnectionQuality.Lost && (
<ZapOff className="w-5 h-5" />
)}
{connectionQuality === ConnectionQuality.Unknown && (
<ShieldQuestion className="w-5 h-5" />
)}
<div className="badge badge-sm badge-soft badge-success">
{remoteParticipants}
</div>
</summary>
<ul className="menu dropdown-content bg-base-200 rounded-box z-[1050] w-52 p-2 shadow-sm">
{ROOMS.map((r) => (
<li key={r}>
<button
className="btn btn-sm btn-ghost text-left flex items-center justify-start gap-2 relative"
onClick={() => {
if (selectedRoom === r) return;
setSelectedRoom(r);
connect(r);
}}
>
{room?.name === r && (
<Disc
className="text-success text-sm absolute left-2"
width={15}
/>
)}
<span className="flex-1 text-center">{r}</span>
</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>
)}
</div>
</>
);
};

View File

@@ -6,7 +6,7 @@ import { Fragment, useEffect, useRef, useState } from "react";
import {
Dispatcher,
getDispatcher,
} from "(dispatch)/_components/navbar/_components/action";
} from "dispatch/_components/navbar/_components/action";
import { cn } from "helpers/cn";
export const Chat = () => {

View File

@@ -1,12 +1,11 @@
"use client";
import { useSession } from "next-auth/react";
import { useConnectionStore } from "../../../../_store/connectionStore";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useDispatchConnectionStore } from "../../../../_store/connectionStore";
import { useRef, useState } from "react";
export const ConnectionBtn = () => {
const modalRef = useRef<HTMLDialogElement>(null);
const connection = useConnectionStore((state) => state);
const connection = useDispatchConnectionStore((state) => state);
const [form, setForm] = useState({
logoffTime: "",
selectedZone: "LST_01",

View File

@@ -1,4 +1,4 @@
import { Missions } from "(dispatch)/_components/pannel/Missions";
import { Missions } from "dispatch/_components/pannel/Missions";
import { usePannelStore } from "_store/pannelStore";
import { cn } from "helpers/cn";

View File

@@ -2,10 +2,9 @@ import type { Metadata } from "next";
import Navbar from "./_components/navbar/Navbar";
import { redirect } from "next/navigation";
import { getServerSession } from "../api/auth/[...nextauth]/auth";
import { Toaster } from "react-hot-toast";
export const metadata: Metadata = {
title: "VAR Leitstelle v2",
title: "VAR v2: Disponent",
description: "Die neue VAR Leitstelle.",
};
@@ -20,21 +19,6 @@ export default async function RootLayout({
}
return (
<>
<Toaster
containerStyle={{
top: 150,
left: 20,
right: 20,
}}
toastOptions={{
style: {
background: "var(--color-base-100)",
color: "var(--color-base-content)",
},
}}
position="top-left"
reverseOrder={false}
/>
<Navbar />
{children}
</>

View File

@@ -1,8 +1,8 @@
"use client";
import { OpenButton } from "(dispatch)/_components/pannel/OpenButton";
import { Pannel } from "(dispatch)/_components/pannel/Pannel";
import MapToastCard2 from "(dispatch)/_components/toast/ToastCard";
import { OpenButton } from "dispatch/_components/pannel/OpenButton";
import { Pannel } from "dispatch/_components/pannel/Pannel";
import MapToastCard2 from "dispatch/_components/toast/ToastCard";
import { usePannelStore } from "_store/pannelStore";
import { cn } from "helpers/cn";
import dynamic from "next/dynamic";

View File

@@ -3,6 +3,7 @@ import localFont from "next/font/local";
import "./globals.css";
import { NextAuthSessionProvider } from "./_components/AuthSessionProvider";
import { getServerSession } from "./api/auth/[...nextauth]/auth";
import { Toaster } from "react-hot-toast";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
@@ -29,6 +30,21 @@ export default async function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} h-screen flex flex-col overflow-hidden`}
>
<Toaster
containerStyle={{
top: 150,
left: 20,
right: 20,
}}
toastOptions={{
style: {
background: "var(--color-base-100)",
color: "var(--color-base-content)",
},
}}
position="top-left"
reverseOrder={false}
/>
<NextAuthSessionProvider session={session}>
{children}
</NextAuthSessionProvider>

View File

@@ -0,0 +1,8 @@
export default () => {
return (
<div>
Hab das hier mal nach /dispatcher verschoben. Unter /pilot soll dann die
Piloten UI sein
</div>
);
};

View File

@@ -17,7 +17,7 @@ import {
import { useAudioStore } from "_store/audioStore";
import { cn } from "helpers/cn";
import { ConnectionQuality } from "livekit-client";
import { ROOMS } from "data/livekitRooms";
import { ROOMS } from "_data/livekitRooms";
export const Audio = () => {
const connection = useConnectionStore();

View File

@@ -0,0 +1,220 @@
"use client";
import { ChatBubbleIcon, PaperPlaneIcon } from "@radix-ui/react-icons";
import { useChatStore } from "_store/chatStore";
import { useSession } from "next-auth/react";
import { Fragment, useEffect, useRef, useState } from "react";
import {
Dispatcher,
getDispatcher,
} from "dispatch/_components/navbar/_components/action";
import { cn } from "helpers/cn";
export const Chat = () => {
const {
chatOpen,
setChatOpen,
sendMessage,
addChat,
chats,
setOwnId,
selectedChat,
setSelectedChat,
setChatNotification,
} = useChatStore();
const [sending, setSending] = useState(false);
const session = useSession();
const [addTabValue, setAddTabValue] = useState<string>("");
const [message, setMessage] = useState<string>("");
const [dispatcher, setDispatcher] = useState<Dispatcher[] | null>(null);
const timeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!session.data?.user.id) return;
setOwnId(session.data.user.id);
}, [session]);
useEffect(() => {
const fetchDispatcher = async () => {
const data = await getDispatcher();
if (data) {
const filteredDispatcher = data.filter((user) => {
return (
user.userId !== session.data?.user.id &&
!Object.keys(chats).includes(user.userId)
);
});
setDispatcher(filteredDispatcher);
}
if (!addTabValue && data[0]) setAddTabValue(data[0].userId);
};
timeout.current = setInterval(() => {
fetchDispatcher();
}, 1000000);
fetchDispatcher();
return () => {
if (timeout.current) {
clearInterval(timeout.current);
timeout.current = null;
console.log("cleared");
}
};
}, [addTabValue, chats]);
return (
<div
className={cn("dropdown dropdown-center", chatOpen && "dropdown-open")}
>
<div className="indicator">
{Object.values(chats).some((c) => c.notification) && (
<span className="indicator-item status status-info"></span>
)}
<button
className="btn btn-soft btn-sm btn-primary"
onClick={() => {
console.log("clicked");
setChatOpen(!chatOpen);
if (selectedChat) {
setChatNotification(selectedChat, false);
}
}}
>
<ChatBubbleIcon className="w-4 h-4" />
</button>
</div>
{chatOpen && (
<div
tabIndex={0}
className="dropdown-content card bg-base-200 w-150 shadow-md z-[1100]"
>
<div className="card-body">
<div className="join">
<select
className="select select-sm w-full"
value={addTabValue}
onChange={(e) => setAddTabValue(e.target.value)}
>
{!dispatcher?.length && (
<option disabled={true}>Keine Chatpartner gefunden</option>
)}
{dispatcher?.map((user) => (
<option key={user.userId} value={user.userId}>
{user.name}
</option>
))}
</select>
<button
className="btn btn-sm btn-soft btn-primary join-item"
onClick={() => {
const user = dispatcher?.find(
(user) => user.userId === addTabValue,
);
if (!user) return;
addChat(addTabValue, user.name);
setSelectedChat(addTabValue);
}}
>
<span className="text-xl">+</span>
</button>
</div>
<div className="tabs tabs-lift">
{Object.keys(chats).map((userId) => {
const chat = chats[userId];
if (!chat) return null;
return (
<Fragment key={userId}>
<input
type="radio"
name="my_tabs_3"
className="tab"
aria-label={`<${chat.name}>`}
checked={selectedChat === userId}
onClick={() => {
setChatNotification(userId, false);
}}
onChange={(e) => {
if (e.target.checked) {
// Handle tab change
setSelectedChat(userId);
}
}}
/>
<div className="tab-content bg-base-100 border-base-300 p-6">
{chat.messages.map((chatMessage) => {
const isSender =
chatMessage.senderId === session.data?.user.id;
return (
<div
key={chatMessage.id}
className={`chat ${isSender ? "chat-end" : "chat-start"}`}
>
<p className="chat-footer opacity-50">
{new Date(
chatMessage.timestamp,
).toLocaleTimeString()}
</p>
<div className="chat-bubble">
{chatMessage.text}
</div>
</div>
);
})}
{!chat.messages.length && (
<p className="text-xs opacity-50">
Noch keine Nachrichten
</p>
)}
</div>
</Fragment>
);
})}
</div>
<div className="join">
<div className="w-full">
<label className="input join-item w-full">
<input
type="text"
required
className="w-full"
onChange={(e) => {
setMessage(e.target.value);
}}
value={message}
/>
</label>
</div>
<button
className="btn btn-soft join-item"
onClick={(e) => {
e.preventDefault();
if (message.length < 1) return;
if (!selectedChat) return;
setSending(true);
sendMessage(selectedChat, message)
.then(() => {
setMessage("");
setSending(false);
})
.catch(() => {
setSending(false);
});
return false;
}}
disabled={sending}
role="button"
onSubmit={(e) => false}
>
{sending ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
<PaperPlaneIcon />
)}
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,107 @@
"use client";
import { useSession } from "next-auth/react";
import { useDispatchConnectionStore } from "../../../_store/connectionStore";
import { useRef, useState } from "react";
export const ConnectionBtn = () => {
const modalRef = useRef<HTMLDialogElement>(null);
const connection = useDispatchConnectionStore((state) => state);
const [form, setForm] = useState({
logoffTime: "",
selectedZone: "LST_01",
});
const session = useSession();
const uid = session.data?.user?.id;
if (!uid) return null;
return (
<>
{!connection.isConnected ? (
<button
className="btn btn-soft btn-info"
onClick={() => modalRef.current?.showModal()}
>
Verbinden
</button>
) : (
<button
className="btn btn-soft btn-success"
onClick={() => modalRef.current?.showModal()}
>
Verbunden
</button>
)}
<dialog ref={modalRef} className="modal">
<div className="modal-box flex flex-col items-center justify-center">
{connection.isConnected ? (
<h3 className="text-lg font-bold mb-5">
Verbunden als{" "}
<span className="text-info">
&lt;{connection.selectedZone}&gt;
</span>
</h3>
) : (
<h3 className="text-lg font-bold mb-5">Als Disponent anmelden</h3>
)}
<fieldset className="fieldset w-full">
<label className="floating-label w-full text-base">
<span>Logoff Zeit (UTC+1)</span>
<input
onChange={(e) =>
setForm({
...form,
logoffTime: e.target.value,
})
}
value={form.logoffTime}
type="time"
className="input w-full"
/>
</label>
{!connection.isConnected && (
<p className="fieldset-label">
Du kannst diese Zeit später noch anpassen.
</p>
)}
</fieldset>
<div className="modal-action flex justify-between w-full">
<form method="dialog" className="w-full flex justify-between">
<button className="btn btn-soft">Abbrechen</button>
{connection.isConnected ? (
<button
className="btn btn-soft btn-error"
type="submit"
onSubmit={() => false}
onClick={() => {
connection.disconnect();
}}
>
Verbindung Trennen
</button>
) : (
<button
type="submit"
onSubmit={() => false}
onClick={() => {
connection.connect(uid, form.selectedZone, form.logoffTime);
}}
className="btn btn-soft btn-info"
>
Verbinden
</button>
)}
</form>
</div>
</div>
</dialog>
</>
);
};
export const Connection = () => {
return (
<div>
<ConnectionBtn />
</div>
);
};

View File

@@ -0,0 +1,52 @@
"use client";
import { Connection } from "./Connection";
import { ThemeSwap } from "./ThemeSwap";
import { Audio } from "./Audio";
import { useState } from "react";
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
import { Chat } from "./Chat";
export default function Navbar() {
const [isDark, setIsDark] = useState(false);
const toggleTheme = () => {
const newTheme = !isDark;
setIsDark(newTheme);
document.documentElement.setAttribute(
"data-theme",
newTheme ? "nord" : "dark",
);
};
return (
<div className="navbar bg-base-100 shadow-sm flex gap-5 justify-between">
<div className="flex items-center gap-2">
<a className="btn btn-ghost text-xl">VAR Leitstelle V2</a>
</div>
<div className="bg-base-200 rounded-box flex items-center gap-2 p-1">
<div className="flex items-center gap-2">
<Chat />
</div>
</div>
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
<Audio />
</div>
<div className="flex items-center">
<Connection />
</div>
<ThemeSwap isDark={isDark} toggleTheme={toggleTheme} />
<div className="flex items-center">
<button className="btn btn-ghost">
<ExternalLinkIcon className="w-4 h-4" /> HUB
</button>
<button className="btn btn-ghost">
<ExitIcon className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
interface ThemeSwapProps {
isDark: boolean;
toggleTheme: () => void;
}
export const ThemeSwap: React.FC<ThemeSwapProps> = ({
isDark,
toggleTheme,
}) => {
return (
<label className="swap swap-rotate">
<input
type="checkbox"
className="theme-controller"
checked={isDark}
onChange={toggleTheme}
/>
<MoonIcon className="swap-off h-5 w-5 fill-current" />
<SunIcon className="swap-on h-5 w-5 fill-current" />
</label>
);
};

View File

@@ -0,0 +1,18 @@
"use server";
export interface Dispatcher {
userId: string;
lastSeen: string;
loginTime: string;
logoffTime: string;
selectedZone: string;
name: string;
socketId: string;
}
export const getDispatcher = async () => {
const res = await fetch(`
${process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL}/dispatcher`);
const data = await res.json();
return data as Dispatcher[];
};

View File

@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import Navbar from "../dispatch/_components/navbar/Navbar";
import { redirect } from "next/navigation";
import { getServerSession } from "../api/auth/[...nextauth]/auth";
export const metadata: Metadata = {
title: "VAR v2: Disponent",
description: "Die neue VAR Leitstelle.",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await getServerSession();
if (!session) {
redirect("/login");
}
return (
<>
<Navbar />
{children}
</>
);
}