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 { Router } from "express";
import livekitRouter from "./livekit";
import dispatcherRotuer from "./dispatcher"; import dispatcherRotuer from "./dispatcher";
import missionRouter from "./mission"; import missionRouter from "./mission";
import statusRouter from "./status"; import statusRouter from "./status";
@@ -8,7 +7,6 @@ import reportRouter from "./report";
const router: Router = Router(); const router: Router = Router();
router.use("/livekit", livekitRouter);
router.use("/dispatcher", dispatcherRotuer); router.use("/dispatcher", dispatcherRotuer);
router.use("/mission", missionRouter); router.use("/mission", missionRouter);
router.use("/status", statusRouter); router.use("/status", statusRouter);

View File

@@ -62,6 +62,7 @@ export const handleConnectDispatch =
esimatedLogoutTime: parsedLogoffDate?.toISOString() || null, esimatedLogoutTime: parsedLogoffDate?.toISOString() || null,
lastHeartbeat: new Date().toISOString(), lastHeartbeat: new Date().toISOString(),
userId: user.id, userId: user.id,
zone: selectedZone,
loginTime: new Date().toISOString(), loginTime: new Date().toISOString(),
}, },
}); });
@@ -72,20 +73,29 @@ export const handleConnectDispatch =
io.to("dispatchers").emit("dispatchers-update"); io.to("dispatchers").emit("dispatchers-update");
io.to("pilots").emit("dispatchers-update"); io.to("pilots").emit("dispatchers-update");
// dispatch-events socket.on("stop-other-transmition", async ({ ownRole, otherRole }) => {
socket.on("ptt", async ({ shouldTransmit, channel }) => { const aircrafts = await prisma.connectedAircraft.findMany({
if (shouldTransmit) { where: {
io.to("dispatchers").emit("other-ptt", { Station: {
publicUser: getPublicUser(user), bosCallsignShort: otherRole,
channel, },
source: "Leitstelle", 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 () => { socket.on("disconnect", async () => {

View File

@@ -18,12 +18,16 @@ import { useAudioStore } from "_store/audioStore";
import { cn } from "_helpers/cn"; import { cn } from "_helpers/cn";
import { ConnectionQuality } from "livekit-client"; import { ConnectionQuality } from "livekit-client";
import { ROOMS } from "_data/livekitRooms"; 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 = () => { export const Audio = () => {
const connection = usePilotConnectionStore(); const connection = usePilotConnectionStore();
const [showSource, setShowSource] = useState(false); const [showSource, setShowSource] = useState(false);
const { const {
speakingParticipants,
isTalking, isTalking,
toggleTalking, toggleTalking,
connect, connect,
@@ -33,50 +37,105 @@ export const Audio = () => {
remoteParticipants, remoteParticipants,
room, room,
message, message,
source, removeMessage,
} = useAudioStore(); } = useAudioStore();
const [selectedRoom, setSelectedRoom] = useState<string>("LST_01"); const [selectedRoom, setSelectedRoom] = useState<string>("LST_01");
useEffect(() => { const { selectedStation, status: pilotState } = usePilotConnectionStore((state) => state);
setShowSource(true);
}, [source, isTalking]); const { selectedZone, status: dispatcherState } = useDispatchConnectionStore((state) => state);
const session = useSession();
const [recentSpeakers, setRecentSpeakers] = useState<typeof speakingParticipants>([]);
useEffect(() => { useEffect(() => {
const joinRoom = async () => { if (speakingParticipants.length > 0) {
if (connection.status != "connected") return; setRecentSpeakers(speakingParticipants);
if (state === "connected") return; } else if (recentSpeakers.length > 0) {
connect(selectedRoom); const timeout = setTimeout(() => {
}; setRecentSpeakers([]);
}, 10000);
joinRoom(); return () => clearTimeout(timeout);
}
return () => {
disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 ( return (
<> <>
<div className="bg-base-200 rounded-box flex items-center gap-2 p-1"> <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>} {message && (
{showSource && source && (
<div <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" data-tip="Funkspruch unterbrechen"
> >
<button <button
className="btn btn-sm btn-soft border-none bg-transparent hover:bg-error" className={cn(
onClick={() => {}} "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> </button>
</div> </div>
)} )}
<button <button
onClick={() => { onClick={() => {
if (state === "connected") toggleTalking(); if (state === "connected") toggleTalking();
if (state === "error" || state === "disconnected") connect(selectedRoom); if (!role) return;
if (state === "error" || state === "disconnected") connect(selectedRoom, role);
}} }}
className={cn( className={cn(
"btn btn-sm btn-soft border-none hover:bg-inherit", "btn btn-sm btn-soft border-none hover:bg-inherit",
@@ -111,9 +170,10 @@ export const Audio = () => {
<button <button
className="btn btn-sm btn-ghost text-left flex items-center justify-start gap-2 relative" className="btn btn-sm btn-ghost text-left flex items-center justify-start gap-2 relative"
onClick={() => { onClick={() => {
if (!role) return;
if (selectedRoom === r) return; if (selectedRoom === r) return;
setSelectedRoom(r); setSelectedRoom(r);
connect(r); connect(r, role);
}} }}
> >
{room?.name === r && ( {room?.name === r && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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