added missing Settings functionality, moved ntfy setting

This commit is contained in:
PxlLoewe
2025-07-17 01:08:10 -07:00
parent 44427a1b4b
commit 11b1d8745d
9 changed files with 141 additions and 150 deletions

View File

@@ -153,7 +153,6 @@ const removeConnectedAircrafts = async () => {
cron.schedule("*/1 * * * *", async () => { cron.schedule("*/1 * * * *", async () => {
try { try {
console.log("Running cron job to remove closed missions and connected aircrafts...");
await removeClosedMissions(); await removeClosedMissions();
await removeConnectedAircrafts(); await removeConnectedAircrafts();
} catch (error) { } catch (error) {

View File

@@ -1,18 +1,11 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query"; import { useAudioStore } from "_store/audioStore";
import { getUserAPI } from "_querys/user";
import { usePilotConnectionStore } from "_store/pilot/connectionStore"; import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { useDmeStore } from "_store/pilot/dmeStore"; import { useDmeStore } from "_store/pilot/dmeStore";
import { useSession } from "next-auth/react";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
export const useSounds = () => { export const useSounds = () => {
const session = useSession(); const dmeVolume = useAudioStore((state) => state.settings.dmeVolume);
const { data: user } = useQuery({
queryKey: ["user", session.data?.user.id],
queryFn: () => getUserAPI(session.data!.user.id),
});
const { page, setPage } = useDmeStore((state) => state); const { page, setPage } = useDmeStore((state) => state);
const mission = usePilotConnectionStore((state) => state.activeMission); const mission = usePilotConnectionStore((state) => state.activeMission);
@@ -25,14 +18,14 @@ export const useSounds = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (user?.settingsDmeVolume) { if (dmeVolume) {
if (newMissionSound.current) { if (newMissionSound.current) {
newMissionSound.current.volume = user.settingsDmeVolume; newMissionSound.current.volume = dmeVolume;
} }
} else if (newMissionSound.current) { } else if (newMissionSound.current) {
newMissionSound.current.volume = 0.8; // Default volume newMissionSound.current.volume = 0.8; // Default volume
} }
}, [user?.settingsDmeVolume]); }, [dmeVolume]);
useEffect(() => { useEffect(() => {
const timeouts: NodeJS.Timeout[] = []; const timeouts: NodeJS.Timeout[] = [];
@@ -40,7 +33,6 @@ export const useSounds = () => {
if (page === "new-mission" && newMissionSound.current) { if (page === "new-mission" && newMissionSound.current) {
console.log("new-mission", mission); console.log("new-mission", mission);
newMissionSound.current.currentTime = 0; newMissionSound.current.currentTime = 0;
newMissionSound.current.volume = 0.3;
newMissionSound.current.play(); newMissionSound.current.play();
if (mission) { if (mission) {
timeouts.push(setTimeout(() => setPage({ page: "mission", mission }), 500)); timeouts.push(setTimeout(() => setPage({ page: "mission", mission }), 500));

View File

@@ -47,27 +47,28 @@ export default function MicrophoneLevel({ deviceId, volumeInput }: MicrophoneLev
}; };
}, [deviceId, volumeInput]); }, [deviceId, volumeInput]);
const barWidth = Math.max((volumeLevel / 70) * 100 - 35, 0); const barWidth = Math.min((volumeLevel / 140) * 100, 100);
return ( return (
<div className="w-full"> <div className="w-full">
<div className="relative w-full bg-base-300 h-5 rounded"> <div className="relative w-full bg-base-300 h-5 rounded">
<div <div
className={cn("bg-primary h-full rounded", barWidth > 100 && "bg-red-400")} className={cn("bg-primary h-full rounded", barWidth == 100 && "bg-red-400")}
style={{ style={{
width: `${barWidth > 100 ? 100 : barWidth}%`, width: `${barWidth}%`,
transition: "width 0.2s", transition: "width 0.2s",
}} }}
/> />
<div <div
className="absolute top-0 left-[60%] w-[20%] h-full bg-green-500 opacity-40 rounded" className="absolute top-0 left-[60%] w-[30%] h-full bg-green-500 opacity-40 rounded"
style={{ style={{
transform: "translateX(-50%)", transform: "translateX(-50%)",
}} }}
/> />
</div> </div>
<p className="text-gray-500 text-sm"> <p className="text-gray-500 text-sm">
Lautstärke sollte beim Sprechen in dem Grünen bereich bleiben Lautstärke sollte beim Sprechen in dem Grünen bereich bleiben. Beachte das scharfe Laute
(z.B. "S" oder "Z") die Anzeige verfälschen können.
</p> </p>
</div> </div>
); );

View File

@@ -73,7 +73,6 @@ const HeliportsLayer = () => {
queryKey: ["heliports"], queryKey: ["heliports"],
queryFn: () => getHeliportsAPI(), queryFn: () => getHeliportsAPI(),
}); });
console.log("Heliports Layer", heliports);
const [heliportsWithIcon, setHeliportsWithIcon] = useState<(Heliport & { icon?: string })[]>([]); const [heliportsWithIcon, setHeliportsWithIcon] = useState<(Heliport & { icon?: string })[]>([]);
const map = useMap(); const map = useMap();
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);

View File

@@ -1,17 +1,19 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons"; import { GearIcon } from "@radix-ui/react-icons";
import { SettingsIcon, Volume2 } from "lucide-react"; import { Bell, SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication"; import MicVolumeBar from "_components/MicVolumeIndication";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { editUserAPI, getUserAPI } from "_querys/user"; import { editUserAPI, getUserAPI } from "_querys/user";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useAudioStore } from "_store/audioStore"; import { useAudioStore } from "_store/audioStore";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import Link from "next/link";
export const SettingsBtn = () => { export const SettingsBtn = () => {
const session = useSession(); const session = useSession();
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const { data: user } = useQuery({ const { data: user } = useQuery({
queryKey: ["user", session.data?.user.id], queryKey: ["user", session.data?.user.id],
queryFn: () => getUserAPI(session.data!.user.id), queryFn: () => getUserAPI(session.data!.user.id),
@@ -30,26 +32,42 @@ export const SettingsBtn = () => {
const modalRef = useRef<HTMLDialogElement>(null); const modalRef = useRef<HTMLDialogElement>(null);
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedDevice, setSelectedDevice] = useState<string | null>(
user?.settingsMicDevice || null,
);
const [showIndication, setShowIndication] = useState<boolean>(false); const [showIndication, setShowIndication] = useState<boolean>(false);
const [micVol, setMicVol] = useState<number>(1);
const [funkVolume, setFunkVol] = useState<number>(0.8);
const [dmeVolume, setDmeVol] = useState<number>(0.8);
const { setMic } = useAudioStore((state) => state); const [settings, setSettings] = useState({
micDeviceId: user?.settingsMicDevice || null,
micVolume: user?.settingsMicVolume || 1,
radioVolume: user?.settingsRadioVolume || 0.8,
dmeVolume: user?.settingsDmeVolume || 0.8,
pilotNtfyRoom: user?.settingsNtfyRoom || "",
});
const { setSettings: setAudioSettings } = useAudioStore((state) => state);
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setSelectedDevice(user.settingsMicDevice); setAudioSettings({
setMic(user.settingsMicDevice, user.settingsMicVolume || 1); micDeviceId: user.settingsMicDevice,
setMicVol(user.settingsMicVolume || 1); micVolume: user.settingsMicVolume || 1,
setFunkVol(user.settingsRadioVolume || 0.8); radioVolume: user.settingsRadioVolume || 0.8,
setDmeVol(user.settingsDmeVolume || 0.8); dmeVolume: user.settingsDmeVolume || 0.8,
});
setSettings({
micDeviceId: user.settingsMicDevice,
micVolume: user.settingsMicVolume || 1,
radioVolume: user.settingsRadioVolume || 0.8,
dmeVolume: user.settingsDmeVolume || 0.8,
pilotNtfyRoom: user.settingsNtfyRoom || "",
});
} }
}, [user, setMic]); }, [user, setSettings, setAudioSettings]);
const setSettingsPartial = (newSettings: Partial<typeof settings>) => {
setSettings((prev) => ({
...prev,
...newSettings,
}));
};
useEffect(() => { useEffect(() => {
const setDevices = async () => { const setDevices = async () => {
@@ -87,9 +105,9 @@ export const SettingsBtn = () => {
<span>Eingabegerät</span> <span>Eingabegerät</span>
<select <select
className="input w-full" className="input w-full"
value={selectedDevice ? selectedDevice : ""} value={settings.micDeviceId ? settings.micDeviceId : ""}
onChange={(e) => { onChange={(e) => {
setSelectedDevice(e.target.value); setSettingsPartial({ micDeviceId: e.target.value });
setShowIndication(true); setShowIndication(true);
}} }}
> >
@@ -115,10 +133,10 @@ export const SettingsBtn = () => {
step={0.01} step={0.01}
onChange={(e) => { onChange={(e) => {
const value = parseFloat(e.target.value); const value = parseFloat(e.target.value);
setMicVol(value); setSettingsPartial({ micVolume: value });
setShowIndication(true); setShowIndication(true);
}} }}
value={micVol} value={settings.micVolume}
className="range range-xs range-accent w-full" className="range range-xs range-accent w-full"
/> />
<div className="flex justify-between px-2.5 mt-2 text-xs"> <div className="flex justify-between px-2.5 mt-2 text-xs">
@@ -130,7 +148,10 @@ export const SettingsBtn = () => {
</div> </div>
</div> </div>
{showIndication && ( {showIndication && (
<MicVolumeBar deviceId={selectedDevice ? selectedDevice : ""} volumeInput={micVol} /> <MicVolumeBar
deviceId={settings.micDeviceId ? settings.micDeviceId : ""}
volumeInput={settings.micVolume}
/>
)} )}
<div className="divider w-full" /> <div className="divider w-full" />
</div> </div>
@@ -144,10 +165,11 @@ export const SettingsBtn = () => {
max={1} max={1}
step={0.01} step={0.01}
onChange={(e) => { onChange={(e) => {
console.log("Radio Volume", e.target.value);
const value = parseFloat(e.target.value); const value = parseFloat(e.target.value);
setFunkVol(value); setSettingsPartial({ radioVolume: value });
}} }}
value={funkVolume} value={settings.radioVolume}
className="range range-xs range-primary w-full" className="range range-xs range-primary w-full"
/> />
<div className="flex justify-between px-2.5 mt-2 text-xs"> <div className="flex justify-between px-2.5 mt-2 text-xs">
@@ -158,9 +180,7 @@ export const SettingsBtn = () => {
<span>100%</span> <span>100%</span>
</div> </div>
</div> </div>
<div className="flex justify-center w-full">
<div className="divider w-1/2" />
</div>
<p className="flex items-center gap-2 text-base mb-2"> <p className="flex items-center gap-2 text-base mb-2">
<Volume2 size={20} /> Melder Lautstärke <Volume2 size={20} /> Melder Lautstärke
</p> </p>
@@ -172,12 +192,12 @@ export const SettingsBtn = () => {
step={0.01} step={0.01}
onChange={(e) => { onChange={(e) => {
const value = parseFloat(e.target.value); const value = parseFloat(e.target.value);
setDmeVol(value); setSettingsPartial({ dmeVolume: value });
if (!testSoundRef.current) return; if (!testSoundRef.current) return;
testSoundRef.current.volume = value; testSoundRef.current.volume = value;
testSoundRef.current.play(); testSoundRef.current.play();
}} }}
value={dmeVolume} value={settings.dmeVolume}
className="range range-xs range-primary w-full" className="range range-xs range-primary w-full"
/> />
<div className="flex justify-between px-2.5 mt-2 text-xs"> <div className="flex justify-between px-2.5 mt-2 text-xs">
@@ -188,6 +208,34 @@ export const SettingsBtn = () => {
<span>100%</span> <span>100%</span>
</div> </div>
</div> </div>
<div className="flex justify-center w-full">
<div className="divider w-full" />
</div>
<div className="w-full">
<label className="floating-label w-full">
<span className="text-lg flex items-center gap-2">
<Bell /> NTFY room
</span>
<input
placeholder="Erhalte eine Benachrichtigung auf dein Handy über NTFY"
className="input input-bordered w-full"
value={settings.pilotNtfyRoom}
onChange={(e) => setSettingsPartial({ pilotNtfyRoom: e.target.value })}
/>
</label>
<p className="label mt-2 w-full">
<Link
href="https://docs.virtualairrescue.com/docs/Leitstelle/App-Alarmierung#download"
target="_blank"
rel="noopener noreferrer"
className="link link-hover link-primary"
>
Hier
</Link>
findest du mehr Informationen!
</p>
</div>
<div className="flex justify-between modal-action"> <div className="flex justify-between modal-action">
<button <button
@@ -210,13 +258,19 @@ export const SettingsBtn = () => {
await editUserMutation.mutateAsync({ await editUserMutation.mutateAsync({
id: session.data!.user.id, id: session.data!.user.id,
user: { user: {
settingsMicDevice: selectedDevice, settingsMicDevice: settings.micDeviceId,
settingsMicVolume: micVol, settingsMicVolume: settings.micVolume,
settingsRadioVolume: funkVolume, settingsRadioVolume: settings.radioVolume,
settingsDmeVolume: dmeVolume, settingsDmeVolume: settings.dmeVolume,
settingsNtfyRoom: settings.pilotNtfyRoom,
}, },
}); });
setMic(selectedDevice, micVol); setAudioSettings({
micDeviceId: settings.micDeviceId,
micVolume: settings.micVolume,
radioVolume: settings.radioVolume,
dmeVolume: settings.dmeVolume,
});
modalRef.current?.close(); modalRef.current?.close();
toast.success("Einstellungen gespeichert"); toast.success("Einstellungen gespeichert");
}} }}
@@ -229,7 +283,6 @@ export const SettingsBtn = () => {
</div> </div>
); );
}; };
export const Settings = () => { export const Settings = () => {
return ( return (
<div> <div>

View File

@@ -15,17 +15,25 @@ export const handleTrackSubscribed = (
if (!track.isMuted) { if (!track.isMuted) {
useAudioStore.getState().addSpeakingParticipant(participant); useAudioStore.getState().addSpeakingParticipant(participant);
} }
track.on("unmuted", () => {
useAudioStore.getState().addSpeakingParticipant(participant);
});
track.on("muted", () => {
useAudioStore.getState().removeSpeakingParticipant(participant);
});
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();
element.play(); element.play();
track.on("unmuted", () => {
useAudioStore.getState().addSpeakingParticipant(participant);
console.log(useAudioStore.getState().settings.radioVolume);
element.volume = useAudioStore.getState().settings.radioVolume;
});
track.on("unmuted", () => {
useAudioStore.getState().addSpeakingParticipant(participant);
});
} }
track.on("muted", () => {
useAudioStore.getState().removeSpeakingParticipant(participant);
});
}; };
export const handleTrackUnsubscribed = (track: RemoteTrack) => { export const handleTrackUnsubscribed = (track: RemoteTrack) => {

View File

@@ -24,8 +24,12 @@ import { getRadioStream } from "_helpers/radioEffect";
let interval: NodeJS.Timeout; let interval: NodeJS.Timeout;
type TalkState = { type TalkState = {
micDeviceId: string | null; settings: {
micVolume: number; micDeviceId: string | null;
micVolume: number;
radioVolume: number;
dmeVolume: number;
};
isTalking: boolean; isTalking: boolean;
transmitBlocked: boolean; transmitBlocked: boolean;
removeMessage: () => void; removeMessage: () => void;
@@ -34,7 +38,7 @@ type TalkState = {
connectionQuality: ConnectionQuality; connectionQuality: ConnectionQuality;
remoteParticipants: number; remoteParticipants: number;
toggleTalking: () => void; toggleTalking: () => void;
setMic: (micDeviceId: string | null, volume: number) => void; setSettings: (settings: Partial<TalkState["settings"]>) => void;
connect: (roomName: string, role: string) => void; connect: (roomName: string, role: string) => void;
disconnect: () => void; disconnect: () => void;
speakingParticipants: Participant[]; speakingParticipants: Participant[];
@@ -54,9 +58,14 @@ export const useAudioStore = create<TalkState>((set, get) => ({
localRadioTrack: undefined, localRadioTrack: undefined,
transmitBlocked: false, transmitBlocked: false,
message: null, message: null,
micDeviceId: null,
speakingParticipants: [], speakingParticipants: [],
micVolume: 1, micVolume: 1,
settings: {
micDeviceId: null,
micVolume: 1,
radioVolume: 0.8,
dmeVolume: 0.8,
},
state: "disconnected" as const, state: "disconnected" as const,
remoteParticipants: 0, remoteParticipants: 0,
connectionQuality: ConnectionQuality.Unknown, connectionQuality: ConnectionQuality.Unknown,
@@ -84,9 +93,19 @@ export const useAudioStore = create<TalkState>((set, get) => ({
set({ transmitBlocked: false, message: null, isTalking: true }); set({ transmitBlocked: false, message: null, isTalking: true });
} }
}, },
setMic: (micDeviceId, micVolume) => { setSettings: (newSettings) => {
set({ micDeviceId, micVolume }); const oldSettings = get().settings;
if (get().state === "connected") { set((s) => ({
settings: {
...s.settings,
...newSettings,
},
}));
if (
get().state === "connected" &&
(oldSettings.micDeviceId !== newSettings.micDeviceId ||
oldSettings.micVolume !== newSettings.micVolume)
) {
const { room, disconnect, connect } = get(); const { room, disconnect, connect } = get();
const role = room?.localParticipant.attributes.role; const role = room?.localParticipant.attributes.role;
console.log(role); console.log(role);
@@ -150,13 +169,13 @@ export const useAudioStore = create<TalkState>((set, get) => ({
const inputStream = await navigator.mediaDevices.getUserMedia({ const inputStream = await navigator.mediaDevices.getUserMedia({
audio: { audio: {
deviceId: get().micDeviceId ?? undefined, deviceId: get().settings.micDeviceId ?? undefined,
noiseSuppression: true, noiseSuppression: true,
}, },
}); });
// Funk-Effekt anwenden // Funk-Effekt anwenden
const radioStream = getRadioStream(inputStream, get().micVolume); const radioStream = getRadioStream(inputStream, get().settings.micVolume);
if (!radioStream) throw new Error("Konnte Funkstream nicht erzeugen"); if (!radioStream) throw new Error("Konnte Funkstream nicht erzeugen");
const [track] = radioStream.getAudioTracks(); const [track] = radioStream.getAudioTracks();

View File

@@ -20,9 +20,7 @@ import {
LockOpen1Icon, LockOpen1Icon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { UserOptionalDefaults, UserOptionalDefaultsSchema } from "@repo/db/zod"; import { CircleAlert, Trash2 } from "lucide-react";
import { Bell, CircleAlert, Plane, Trash2 } from "lucide-react";
import Link from "next/link";
import { deleteUser, sendVerificationLink } from "(app)/admin/user/action"; import { deleteUser, sendVerificationLink } from "(app)/admin/user/action";
import { setStandardName } from "../../../../helper/discord"; import { setStandardName } from "../../../../helper/discord";
@@ -458,78 +456,3 @@ export const PasswordForm = (): React.JSX.Element => {
</form> </form>
); );
}; };
export const PilotForm = ({ user }: { user: User }): React.JSX.Element | null => {
const [isLoading, setIsLoading] = useState(false);
const form = useForm<UserOptionalDefaults>({
defaultValues: {
...user,
emailVerified: user.emailVerified ?? undefined,
},
resolver: zodResolver(UserOptionalDefaultsSchema),
});
if (!user) return null;
return (
<form
className="card-body"
onSubmit={form.handleSubmit(async (values) => {
form.handleSubmit(async () => {
setIsLoading(true);
});
await updateUser(values);
setIsLoading(false);
form.reset(values);
toast.success("Deine Änderungen wurden gespeichert!", {
style: {
background: "var(--color-base-100)",
color: "var(--color-base-content)",
},
});
})}
>
<h2 className="card-title mb-5">
<Plane className="w-5 h-5" /> Pilot
</h2>
<div className="content-center">
<label className="floating-label w-full">
<span className="text-lg flex items-center gap-2">
<Bell /> NTFY room
</span>
<input
placeholder="Erhalte eine Benachrichtigung auf dein Handy über NTFY"
className="input input-bordered w-full"
{...form.register("settingsNtfyRoom")}
/>
</label>
<p className="label mt-2 w-full">
<Link
href="https://docs.virtualairrescue.com/docs/Leitstelle/App-Alarmierung#download"
target="_blank"
rel="noopener noreferrer"
className="link link-hover link-primary"
>
Hier
</Link>
findest du mehr Informationen!
</p>
{form.formState.errors.settingsNtfyRoom && (
<p className="text-error">{form.formState.errors.settingsNtfyRoom.message}</p>
)}
</div>
<div className="card-actions justify-center pt-6 mt-auto">
<Button
className="btn-sm btn-wide btn-outline btn-primary"
isLoading={isLoading}
disabled={!form.formState.isDirty}
role="submit"
>
<BookmarkIcon /> Speichern
</Button>
</div>
</form>
);
};

View File

@@ -1,6 +1,6 @@
import { prisma } from "@repo/db"; import { prisma } from "@repo/db";
import { getServerSession } from "../../api/auth/[...nextauth]/auth"; import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import { ProfileForm, SocialForm, PasswordForm, PilotForm, DeleteForm } from "./_components/forms"; import { ProfileForm, SocialForm, PasswordForm, DeleteForm } from "./_components/forms";
import { GearIcon } from "@radix-ui/react-icons"; import { GearIcon } from "@radix-ui/react-icons";
import { Error } from "_components/Error"; import { Error } from "_components/Error";
@@ -48,9 +48,6 @@ export default async function Page() {
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3"> <div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<PasswordForm /> <PasswordForm />
</div> </div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<PilotForm user={user} />
</div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3"> <div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<DeleteForm user={user} penaltys={userPenaltys} /> <DeleteForm user={user} penaltys={userPenaltys} />
</div> </div>