KI-AGENT: LiveKit-Calls nativ in FEDEO integrieren

This commit is contained in:
2026-05-18 19:15:36 +02:00
parent c93ea4284d
commit 248da3412c
7 changed files with 480 additions and 54 deletions

View File

@@ -1,7 +1,8 @@
<script setup>
import { ConnectionState, Room, RoomEvent, Track } from "livekit-client"
const toast = useToast()
const { $api } = useNuxtApp()
const runtimeConfig = useRuntimeConfig()
const status = ref(null)
const identity = ref(null)
@@ -15,7 +16,14 @@ const roomCreateOpen = ref(false)
const matrixCallOpen = ref(false)
const matrixCallMode = ref("video")
const matrixCallLoading = ref(false)
const matrixCallUrl = ref("")
const matrixCallConnected = ref(false)
const matrixCallState = ref("disconnected")
const matrixCallError = ref("")
const matrixCallSession = ref(null)
const matrixCallTiles = ref([])
const matrixCallMicEnabled = ref(true)
const matrixCallCameraEnabled = ref(true)
const matrixCallAudioContainer = ref(null)
const roomCreateForm = ref({
name: "",
key: "",
@@ -33,6 +41,8 @@ const lastUpdated = ref(null)
let matrixRefreshInterval = null
let matrixMessagesRequestActive = false
let matrixMembersRequestActive = false
let matrixLiveKitRoom = null
const matrixCallVideoElements = new Map()
const canUseMatrixChat = computed(() =>
Boolean(status.value?.reachable && status.value?.provisioningConfigured)
@@ -53,32 +63,18 @@ const activeRoomEndpoint = computed(() =>
`/api/communication/matrix/rooms/${encodeURIComponent(activeRoomKey.value)}`
)
const matrixElementUrl = computed(() =>
String(runtimeConfig.public?.matrixElementUrl || "").replace(/\/+$/, "")
)
const activeRoomMatrixAddress = computed(() =>
activeRoom.value?.roomId || activeRoom.value?.alias || ""
)
const activeRoomElementUrl = computed(() => {
if (!matrixElementUrl.value || !activeRoomMatrixAddress.value) return ""
return `${matrixElementUrl.value}/#/room/${encodeURIComponent(activeRoomMatrixAddress.value)}`
})
const canStartMatrixCall = computed(() =>
Boolean(canUseMatrixChat.value && activeRoom.value?.exists && activeRoomElementUrl.value)
Boolean(canUseMatrixChat.value && activeRoom.value?.exists)
)
const matrixCallTitle = computed(() =>
matrixCallMode.value === "audio" ? "Audioanruf" : "Videokonferenz"
)
const activeMatrixCallUrl = computed(() =>
matrixCallUrl.value || activeRoomElementUrl.value
)
const roomCreateKeyPreview = computed(() =>
normalizeRoomKey(roomCreateForm.value.key || roomCreateForm.value.name)
)
@@ -346,17 +342,6 @@ const syncRoomMembers = async () => {
}
}
const buildElementRoomSessionUrl = (session) => {
const roomAddress = session.roomId || session.alias || activeRoomMatrixAddress.value
if (!matrixElementUrl.value || !roomAddress) return ""
const params = new URLSearchParams({
loginToken: session.loginToken
})
return `${matrixElementUrl.value}/?${params.toString()}#/room/${encodeURIComponent(roomAddress)}`
}
const openMatrixCall = async (mode = "video") => {
if (!canStartMatrixCall.value) {
toast.add({
@@ -368,25 +353,175 @@ const openMatrixCall = async (mode = "video") => {
matrixCallMode.value = mode
matrixCallLoading.value = true
matrixCallError.value = ""
matrixCallOpen.value = true
try {
const session = await $api(`${activeRoomEndpoint.value}/session`, {
await leaveMatrixCall()
const session = await $api(`${activeRoomEndpoint.value}/call-session`, {
method: "POST"
})
matrixCallUrl.value = buildElementRoomSessionUrl(session) || activeRoomElementUrl.value
matrixCallSession.value = session
await connectMatrixCall(session, { video: mode === "video" })
} catch (error) {
matrixCallUrl.value = activeRoomElementUrl.value
matrixCallError.value = error?.data?.error || error?.message || "Verbindung fehlgeschlagen"
toast.add({
title: "Matrix-Anmeldung konnte nicht vorbereitet werden",
description: "Der Raum wird ohne automatische Anmeldung geöffnet.",
color: "warning"
title: "Besprechung konnte nicht gestartet werden",
description: matrixCallError.value,
color: "error"
})
} finally {
matrixCallLoading.value = false
}
}
const getParticipantTile = (participant, local = false) => {
const publications = participant.getTrackPublications?.() || []
const videoPublication = publications.find((publication) =>
publication.kind === Track.Kind.Video && publication.track
)
const audioPublication = publications.find((publication) =>
publication.kind === Track.Kind.Audio && publication.track
)
return {
id: local ? "local" : participant.sid || participant.identity,
identity: participant.identity,
name: local ? "Du" : participant.name || participant.identity,
local,
speaking: participant.isSpeaking,
videoTrack: videoPublication?.track || null,
audioTrack: audioPublication?.track || null,
cameraEnabled: participant.isCameraEnabled,
microphoneEnabled: participant.isMicrophoneEnabled,
}
}
const attachMatrixCallMedia = async () => {
await nextTick()
for (const tile of matrixCallTiles.value) {
const videoElement = matrixCallVideoElements.get(tile.id)
if (videoElement && tile.videoTrack) {
tile.videoTrack.attach(videoElement)
}
if (!tile.local && tile.audioTrack && matrixCallAudioContainer.value) {
const audioElement = tile.audioTrack.attach()
audioElement.autoplay = true
audioElement.dataset.participant = tile.id
matrixCallAudioContainer.value.appendChild(audioElement)
}
}
}
const refreshMatrixCallTiles = async () => {
if (!matrixLiveKitRoom) {
matrixCallTiles.value = []
return
}
matrixCallAudioContainer.value?.replaceChildren()
matrixCallTiles.value = [
getParticipantTile(matrixLiveKitRoom.localParticipant, true),
...Array.from(matrixLiveKitRoom.remoteParticipants.values()).map((participant) =>
getParticipantTile(participant)
)
]
await attachMatrixCallMedia()
}
const connectMatrixCall = async (session, { video = true } = {}) => {
const room = new Room({
adaptiveStream: true,
dynacast: true,
})
matrixLiveKitRoom = room
matrixCallState.value = ConnectionState.Connecting
const refreshEvents = [
RoomEvent.TrackSubscribed,
RoomEvent.TrackUnsubscribed,
RoomEvent.LocalTrackPublished,
RoomEvent.LocalTrackUnpublished,
RoomEvent.ParticipantConnected,
RoomEvent.ParticipantDisconnected,
RoomEvent.TrackMuted,
RoomEvent.TrackUnmuted,
RoomEvent.ActiveSpeakersChanged,
]
for (const eventName of refreshEvents) {
room.on(eventName, refreshMatrixCallTiles)
}
room.on(RoomEvent.ConnectionStateChanged, (state) => {
matrixCallState.value = state
matrixCallConnected.value = state === ConnectionState.Connected
})
room.on(RoomEvent.Disconnected, () => {
matrixCallConnected.value = false
matrixCallState.value = ConnectionState.Disconnected
})
await room.connect(session.liveKitUrl, session.liveKitToken)
await room.localParticipant.setMicrophoneEnabled(true)
await room.localParticipant.setCameraEnabled(video)
matrixCallMicEnabled.value = true
matrixCallCameraEnabled.value = video
await refreshMatrixCallTiles()
}
const leaveMatrixCall = async () => {
if (matrixLiveKitRoom) {
matrixLiveKitRoom.disconnect()
matrixLiveKitRoom = null
}
matrixCallVideoElements.clear()
matrixCallAudioContainer.value?.replaceChildren()
matrixCallTiles.value = []
matrixCallConnected.value = false
matrixCallState.value = "disconnected"
}
const closeMatrixCall = async () => {
await leaveMatrixCall()
matrixCallOpen.value = false
}
const setMatrixCallVideoElement = (tileId, element) => {
if (element) {
matrixCallVideoElements.set(tileId, element)
const tile = matrixCallTiles.value.find((item) => item.id === tileId)
tile?.videoTrack?.attach(element)
} else {
matrixCallVideoElements.delete(tileId)
}
}
const toggleMatrixCallMic = async () => {
if (!matrixLiveKitRoom) return
const enabled = !matrixCallMicEnabled.value
await matrixLiveKitRoom.localParticipant.setMicrophoneEnabled(enabled)
matrixCallMicEnabled.value = enabled
await refreshMatrixCallTiles()
}
const toggleMatrixCallCamera = async () => {
if (!matrixLiveKitRoom) return
const enabled = !matrixCallCameraEnabled.value
await matrixLiveKitRoom.localParticipant.setCameraEnabled(enabled)
matrixCallCameraEnabled.value = enabled
await refreshMatrixCallTiles()
}
const loadRoomChat = async ({ silent = false, includeMembers = false } = {}) => {
await loadRoomMessages({ silent })
@@ -488,7 +623,10 @@ onMounted(async () => {
startMatrixAutoRefresh()
})
onBeforeUnmount(stopMatrixAutoRefresh)
onBeforeUnmount(() => {
stopMatrixAutoRefresh()
leaveMatrixCall()
})
</script>
<template>
@@ -978,20 +1116,36 @@ onBeforeUnmount(stopMatrixAutoRefresh)
</div>
<div class="flex items-center gap-2">
<UButton
v-if="activeMatrixCallUrl"
:to="activeMatrixCallUrl"
target="_blank"
icon="i-heroicons-arrow-top-right-on-square"
icon="i-heroicons-microphone"
color="neutral"
variant="outline"
:variant="matrixCallMicEnabled ? 'soft' : 'outline'"
:disabled="!matrixLiveKitRoom"
@click="toggleMatrixCallMic"
>
Extern öffnen
{{ matrixCallMicEnabled ? "Mikro an" : "Mikro aus" }}
</UButton>
<UButton
icon="i-heroicons-video-camera"
color="neutral"
:variant="matrixCallCameraEnabled ? 'soft' : 'outline'"
:disabled="!matrixLiveKitRoom"
@click="toggleMatrixCallCamera"
>
{{ matrixCallCameraEnabled ? "Kamera an" : "Kamera aus" }}
</UButton>
<UButton
icon="i-heroicons-phone-x-mark"
color="error"
variant="soft"
@click="closeMatrixCall"
>
Auflegen
</UButton>
<UButton
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
@click="matrixCallOpen = false"
@click="closeMatrixCall"
/>
</div>
</header>
@@ -1001,16 +1155,68 @@ onBeforeUnmount(stopMatrixAutoRefresh)
v-if="matrixCallLoading"
class="flex h-full items-center justify-center text-sm text-muted"
>
Matrix wird geladen...
Besprechung wird gestartet...
</div>
<iframe
<div
v-else-if="matrixCallError"
class="flex h-full items-center justify-center p-6"
>
<UAlert
icon="i-heroicons-exclamation-triangle"
color="error"
variant="soft"
title="Besprechung konnte nicht gestartet werden"
:description="matrixCallError"
/>
</div>
<div
v-else-if="canStartMatrixCall"
:key="`${activeRoomKey}-${matrixCallMode}`"
:src="activeMatrixCallUrl"
class="h-full w-full border-0"
allow="camera; microphone; display-capture; clipboard-read; clipboard-write; fullscreen; autoplay"
referrerpolicy="no-referrer"
/>
class="flex h-full flex-col"
>
<div class="flex shrink-0 items-center justify-between border-b border-default bg-default px-4 py-2 text-xs text-muted">
<span>{{ matrixCallSession?.liveKitRoomName || activeRoom.key }}</span>
<span>{{ matrixCallState }}</span>
</div>
<div class="grid min-h-0 flex-1 auto-rows-fr gap-3 overflow-y-auto p-3 sm:grid-cols-2 xl:grid-cols-3">
<div
v-for="tile in matrixCallTiles"
:key="tile.id"
class="relative min-h-52 overflow-hidden rounded-lg bg-inverted text-inverted"
>
<video
v-if="tile.videoTrack"
:ref="(element) => setMatrixCallVideoElement(tile.id, element)"
class="h-full w-full object-cover"
autoplay
playsinline
:muted="tile.local"
/>
<div
v-else
class="flex h-full min-h-52 items-center justify-center bg-elevated text-highlighted"
>
<span class="flex size-16 items-center justify-center rounded-lg bg-primary/10 text-xl font-semibold text-primary">
{{ (tile.name || tile.identity || "?").slice(0, 1).toUpperCase() }}
</span>
</div>
<div class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/55 px-3 py-2 text-sm text-white">
<span class="truncate">{{ tile.name }}</span>
<div class="flex items-center gap-2">
<UIcon
:name="tile.microphoneEnabled ? 'i-heroicons-microphone' : 'i-heroicons-microphone'"
class="size-4"
:class="tile.microphoneEnabled ? 'opacity-100' : 'opacity-35'"
/>
<UIcon
:name="tile.cameraEnabled ? 'i-heroicons-video-camera' : 'i-heroicons-video-camera-slash'"
class="size-4"
/>
</div>
</div>
</div>
</div>
<div ref="matrixCallAudioContainer" class="hidden" />
</div>
<div
v-else
class="flex h-full items-center justify-center p-6"
@@ -1020,7 +1226,7 @@ onBeforeUnmount(stopMatrixAutoRefresh)
color="warning"
variant="soft"
title="Besprechung nicht verfügbar"
description="Der Matrix-Raum oder die Element-Integration ist noch nicht bereit."
description="Der Matrix-Raum oder LiveKit ist noch nicht bereit."
/>
</div>
</div>