333 lines
9.6 KiB
TypeScript
333 lines
9.6 KiB
TypeScript
import { dispatchSocket } from "(app)/dispatch/socket";
|
|
import {
|
|
handleDisconnect,
|
|
handleLocalTrackUnpublished,
|
|
handleTrackSubscribed,
|
|
handleTrackUnsubscribed,
|
|
} from "_helpers/liveKitEventHandler";
|
|
import {
|
|
ConnectionQuality,
|
|
LocalTrackPublication,
|
|
Participant,
|
|
Room,
|
|
RoomEvent,
|
|
RpcInvocationData,
|
|
Track,
|
|
} from "livekit-client";
|
|
import { pilotSocket } from "(app)/pilot/socket";
|
|
import { create } from "zustand";
|
|
import axios from "axios";
|
|
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
|
import { changeDispatcherAPI } from "_querys/dispatcher";
|
|
import { getRadioStream } from "_helpers/radioEffect";
|
|
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
|
import { ROOMS } from "_data/livekitRooms";
|
|
|
|
let interval: NodeJS.Timeout;
|
|
|
|
type TalkState = {
|
|
addSpeakingParticipant: (participant: Participant) => void;
|
|
connect: (room: (typeof ROOMS)[number] | undefined, role: string) => void;
|
|
connectionQuality: ConnectionQuality;
|
|
disconnect: () => void;
|
|
isTalking: boolean;
|
|
localRadioTrack: LocalTrackPublication | undefined;
|
|
message: string | null;
|
|
removeMessage: () => void;
|
|
removeSpeakingParticipant: (speakingParticipants: Participant) => void;
|
|
remoteParticipants: number;
|
|
resetSpeakingParticipants: (source: string) => void;
|
|
room: Room | null;
|
|
setSettings: (settings: Partial<TalkState["settings"]>) => void;
|
|
settings: {
|
|
micDeviceId: string | null;
|
|
micVolume: number;
|
|
radioVolume: number;
|
|
dmeVolume: number;
|
|
};
|
|
selectedRoom?: (typeof ROOMS)[number];
|
|
setSelectedRoom: (room: (typeof ROOMS)[number]) => void;
|
|
speakingParticipants: Participant[];
|
|
state: "connecting" | "connected" | "disconnected" | "error";
|
|
toggleTalking: () => void;
|
|
transmitBlocked: boolean;
|
|
};
|
|
const getToken = async (roomName: string) => {
|
|
const response = await axios.get(`/api/livekit-token?roomName=${roomName}`);
|
|
const data = response.data;
|
|
return data.token;
|
|
};
|
|
|
|
export const useAudioStore = create<TalkState>((set, get) => ({
|
|
isTalking: false,
|
|
localRadioTrack: undefined,
|
|
transmitBlocked: false,
|
|
message: null,
|
|
speakingParticipants: [],
|
|
micVolume: 1,
|
|
settings: {
|
|
micDeviceId: null,
|
|
micVolume: 1,
|
|
radioVolume: 0.8,
|
|
dmeVolume: 0.8,
|
|
},
|
|
state: "disconnected" as const,
|
|
remoteParticipants: 0,
|
|
connectionQuality: ConnectionQuality.Unknown,
|
|
room: null,
|
|
selectedRoom: ROOMS[0],
|
|
setSelectedRoom: (room) => {
|
|
set({ selectedRoom: room });
|
|
},
|
|
resetSpeakingParticipants: (source: string) => {
|
|
set({
|
|
speakingParticipants: [],
|
|
isTalking: false,
|
|
transmitBlocked: false,
|
|
message: `Ruf beendet durch ${source || "eine unsichtbare Macht"}`,
|
|
});
|
|
get().room?.localParticipant.setMicrophoneEnabled(false);
|
|
},
|
|
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) => {
|
|
const newSpeaktingParticipants = get().speakingParticipants.filter(
|
|
(p) => !(p.identity === participant.identity),
|
|
);
|
|
set(() => ({
|
|
speakingParticipants: newSpeaktingParticipants,
|
|
}));
|
|
if (newSpeaktingParticipants.length === 0 && get().transmitBlocked) {
|
|
get().room?.localParticipant.setMicrophoneEnabled(true);
|
|
set({ transmitBlocked: false, message: null, isTalking: true });
|
|
}
|
|
},
|
|
setSettings: (newSettings) => {
|
|
const oldSettings = get().settings;
|
|
set((s) => ({
|
|
settings: {
|
|
...s.settings,
|
|
...newSettings,
|
|
},
|
|
}));
|
|
if (
|
|
get().state === "connected" &&
|
|
(oldSettings.micDeviceId !== newSettings.micDeviceId ||
|
|
oldSettings.micVolume !== newSettings.micVolume)
|
|
) {
|
|
const { room, disconnect, connect, selectedRoom } = get();
|
|
const role = room?.localParticipant.attributes.role;
|
|
if (selectedRoom || role) {
|
|
disconnect();
|
|
connect(selectedRoom, role || "user");
|
|
}
|
|
}
|
|
},
|
|
toggleTalking: () => {
|
|
const { room, isTalking, speakingParticipants, transmitBlocked } = get();
|
|
if (!room) return;
|
|
|
|
if (speakingParticipants.length > 0 && !isTalking && !transmitBlocked) {
|
|
// Wenn andere sprechen, nicht reden
|
|
set({
|
|
message: "Rufgruppe besetzt",
|
|
transmitBlocked: true,
|
|
});
|
|
return;
|
|
} else if (!isTalking && transmitBlocked) {
|
|
set({
|
|
message: null,
|
|
transmitBlocked: false,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const { status: DispatcherConnectionStatus } = useDispatchConnectionStore.getState();
|
|
const { status: PilotConnectionStatus } = usePilotConnectionStore.getState();
|
|
if (
|
|
!isTalking &&
|
|
!(DispatcherConnectionStatus === "connected" || PilotConnectionStatus === "connected")
|
|
) {
|
|
useAudioStore.setState({
|
|
message: "Keine Verbindung",
|
|
});
|
|
return;
|
|
}
|
|
|
|
room.localParticipant.setMicrophoneEnabled(!isTalking);
|
|
|
|
set((state) => ({ isTalking: !state.isTalking, transmitBlocked: false }));
|
|
},
|
|
connect: async (_room, role) => {
|
|
set({ state: "connecting" });
|
|
|
|
try {
|
|
// Clean old room
|
|
const connectedRoom = get().room;
|
|
if (interval) clearInterval(interval);
|
|
if (connectedRoom) {
|
|
connectedRoom.disconnect();
|
|
connectedRoom.removeAllListeners();
|
|
}
|
|
|
|
const { selectedRoom } = get();
|
|
|
|
const url = process.env.NEXT_PUBLIC_LIVEKIT_URL;
|
|
if (!url) return console.error("NEXT_PUBLIC_LIVEKIT_URL not set");
|
|
|
|
const token = await getToken(_room?.name || selectedRoom?.name || "VAR_LST_RD_01");
|
|
if (!token) throw new Error("Fehlende Berechtigung");
|
|
const room = new Room({});
|
|
await room.prepareConnection(url, token);
|
|
const roomConnectedSound = new Audio("/sounds/403.wav");
|
|
room
|
|
// Connection events
|
|
.on(RoomEvent.Connected, async () => {
|
|
const dispatchState = useDispatchConnectionStore.getState();
|
|
|
|
if (dispatchState.status === "connected" && dispatchState.connectedDispatcher?.id) {
|
|
changeDispatcherAPI(dispatchState.connectedDispatcher?.id, {
|
|
zone: _room?.name || selectedRoom?.name || "VAR_LST_RD_01",
|
|
ghostMode: dispatchState.ghostMode,
|
|
});
|
|
}
|
|
|
|
const inputStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
deviceId: get().settings.micDeviceId ?? undefined,
|
|
},
|
|
});
|
|
// Funk-Effekt anwenden
|
|
const radioStream = getRadioStream(inputStream, get().settings.micVolume);
|
|
if (!radioStream) throw new Error("Konnte Funkstream nicht erzeugen");
|
|
|
|
const [track] = radioStream.getAudioTracks();
|
|
if (!track) throw new Error("Konnte Audio-Track nicht erzeugen");
|
|
|
|
const publishedTrack = await room.localParticipant.publishTrack(track, {
|
|
name: "radio-audio",
|
|
source: Track.Source.Microphone,
|
|
});
|
|
await publishedTrack.mute();
|
|
roomConnectedSound.play();
|
|
set({ localRadioTrack: publishedTrack });
|
|
|
|
set({ state: "connected", room, isTalking: false, message: null });
|
|
})
|
|
.on(RoomEvent.Disconnected, () => {
|
|
set({
|
|
state: "disconnected",
|
|
speakingParticipants: [],
|
|
transmitBlocked: false,
|
|
isTalking: false,
|
|
});
|
|
|
|
handleDisconnect();
|
|
})
|
|
.on(RoomEvent.ConnectionQualityChanged, (connectionQuality) => set({ connectionQuality }))
|
|
|
|
// Track events
|
|
.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
|
|
.on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed)
|
|
.on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished);
|
|
await room.connect(url, token, {});
|
|
|
|
room.localParticipant.setAttributes({
|
|
role,
|
|
});
|
|
set({ room });
|
|
|
|
room.registerRpcMethod("force-mute", async (data: RpcInvocationData) => {
|
|
const { by } = JSON.parse(data.payload);
|
|
get().resetSpeakingParticipants(by);
|
|
return "OK";
|
|
});
|
|
|
|
interval = setInterval(() => {
|
|
// Filter forgotten participants
|
|
const oldSpeakingParticipants = get().speakingParticipants;
|
|
const speakingParticipants = oldSpeakingParticipants.filter((oP) => {
|
|
return Array.from(room.remoteParticipants.values()).find(
|
|
(p) => p.identity === oP.identity,
|
|
);
|
|
});
|
|
|
|
if (oldSpeakingParticipants.length !== speakingParticipants.length) {
|
|
set({
|
|
remoteParticipants: room.numParticipants === 0 ? 0 : room.numParticipants - 1, // Unreliable and delayed
|
|
speakingParticipants,
|
|
});
|
|
} else {
|
|
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" });
|
|
}
|
|
}
|
|
},
|
|
disconnect: () => {
|
|
get().room?.disconnect();
|
|
},
|
|
}));
|
|
|
|
interface PTTData {
|
|
shouldTransmit: boolean;
|
|
source: string;
|
|
}
|
|
|
|
const handlePTT = (data: PTTData) => {
|
|
const { shouldTransmit } = data;
|
|
const { room, speakingParticipants } = useAudioStore.getState();
|
|
if (!room) return;
|
|
|
|
if (speakingParticipants.length > 0 && shouldTransmit) {
|
|
// Wenn andere sprechen, nicht reden
|
|
useAudioStore.setState({
|
|
message: "Rufgruppe besetzt",
|
|
transmitBlocked: true,
|
|
});
|
|
return;
|
|
}
|
|
const { status: DispatcherConnectionStatus } = useDispatchConnectionStore.getState();
|
|
const { status: PilotConnectionStatus } = usePilotConnectionStore.getState();
|
|
if (
|
|
shouldTransmit &&
|
|
!(DispatcherConnectionStatus === "connected" || PilotConnectionStatus === "connected")
|
|
) {
|
|
useAudioStore.setState({
|
|
message: "Keine Verbindung",
|
|
});
|
|
return;
|
|
}
|
|
|
|
useAudioStore.setState({
|
|
isTalking: shouldTransmit,
|
|
transmitBlocked: false,
|
|
});
|
|
|
|
if (shouldTransmit) {
|
|
room.localParticipant.setMicrophoneEnabled(true);
|
|
} else {
|
|
room.localParticipant.setMicrophoneEnabled(false);
|
|
}
|
|
};
|
|
|
|
pilotSocket.on("ptt", handlePTT);
|
|
dispatchSocket.on("ptt", handlePTT);
|