diff --git a/src/components/playground/DataChannelLog.tsx b/src/components/playground/DataChannelLog.tsx new file mode 100644 index 00000000..fba07d2a --- /dev/null +++ b/src/components/playground/DataChannelLog.tsx @@ -0,0 +1,90 @@ +import React, { useMemo } from "react"; + +export type DataChannelLogEntry = { + id: string; + timestamp: number; + topic?: string; + participantIdentity?: string; + participantName?: string; + kind: "reliable" | "lossy" | "unknown"; + payload: string; + payloadFormat: "json" | "text" | "binary"; +}; + +type DataChannelLogProps = { + entries: DataChannelLogEntry[]; + onClear: () => void; +}; + +const noEntriesMessage = "No data events received yet."; + +export const DataChannelLog: React.FC = ({ + entries, + onClear, +}: DataChannelLogProps) => { + const sortedEntries = useMemo( + () => [...entries].sort((a, b) => a.timestamp - b.timestamp), + [entries], + ); + + return ( +
+
+ + Room data events + + +
+
+ {sortedEntries.length === 0 ? ( +
+ {noEntriesMessage} +
+ ) : ( +
    + {sortedEntries.map((entry: DataChannelLogEntry) => ( +
  • +
    + + {new Date(entry.timestamp).toLocaleTimeString()} + + + {entry.kind} + + {entry.topic && ( + + {entry.topic} + + )} + {entry.payloadFormat !== "text" && ( + + {entry.payloadFormat} + + )} + {(entry.participantName || entry.participantIdentity) && ( + + {entry.participantName || entry.participantIdentity} + + )} +
    +
    +                  {entry.payload}
    +                
    +
  • + ))} +
+ )} +
+
+ ); +}; + diff --git a/src/components/playground/Playground.tsx b/src/components/playground/Playground.tsx index 77d3cc2f..8b680de4 100644 --- a/src/components/playground/Playground.tsx +++ b/src/components/playground/Playground.tsx @@ -26,13 +26,24 @@ import { useRoomContext, useParticipantAttributes, } from "@livekit/components-react"; -import { ConnectionState, LocalParticipant, Track } from "livekit-client"; +import { + ConnectionState, + DataPacket_Kind, + LocalParticipant, + Participant, + RoomEvent, + Track, +} from "livekit-client"; import { QRCodeSVG } from "qrcode.react"; import { ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import tailwindTheme from "../../lib/tailwindTheme.preval"; import { EditableNameValueRow } from "@/components/config/NameValueRow"; import { AttributesInspector } from "@/components/config/AttributesInspector"; import { RpcPanel } from "./RpcPanel"; +import { + DataChannelLog, + DataChannelLogEntry, +} from "./DataChannelLog"; export interface PlaygroundMeta { name: string; @@ -55,6 +66,7 @@ export default function Playground({ const { config, setUserSettings } = useConfig(); const { name } = useRoomInfo(); const [transcripts, setTranscripts] = useState([]); + const [dataEvents, setDataEvents] = useState([]); const { localParticipant } = useLocalParticipant(); const voiceAssistant = useVoiceAssistant(); @@ -66,6 +78,9 @@ export default function Playground({ const [rpcMethod, setRpcMethod] = useState(""); const [rpcPayload, setRpcPayload] = useState(""); const [showRpc, setShowRpc] = useState(false); + const handleClearDataEvents = useCallback(() => { + setDataEvents([]); + }, []); useEffect(() => { if (roomState === ConnectionState.Connected) { @@ -93,31 +108,105 @@ export default function Playground({ ({ source }) => source === Track.Source.Microphone, ); - const onDataReceived = useCallback( - (msg: any) => { - if (msg.topic === "transcription") { - const decoded = JSON.parse( - new TextDecoder("utf-8").decode(msg.payload), - ); - let timestamp = new Date().getTime(); - if ("timestamp" in decoded && decoded.timestamp > 0) { - timestamp = decoded.timestamp; + const onDataReceived = useCallback((msg: any) => { + if (msg.topic === "transcription") { + const decoded = JSON.parse( + new TextDecoder("utf-8").decode(msg.payload), + ); + let timestamp = new Date().getTime(); + if ("timestamp" in decoded && decoded.timestamp > 0) { + timestamp = decoded.timestamp; + } + setTranscripts((prev) => [ + ...prev, + { + name: "You", + message: decoded.text, + timestamp: timestamp, + isSelf: true, + }, + ]); + } + }, []); + + useDataChannel(onDataReceived); + useEffect(() => { + if (!room) { + return; + } + + const decoder = new TextDecoder("utf-8"); + + const handleDataReceived = ( + payload: Uint8Array, + participant?: Participant, + kind?: DataPacket_Kind, + topic?: string, + ) => { + const timestamp = Date.now(); + let payloadText = ""; + let payloadFormat: DataChannelLogEntry["payloadFormat"] = "binary"; + + try { + payloadText = decoder.decode(payload); + if (payloadText.length > 0) { + payloadFormat = "text"; + const trimmed = payloadText.trim(); + if ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + try { + const parsed = JSON.parse(payloadText); + payloadText = JSON.stringify(parsed, null, 2); + payloadFormat = "json"; + } catch { + payloadFormat = "text"; + } + } } - setTranscripts([ - ...transcripts, + } catch (error) { + payloadText = Array.from(payload) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(" "); + payloadFormat = "binary"; + } + + const kindLabel: DataChannelLogEntry["kind"] = + kind === DataPacket_Kind.RELIABLE + ? "reliable" + : kind === DataPacket_Kind.LOSSY + ? "lossy" + : "unknown"; + + setDataEvents((prev) => { + const next = [ + ...prev, { - name: "You", - message: decoded.text, - timestamp: timestamp, - isSelf: true, + id: `${timestamp}-${Math.random().toString(16).slice(2)}`, + timestamp, + topic, + participantIdentity: participant?.identity, + participantName: participant?.name, + kind: kindLabel, + payload: payloadText, + payloadFormat, }, - ]); - } - }, - [transcripts], - ); + ]; + const maxEntries = 200; + if (next.length > maxEntries) { + return next.slice(next.length - maxEntries); + } + return next; + }); + }; - useDataChannel(onDataReceived); + room.on(RoomEvent.DataReceived, handleDataReceived); + + return () => { + room.off(RoomEvent.DataReceived, handleDataReceived); + }; + }, [room]); const videoTileContent = useMemo(() => { const videoFitClassName = `object-${config.video_fit || "contain"}`; @@ -228,6 +317,14 @@ export default function Playground({ voiceAssistant.audioTrack, voiceAssistant.agent, ]); + const dataEventsContent = useMemo(() => { + return ( + + ); + }, [dataEvents, handleClearDataEvents]); const handleRpcCall = useCallback(async () => { if (!voiceAssistant.agent || !room) { @@ -536,6 +633,11 @@ export default function Playground({ }); } + mobileTabs.push({ + title: "Events", + content:
{dataEventsContent}
, + }); + mobileTabs.push({ title: "Settings", content: ( @@ -574,8 +676,21 @@ export default function Playground({ initialTab={mobileTabs.length - 1} /> + + {/* Data Events - Left Column */} + {config.settings.outputs.data_events && ( + + {dataEventsContent} + + )} + + {/* Video/Audio - Center-Left Column */}