added force end transmition for dispatchers

This commit is contained in:
PxlLoewe
2025-05-30 22:47:02 -07:00
parent eaedd78202
commit 6950e24e54
13 changed files with 179 additions and 100 deletions

View File

@@ -1,34 +0,0 @@
import { Router } from "express";
import { AccessToken } from "livekit-server-sdk";
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 (roomName: string) => {
// If this room doesn't exist, it'll be automatically created when the first
// participant joins
// 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);
const at = new AccessToken(process.env.LIVEKIT_API_KEY, process.env.LIVEKIT_API_SECRET, {
identity: participantName,
// Token to expire after 10 minutes
ttl: "10m",
});
at.addGrant({ roomJoin: true, room: roomName });
return await at.toJwt();
};
const router: Router = Router();
router.get("/token", async (req, res) => {
const roomName = req.query.roomName as string;
res.send({
token: await createToken(roomName),
});
});
export default router;

View File

@@ -1,5 +1,4 @@
import { Router } from "express";
import livekitRouter from "./livekit";
import dispatcherRotuer from "./dispatcher";
import missionRouter from "./mission";
import statusRouter from "./status";
@@ -8,7 +7,6 @@ import reportRouter from "./report";
const router: Router = Router();
router.use("/livekit", livekitRouter);
router.use("/dispatcher", dispatcherRotuer);
router.use("/mission", missionRouter);
router.use("/status", statusRouter);

View File

@@ -62,6 +62,7 @@ export const handleConnectDispatch =
esimatedLogoutTime: parsedLogoffDate?.toISOString() || null,
lastHeartbeat: new Date().toISOString(),
userId: user.id,
zone: selectedZone,
loginTime: new Date().toISOString(),
},
});
@@ -72,20 +73,29 @@ export const handleConnectDispatch =
io.to("dispatchers").emit("dispatchers-update");
io.to("pilots").emit("dispatchers-update");
// dispatch-events
socket.on("ptt", async ({ shouldTransmit, channel }) => {
if (shouldTransmit) {
io.to("dispatchers").emit("other-ptt", {
publicUser: getPublicUser(user),
channel,
source: "Leitstelle",
socket.on("stop-other-transmition", async ({ ownRole, otherRole }) => {
const aircrafts = await prisma.connectedAircraft.findMany({
where: {
Station: {
bosCallsignShort: otherRole,
},
logoutTime: null,
},
include: {
Station: true,
},
});
const dispatchers = await prisma.connectedDispatcher.findMany({
where: {
zone: otherRole,
logoutTime: null,
},
});
[...aircrafts, ...dispatchers].forEach((entry) => {
io.to(`user:${entry.userId}`).emit("force-end-transmission", {
by: ownRole,
});
io.to("piots").emit("other-ptt", {
publicUser: getPublicUser(user),
channel,
source: "Leitstelle",
});
}
});
});
socket.on("disconnect", async () => {

View File

@@ -18,12 +18,16 @@ import { useAudioStore } from "_store/audioStore";
import { cn } from "_helpers/cn";
import { ConnectionQuality } from "livekit-client";
import { ROOMS } from "_data/livekitRooms";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { useSession } from "next-auth/react";
import { dispatchSocket } from "dispatch/socket";
export const Audio = () => {
const connection = usePilotConnectionStore();
const [showSource, setShowSource] = useState(false);
const {
speakingParticipants,
isTalking,
toggleTalking,
connect,
@@ -33,50 +37,105 @@ export const Audio = () => {
remoteParticipants,
room,
message,
source,
removeMessage,
} = useAudioStore();
const [selectedRoom, setSelectedRoom] = useState<string>("LST_01");
useEffect(() => {
setShowSource(true);
}, [source, isTalking]);
const { selectedStation, status: pilotState } = usePilotConnectionStore((state) => state);
const { selectedZone, status: dispatcherState } = useDispatchConnectionStore((state) => state);
const session = useSession();
const [recentSpeakers, setRecentSpeakers] = useState<typeof speakingParticipants>([]);
useEffect(() => {
const joinRoom = async () => {
if (connection.status != "connected") return;
if (state === "connected") return;
connect(selectedRoom);
};
joinRoom();
return () => {
disconnect();
};
if (speakingParticipants.length > 0) {
setRecentSpeakers(speakingParticipants);
} else if (recentSpeakers.length > 0) {
const timeout = setTimeout(() => {
setRecentSpeakers([]);
}, 10000);
return () => clearTimeout(timeout);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connection.status]);
}, [speakingParticipants]);
useEffect(() => {
if (message && state !== "error") {
const timeout = setTimeout(() => {
removeMessage();
}, 10000);
return () => clearTimeout(timeout);
}
}, [message, removeMessage, state]);
const displayedSpeakers = speakingParticipants.length > 0 ? speakingParticipants : recentSpeakers;
const canStopOtherSpeakers = dispatcherState === "connected";
const role =
(dispatcherState === "connected" && selectedZone) ||
(pilotState == "connected" && selectedStation?.bosCallsignShort) ||
session.data?.user?.publicId;
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>}
{showSource && source && (
{message && (
<div
className="tooltip tooltip-left tooltip-error font-semibold"
className="tooltip tooltip-left tooltip-warning font-semibold"
data-tip="Nachricht entfernen"
>
<button
className={cn("btn btn-sm btn-ghost border-warning bg-transparent ")}
onClick={() => {
removeMessage();
// Probably via socket event to set ppt = false for participant
if (!canStopOtherSpeakers) return;
speakingParticipants.forEach((p) => {
dispatchSocket.emit("stop-other-transmition", {
ownRole: role,
otherRole: p.attributes.role,
});
});
}}
>
{message}
</button>
</div>
)}
{(displayedSpeakers.length || message) && (
<div
className={cn(
"tooltip-left tooltip-error font-semibold",
canStopOtherSpeakers && "tooltip",
)}
data-tip="Funkspruch unterbrechen"
>
<button
className="btn btn-sm btn-soft border-none bg-transparent hover:bg-error"
onClick={() => {}}
className={cn(
"btn btn-sm btn-soft border-none bg-transparent",
canStopOtherSpeakers && "hover:bg-error",
)}
onClick={() => {
if (!canStopOtherSpeakers) return;
speakingParticipants.forEach((p) => {
dispatchSocket.emit("stop-other-transmition", {
ownRole: role,
otherRole: p.attributes.role,
});
});
}}
>
{source}
{displayedSpeakers.map((p) => p.attributes.role).join(", ") || ""}
</button>
</div>
)}
<button
onClick={() => {
if (state === "connected") toggleTalking();
if (state === "error" || state === "disconnected") connect(selectedRoom);
if (!role) return;
if (state === "error" || state === "disconnected") connect(selectedRoom, role);
}}
className={cn(
"btn btn-sm btn-soft border-none hover:bg-inherit",
@@ -111,9 +170,10 @@ export const Audio = () => {
<button
className="btn btn-sm btn-ghost text-left flex items-center justify-start gap-2 relative"
onClick={() => {
if (!role) return;
if (selectedRoom === r) return;
setSelectedRoom(r);
connect(r);
connect(r, role);
}}
>
{room?.name === r && (

View File

@@ -59,7 +59,8 @@ const PopupContent = ({
{missions.map((mission) => {
const needsAction =
HPGValidationRequired(mission.missionStationIds, aircrafts, mission.hpgMissionString) &&
mission.hpgValidationState !== "VALID";
mission.hpgValidationState !== HpgValidationState.VALID &&
mission.state === "draft";
const markerColor = needsAction
? MISSION_STATUS_COLORS["attention"]

View File

@@ -1,3 +1,4 @@
import { useAudioStore } from "_store/audioStore";
import {
LocalParticipant,
LocalTrackPublication,
@@ -13,6 +14,20 @@ export const handleTrackSubscribed = (
publication: RemoteTrackPublication,
participant: RemoteParticipant,
) => {
console.log("Track subscribed:", track, publication, participant);
if (!track.isMuted) {
useAudioStore.getState().addSpeakingParticipant(participant);
}
track.on("unmuted", () => {
useAudioStore.getState().addSpeakingParticipant(participant);
console.log("Track unmuted:", track);
});
track.on("muted", () => {
useAudioStore.getState().removeSpeakingParticipant(participant);
console.log("Track muted:", track);
});
if (track.kind === Track.Kind.Video || track.kind === Track.Kind.Audio) {
// attach it to a new HTMLVideoElement or HTMLAudioElement
const element = track.attach();
@@ -37,10 +52,6 @@ export const handleLocalTrackUnpublished = (
publication.track?.detach();
};
export const handleActiveSpeakerChange = (speakers: Participant[]) => {
// show UI indicators when participant is speaking
};
export const handleDisconnect = () => {
console.log("disconnected from room");
};

View File

@@ -2,15 +2,15 @@ import { PublicUser } from "@repo/db";
import { dispatchSocket } from "dispatch/socket";
import { serverApi } from "_helpers/axios";
import {
handleActiveSpeakerChange,
handleDisconnect,
handleLocalTrackUnpublished,
handleTrackSubscribed,
handleTrackUnsubscribed,
} from "_helpers/liveKitEventHandler";
import { ConnectionQuality, Room, RoomEvent } from "livekit-client";
import { ConnectionQuality, Participant, Room, RoomEvent } from "livekit-client";
import { pilotSocket } from "pilot/socket";
import { create } from "zustand";
import axios from "axios";
let interval: NodeJS.Timeout;
@@ -18,19 +18,22 @@ type TalkState = {
micDeviceId: string | null;
micVolume: number;
isTalking: boolean;
source: string;
removeMessage: () => void;
state: "connecting" | "connected" | "disconnected" | "error";
message: string | null;
connectionQuality: ConnectionQuality;
remoteParticipants: number;
toggleTalking: () => void;
setMic: (micDeviceId: string | null, volume: number) => void;
connect: (roomName: string) => void;
connect: (roomName: string, role: string) => void;
disconnect: () => void;
speakingParticipants: Participant[];
addSpeakingParticipant: (participant: Participant) => void;
removeSpeakingParticipant: (speakingParticipants: Participant) => void;
room: Room | null;
};
const getToken = async (roomName: string) => {
const response = await serverApi.get(`/livekit/token?roomName=${roomName}`);
const response = await axios.get(`/api/livekit-token?roomName=${roomName}`);
const data = response.data;
return data.token;
};
@@ -39,12 +42,31 @@ export const useAudioStore = create<TalkState>((set, get) => ({
isTalking: false,
message: null,
micDeviceId: null,
speakingParticipants: [],
micVolume: 1,
state: "disconnected",
source: "",
remoteParticipants: 0,
connectionQuality: ConnectionQuality.Unknown,
room: null,
addSpeakingParticipant: (participant) => {
set((state) => {
if (!state.speakingParticipants.some((p) => p.identity === participant.identity)) {
return { speakingParticipants: [...state.speakingParticipants, participant] };
}
return state;
});
},
removeMessage: () => {
set({ message: null });
},
removeSpeakingParticipant: (participant) => {
set((state) => ({
speakingParticipants: state.speakingParticipants.filter(
(p) => !(p.identity === participant.identity),
),
}));
},
setMic: (micDeviceId, micVolume) => {
set({ micDeviceId, micVolume });
},
@@ -71,9 +93,9 @@ export const useAudioStore = create<TalkState>((set, get) => ({
});
}
set((state) => ({ isTalking: !state.isTalking, source: "web-app" }));
set((state) => ({ isTalking: !state.isTalking }));
},
connect: async (roomName) => {
connect: async (roomName, role) => {
set({ state: "connecting" });
console.log("Connecting to room: ", roomName);
try {
@@ -107,9 +129,11 @@ export const useAudioStore = create<TalkState>((set, get) => ({
// Track events
.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
.on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed)
.on(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakerChange)
.on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished);
await room.connect(url, token, {});
room.localParticipant.setAttributes({
role,
});
set({ room });
interval = setInterval(() => {
@@ -137,14 +161,12 @@ interface PTTData {
}
const handlePTT = (data: PTTData) => {
console.log("PTT", data);
const { shouldTransmit, source } = data;
const { room } = useAudioStore.getState();
if (!room) return;
useAudioStore.setState({
isTalking: shouldTransmit,
source,
});
if (shouldTransmit) {
@@ -154,16 +176,19 @@ const handlePTT = (data: PTTData) => {
}
};
const handleOtherPTT = (data: { publicUser: PublicUser; channel: string; source: string }) => {
const currentChannel = useAudioStore.getState().room?.name;
console.log("Other PTT", data);
if (data.channel === currentChannel)
useAudioStore.setState({
source: data.source,
});
const handleForceEndTransmission = ({ by }: { by?: string }) => {
const { room } = useAudioStore.getState();
if (!room) return;
room.localParticipant.setMicrophoneEnabled(false);
useAudioStore.setState({
isTalking: false,
message: `Ruf beendet durch ${by || "unknown"}`,
});
};
pilotSocket.on("ptt", handlePTT);
pilotSocket.on("other-ptt", handleOtherPTT);
dispatchSocket.on("ptt", handlePTT);
dispatchSocket.on("other-ptt", handleOtherPTT);
pilotSocket.on("force-end-transmission", handleForceEndTransmission);
dispatchSocket.on("force-end-transmission", handleForceEndTransmission);

View File

@@ -34,7 +34,7 @@ export const useDispatchConnectionStore = create<ConnectionStore>((set) => ({
dispatchSocket.on("connect", () => {
const { logoffTime, selectedZone } = useDispatchConnectionStore.getState();
useAudioStore.getInitialState().connect("LST_01");
useAudioStore.getState().connect("LST_01", selectedZone || "Leitstelle");
dispatchSocket.emit("connect-dispatch", {
logoffTime,
selectedZone,

View File

@@ -64,7 +64,7 @@ pilotSocket.on("connect", () => {
usePilotConnectionStore.setState({ status: "connected", message: "" });
const { logoffTime, selectedStation } = usePilotConnectionStore.getState();
dispatchSocket.disconnect();
useAudioStore.getInitialState().connect("LST_01");
useAudioStore.getState().connect("LST_01", selectedStation?.bosCallsignShort || "pilot");
pilotSocket.emit("connect-pilot", {
logoffTime,

View File

@@ -2,7 +2,7 @@ import { getServerSession } from "api/auth/[...nextauth]/auth";
import { ROOMS } from "_data/livekitRooms";
import { AccessToken } from "livekit-server-sdk";
import { NextRequest } from "next/server";
import { prisma } from "@repo/db";
import { getPublicUser, prisma } from "@repo/db";
/* if (!process.env.LIVEKIT_API_KEY) throw new Error("LIVEKIT_API_KEY not set");
if (!process.env.LIVEKIT_API_SECRET)
@@ -38,8 +38,13 @@ export const GET = async (request: NextRequest) => {
roomJoin: true,
canPublish: true,
canSubscribe: true,
canUpdateOwnMetadata: true,
});
at.attributes = {
publicId: user.publicId,
};
const token = await at.toJwt();
return Response.json({ token });

View File

@@ -349,6 +349,7 @@ export const MissionForm = () => {
/>
{form.watch("type") === "sekundär" && (
<input
{...form.register("addressMissionLocation")}
type="text"
placeholder="Zielkrankenhaus"
className="input input-primary input-bordered w-full"

View File

@@ -4,6 +4,7 @@ model ConnectedDispatcher {
publicUser Json
lastHeartbeat DateTime @default(now())
loginTime DateTime @default(now())
zone String @default("LST_1")
esimatedLogoutTime DateTime?
logoutTime DateTime?

View File

@@ -9,6 +9,7 @@ model Mission {
addressCity String?
addressZip String?
addressAdditionalInfo String?
addressMissionLocation String?
addressOSMways Json[] @default([])
missionKeywordCategory String?
missionKeywordName String?