added force end transmition for dispatchers
This commit is contained in:
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
@@ -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"
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ model Mission {
|
||||
addressCity String?
|
||||
addressZip String?
|
||||
addressAdditionalInfo String?
|
||||
addressMissionLocation String?
|
||||
addressOSMways Json[] @default([])
|
||||
missionKeywordCategory String?
|
||||
missionKeywordName String?
|
||||
|
||||
Reference in New Issue
Block a user