completed Audio frontend

This commit is contained in:
PxlLoewe
2025-03-17 00:41:07 -07:00
parent e722adaf8e
commit 9a7049ec44
28 changed files with 252 additions and 889 deletions

View File

@@ -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>
);
};

View File

@@ -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} />

View File

@@ -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>
</>
);
};

View File

@@ -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",

View 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();
},
}));

View File

@@ -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 });
});

View File

@@ -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 })),
}));

View File

@@ -0,0 +1,6 @@
import clsx, { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs));
};

View 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");
};

View File

@@ -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",

View File

@@ -1,6 +1,7 @@
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"baseUrl": "./app",
"plugins": [
{
"name": "next"