completed Audio frontend
This commit is contained in:
@@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
import { useTalkStore } from "../../_store/useTalkStore";
|
||||
|
||||
export const ToggleTalkButton = () => {
|
||||
const { isTalking, toggleTalking } = useTalkStore();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTalking}
|
||||
className={`${
|
||||
isTalking
|
||||
? "bg-red-500 hover:bg-red-600"
|
||||
: "bg-transparent hover:bg-neutral-300"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { ToggleTalkButton } from "../ToggleTalkButton";
|
||||
import Link from "next/link";
|
||||
import { Connection } from "../Connection";
|
||||
import { Connection } from "./_components/Connection";
|
||||
import { ThemeSwap } from "./_components/ThemeSwap";
|
||||
import { useState } from "react";
|
||||
import { Audio } from "./_components/Audio";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Navbar() {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
@@ -25,14 +24,7 @@ export default function Navbar() {
|
||||
<a className="btn btn-ghost text-xl">VAR Leitstelle V2</a>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ul className="menu menu-horizontal bg-base-200 rounded-box flex items-center gap-2">
|
||||
<li>
|
||||
<ToggleTalkButton />
|
||||
</li>
|
||||
<li>
|
||||
<Audio />
|
||||
</li>
|
||||
</ul>
|
||||
<Audio />
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<ThemeSwap isDark={isDark} toggleTheme={toggleTheme} />
|
||||
|
||||
@@ -1,159 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useConnectionStore } from "_store/connectionStore";
|
||||
import {
|
||||
LocalParticipant,
|
||||
LocalTrackPublication,
|
||||
Participant,
|
||||
RemoteParticipant,
|
||||
RemoteTrack,
|
||||
RemoteTrackPublication,
|
||||
Room,
|
||||
RoomEvent,
|
||||
Track,
|
||||
VideoPresets,
|
||||
} from "livekit-client";
|
||||
import { connectionStore } from "../../../../_store/connectionStore";
|
||||
Circle,
|
||||
Disc,
|
||||
Mic,
|
||||
PlugZap,
|
||||
ShieldQuestion,
|
||||
Signal,
|
||||
SignalLow,
|
||||
SignalMedium,
|
||||
WifiOff,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
import { useAudioStore } from "_store/audioStore";
|
||||
import { cn } from "helpers/cn";
|
||||
import { ConnectionQuality } from "livekit-client";
|
||||
|
||||
const ROOMS = ["LST_01", "LST_02", "LST_03", "LST_04", "LST_05"];
|
||||
|
||||
export const Audio = () => {
|
||||
const connection = connectionStore();
|
||||
const [token, setToken] = useState("");
|
||||
const [room, setRoom] = useState<Room | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchToken = async () => {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL}/livekit/token`,
|
||||
);
|
||||
const data = await response.json();
|
||||
setToken(data.token);
|
||||
};
|
||||
|
||||
fetchToken();
|
||||
}, []);
|
||||
const connection = useConnectionStore();
|
||||
const {
|
||||
isTalking,
|
||||
toggleTalking,
|
||||
connect,
|
||||
state,
|
||||
connectionQuality,
|
||||
disconnect,
|
||||
remoteParticipants,
|
||||
room,
|
||||
} = useAudioStore();
|
||||
const [selectedRoom, setSelectedRoom] = useState<string>("LST_01");
|
||||
|
||||
useEffect(() => {
|
||||
const joinRoom = async () => {
|
||||
if (!connection.isConnected) return;
|
||||
if (!token) return;
|
||||
if (!process.env.NEXT_PUBLIC_LIVEKIT_URL)
|
||||
return console.error("NEXT_PUBLIC_LIVEKIT_URL not set");
|
||||
console.log("Connecting to room", {
|
||||
token,
|
||||
url: process.env.NEXT_PUBLIC_LIVEKIT_URL,
|
||||
});
|
||||
const url = "ws://localhost:7880";
|
||||
/* const token =
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJ2aWRlbyI6eyJyb29tSm9pbiI6dHJ1ZSwicm9vbSI6InF1aWNrc3RhcnQtcm9vbSJ9LCJpc3MiOiJBUElBbnNHZHRkWXAySG8iLCJleHAiOjE3NDIxNjAwOTIsIm5iZiI6MCwic3ViIjoicXVpY2tzdGFydC11c2VybmFtZSJ9.ih7my6oXby6yYBfzt5LXHKN5WZU9exIqS8CdpRJRLQI"; */
|
||||
console.log("Connecting to room", { token, url });
|
||||
const room = new Room({
|
||||
// automatically manage subscribed video quality
|
||||
adaptiveStream: true,
|
||||
|
||||
// optimize publishing bandwidth and CPU for published tracks
|
||||
dynacast: true,
|
||||
|
||||
// default capture settings
|
||||
videoCaptureDefaults: {
|
||||
resolution: VideoPresets.h720.resolution,
|
||||
},
|
||||
});
|
||||
|
||||
// pre-warm connection, this can be called as early as your page is loaded
|
||||
room.prepareConnection(url, token);
|
||||
|
||||
// set up event listeners
|
||||
room
|
||||
.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
|
||||
.on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed)
|
||||
.on(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakerChange)
|
||||
.on(RoomEvent.Disconnected, handleDisconnect)
|
||||
.on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished);
|
||||
|
||||
// connect to room
|
||||
await room.connect(url, token);
|
||||
console.log("connected to room", room.name);
|
||||
|
||||
// publish local camera and mic tracks
|
||||
await room.localParticipant.enableCameraAndMicrophone();
|
||||
|
||||
function handleTrackSubscribed(
|
||||
track: RemoteTrack,
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
) {
|
||||
if (
|
||||
track.kind === Track.Kind.Video ||
|
||||
track.kind === Track.Kind.Audio
|
||||
) {
|
||||
// attach it to a new HTMLVideoElement or HTMLAudioElement
|
||||
const element = track.attach();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTrackUnsubscribed(
|
||||
track: RemoteTrack,
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
) {
|
||||
// remove tracks from all attached elements
|
||||
track.detach();
|
||||
}
|
||||
|
||||
function handleLocalTrackUnpublished(
|
||||
publication: LocalTrackPublication,
|
||||
participant: LocalParticipant,
|
||||
) {
|
||||
// when local tracks are ended, update UI to remove them from rendering
|
||||
publication.track?.detach();
|
||||
}
|
||||
|
||||
function handleActiveSpeakerChange(speakers: Participant[]) {
|
||||
// show UI indicators when participant is speaking
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
console.log("disconnected from room");
|
||||
}
|
||||
setRoom(room);
|
||||
if (state === "connected") return;
|
||||
connect(selectedRoom);
|
||||
};
|
||||
|
||||
joinRoom();
|
||||
|
||||
return () => {
|
||||
room?.disconnect();
|
||||
disconnect();
|
||||
};
|
||||
}, [token, connection.isConnected]);
|
||||
}, [connection.isConnected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<details className="dropdown">
|
||||
<summary className="dropdown flex items-center gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="badge badge-soft badge-success">1</div>
|
||||
</summary>
|
||||
<ul className="menu dropdown-content bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm">
|
||||
<li>
|
||||
<a>Rufgruppe 1</a>
|
||||
</li>
|
||||
<li>
|
||||
<a>Rufgruppe 2</a>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
<div className="bg-base-200 rounded-box flex items-center gap-2 p-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (state === "connected") toggleTalking();
|
||||
if (state === "disconnected") connect(selectedRoom);
|
||||
}}
|
||||
className={cn(
|
||||
"btn btn-sm btn-soft border-none hover:bg-inherit",
|
||||
!isTalking && "bg-transparent hover:bg-sky-400/20",
|
||||
isTalking && "bg-red-500 hover:bg-red-600",
|
||||
state === "disconnected" && "bg-red-500 hover:bg-red-500",
|
||||
state === "connecting" &&
|
||||
"bg-yellow-500 hover:bg-yellow-500 cursor-default",
|
||||
)}
|
||||
>
|
||||
{state === "connected" && <Mic />}
|
||||
{state === "disconnected" && <WifiOff />}
|
||||
{state === "connecting" && <PlugZap />}
|
||||
</button>
|
||||
|
||||
{state === "connected" && (
|
||||
<details className="dropdown">
|
||||
<summary className="dropdown btn btn-ghost flex items-center gap-1">
|
||||
{connectionQuality === ConnectionQuality.Excellent && <Signal />}
|
||||
{connectionQuality === ConnectionQuality.Good && <SignalMedium />}
|
||||
{connectionQuality === ConnectionQuality.Poor && <SignalLow />}
|
||||
{connectionQuality === ConnectionQuality.Lost && <ZapOff />}
|
||||
{connectionQuality === ConnectionQuality.Unknown && (
|
||||
<ShieldQuestion />
|
||||
)}
|
||||
<div className="badge badge-soft badge-success">
|
||||
{remoteParticipants}
|
||||
</div>
|
||||
</summary>
|
||||
<ul className="menu dropdown-content bg-base-100 rounded-box z-[99999999] w-52 p-2 shadow-sm">
|
||||
{ROOMS.map((r) => (
|
||||
<li key={r}>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost text-left"
|
||||
onClick={() => {
|
||||
if (selectedRoom === r) return;
|
||||
setSelectedRoom(r);
|
||||
connect(r);
|
||||
}}
|
||||
>
|
||||
{room?.name === r && (
|
||||
<Disc className="text-success text-sm" width={15} />
|
||||
)}
|
||||
{r}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { connectionStore } from "../../_store/connectionStore";
|
||||
import { useConnectionStore } from "../../../../_store/connectionStore";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
export const ConnectionBtn = () => {
|
||||
const modalRef = useRef<HTMLDialogElement>(null);
|
||||
const connection = connectionStore((state) => state);
|
||||
const connection = useConnectionStore((state) => state);
|
||||
const [form, setForm] = useState({
|
||||
logoffTime: "",
|
||||
selectedZone: "LST_01",
|
||||
82
apps/dispatch/app/_store/audioStore.ts
Normal file
82
apps/dispatch/app/_store/audioStore.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
handleActiveSpeakerChange,
|
||||
handleDisconnect,
|
||||
handleLocalTrackUnpublished,
|
||||
handleTrackSubscribed,
|
||||
handleTrackUnsubscribed,
|
||||
} from "helpers/liveKitEventHandler";
|
||||
import { ConnectionQuality, Room, RoomEvent } from "livekit-client";
|
||||
import { create } from "zustand";
|
||||
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
type TalkState = {
|
||||
isTalking: boolean;
|
||||
state: "connecting" | "connected" | "disconnected";
|
||||
connectionQuality: ConnectionQuality;
|
||||
remoteParticipants: number;
|
||||
toggleTalking: () => void;
|
||||
|
||||
connect: (roomName: string) => void;
|
||||
disconnect: () => void;
|
||||
room: Room | null;
|
||||
};
|
||||
const getToken = async (roomName: string) => {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL}/livekit/token?roomName=${roomName}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
return data.token;
|
||||
};
|
||||
|
||||
export const useAudioStore = create<TalkState>((set, get) => ({
|
||||
isTalking: false,
|
||||
state: "disconnected",
|
||||
remoteParticipants: 0,
|
||||
connectionQuality: ConnectionQuality.Unknown,
|
||||
room: null,
|
||||
toggleTalking: () => set((state) => ({ isTalking: !state.isTalking })),
|
||||
connect: async (roomName) => {
|
||||
const connectedRoom = get().room;
|
||||
if (interval) clearInterval(interval);
|
||||
if (connectedRoom) {
|
||||
connectedRoom.disconnect();
|
||||
connectedRoom.removeAllListeners();
|
||||
}
|
||||
set({ state: "connecting" });
|
||||
const url = process.env.NEXT_PUBLIC_LIVEKIT_URL;
|
||||
if (!url) return console.error("NEXT_PUBLIC_LIVEKIT_URL not set");
|
||||
const token = await getToken(roomName);
|
||||
const room = new Room();
|
||||
await room.prepareConnection(url, token);
|
||||
room
|
||||
// Connection events
|
||||
.on(RoomEvent.Connected, () => {
|
||||
set({ state: "connected", room });
|
||||
})
|
||||
.on(RoomEvent.Disconnected, () => {
|
||||
set({ state: "disconnected" });
|
||||
|
||||
handleDisconnect();
|
||||
})
|
||||
.on(RoomEvent.ConnectionQualityChanged, (connectionQuality) =>
|
||||
set({ connectionQuality }),
|
||||
)
|
||||
|
||||
// Track events
|
||||
.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
|
||||
.on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed)
|
||||
.on(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakerChange)
|
||||
.on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished);
|
||||
await room.connect(url, token);
|
||||
interval = setInterval(() => {
|
||||
set({
|
||||
remoteParticipants:
|
||||
room.numParticipants === 0 ? 0 : room.numParticipants - 1, // Unreliable and delayed
|
||||
});
|
||||
}, 500);
|
||||
},
|
||||
disconnect: () => {
|
||||
get().room?.disconnect();
|
||||
},
|
||||
}));
|
||||
@@ -12,7 +12,7 @@ interface ConnectionStore {
|
||||
disconnect: () => void;
|
||||
}
|
||||
|
||||
export const connectionStore = create<ConnectionStore>((set) => ({
|
||||
export const useConnectionStore = create<ConnectionStore>((set) => ({
|
||||
isConnected: false,
|
||||
selectedZone: "LST_01",
|
||||
connect: async (uid, selectedZone, logoffTime) =>
|
||||
@@ -34,8 +34,8 @@ export const connectionStore = create<ConnectionStore>((set) => ({
|
||||
}));
|
||||
|
||||
socket.on("connect", () => {
|
||||
connectionStore.setState({ isConnected: true });
|
||||
useConnectionStore.setState({ isConnected: true });
|
||||
});
|
||||
socket.on("disconnect", () => {
|
||||
connectionStore.setState({ isConnected: false });
|
||||
useConnectionStore.setState({ isConnected: false });
|
||||
});
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
type TalkState = {
|
||||
isTalking: boolean;
|
||||
toggleTalking: () => void;
|
||||
};
|
||||
|
||||
export const useTalkStore = create<TalkState>((set) => ({
|
||||
isTalking: false,
|
||||
toggleTalking: () => set((state) => ({ isTalking: !state.isTalking })),
|
||||
}));
|
||||
6
apps/dispatch/app/helpers/cn.ts
Normal file
6
apps/dispatch/app/helpers/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import clsx, { ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const cn = (...inputs: ClassValue[]) => {
|
||||
return twMerge(clsx(inputs));
|
||||
};
|
||||
46
apps/dispatch/app/helpers/liveKitEventHandler.ts
Normal file
46
apps/dispatch/app/helpers/liveKitEventHandler.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
LocalParticipant,
|
||||
LocalTrackPublication,
|
||||
Participant,
|
||||
RemoteParticipant,
|
||||
RemoteTrack,
|
||||
RemoteTrackPublication,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
|
||||
export const handleTrackSubscribed = (
|
||||
track: RemoteTrack,
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
) => {
|
||||
if (track.kind === Track.Kind.Video || track.kind === Track.Kind.Audio) {
|
||||
// attach it to a new HTMLVideoElement or HTMLAudioElement
|
||||
const element = track.attach();
|
||||
element.play();
|
||||
}
|
||||
};
|
||||
|
||||
export const handleTrackUnsubscribed = (
|
||||
track: RemoteTrack,
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
) => {
|
||||
// remove tracks from all attached elements
|
||||
track.detach();
|
||||
};
|
||||
|
||||
export const handleLocalTrackUnpublished = (
|
||||
publication: LocalTrackPublication,
|
||||
participant: LocalParticipant,
|
||||
) => {
|
||||
// when local tracks are ended, update UI to remove them from rendering
|
||||
publication.track?.detach();
|
||||
};
|
||||
|
||||
export const handleActiveSpeakerChange = (speakers: Participant[]) => {
|
||||
// show UI indicators when participant is speaking
|
||||
};
|
||||
|
||||
export const handleDisconnect = () => {
|
||||
console.log("disconnected from room");
|
||||
};
|
||||
@@ -18,6 +18,7 @@
|
||||
"@tailwindcss/postcss": "^4.0.14",
|
||||
"leaflet": "^1.9.4",
|
||||
"livekit-client": "^2.9.7",
|
||||
"lucide-react": "^0.482.0",
|
||||
"next": "^15.1.0",
|
||||
"next-auth": "^4.24.11",
|
||||
"postcss": "^8.5.1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./app",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
|
||||
Reference in New Issue
Block a user