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

@@ -5,6 +5,7 @@ import { FastifyInstance } from "fastify"
import { authProfiles, authTenantUsers, authUsers, communicationRooms, tenants } from "../../db/schema" import { authProfiles, authTenantUsers, authUsers, communicationRooms, tenants } from "../../db/schema"
import { and, eq } from "drizzle-orm" import { and, eq } from "drizzle-orm"
import { secrets } from "../utils/secrets" import { secrets } from "../utils/secrets"
import jwt from "jsonwebtoken"
type MatrixErrorResponse = { type MatrixErrorResponse = {
errcode?: string errcode?: string
@@ -40,6 +41,13 @@ type MatrixLoginTokenResponse = {
expires_in_ms: number expires_in_ms: number
} }
type LiveKitGrant = {
roomJoin: boolean
room: string
canPublish: boolean
canSubscribe: boolean
}
type MatrixTenantRoomOptions = { type MatrixTenantRoomOptions = {
key?: string key?: string
name?: string name?: string
@@ -153,6 +161,16 @@ export function matrixService(server: FastifyInstance) {
? `wss://${rtcHost()}/livekit/sfu` ? `wss://${rtcHost()}/livekit/sfu`
: `ws://localhost:${process.env.MATRIX_DEV_LIVEKIT_PORT || "7880"}`) : `ws://localhost:${process.env.MATRIX_DEV_LIVEKIT_PORT || "7880"}`)
const livekitKey = () =>
process.env.LIVEKIT_KEY ||
secrets.LIVEKIT_KEY ||
(process.env.NODE_ENV === "production" ? "" : "devkey")
const livekitSecret = () =>
process.env.LIVEKIT_SECRET ||
secrets.LIVEKIT_SECRET ||
(process.env.NODE_ENV === "production" ? "" : "devsecret-local-matrix-stack-32-chars")
const serviceUserLocalpart = () => const serviceUserLocalpart = () =>
process.env.MATRIX_SERVICE_USER_LOCALPART || process.env.MATRIX_SERVICE_USER_LOCALPART ||
secrets.MATRIX_SERVICE_USER_LOCALPART || secrets.MATRIX_SERVICE_USER_LOCALPART ||
@@ -1185,6 +1203,62 @@ export function matrixService(server: FastifyInstance) {
} }
} }
const createLiveKitRoomSession = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {}
) => {
if (!livekitKey() || !livekitSecret()) {
throw Object.assign(
new Error("LIVEKIT_KEY and LIVEKIT_SECRET are not configured"),
{ statusCode: 503 }
)
}
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const displayName = await getCurrentUserDisplayName(userId, tenantId)
const liveKitRoomName = `fedeo-${tenantId || "global"}-${room.key}`.replace(/[^a-zA-Z0-9_-]/g, "_")
const now = Math.floor(Date.now() / 1000)
const expiresInSeconds = 60 * 60
const video: LiveKitGrant = {
roomJoin: true,
room: liveKitRoomName,
canPublish: true,
canSubscribe: true,
}
const token = jwt.sign(
{
sub: session.matrixUserId,
name: displayName,
video,
nbf: now - 10,
exp: now + expiresInSeconds,
},
livekitSecret(),
{
algorithm: "HS256",
issuer: livekitKey(),
}
)
return {
roomId: room.roomId,
alias: room.alias,
key: room.key,
name: room.name,
matrixUserId: session.matrixUserId,
displayName,
liveKitUrl: livekitUrl(),
liveKitRoomName,
liveKitToken: token,
expiresInMs: expiresInSeconds * 1000,
}
}
const listTenantCommunicationUsers = async (tenantId: number | null) => { const listTenantCommunicationUsers = async (tenantId: number | null) => {
const tenant = await getCurrentTenant(tenantId) const tenant = await getCurrentTenant(tenantId)
const rows = await server.db const rows = await server.db
@@ -1342,6 +1416,7 @@ export function matrixService(server: FastifyInstance) {
getTenantRoomMembers, getTenantRoomMembers,
sendTenantRoomMessage, sendTenantRoomMessage,
createElementRoomSession, createElementRoomSession,
createLiveKitRoomSession,
syncTenantRoomMembers, syncTenantRoomMembers,
getGeneralRoomMessages, getGeneralRoomMessages,
getGeneralRoomMembers, getGeneralRoomMembers,

View File

@@ -136,6 +136,17 @@ export default async function communicationRoutes(server: FastifyInstance) {
} }
}) })
server.post("/communication/matrix/rooms/general/call-session", async (req, reply) => {
try {
return await matrix.createLiveKitRoomSession(req.user.user_id, req.user.tenant_id, {
key: "allgemein",
name: "Allgemeiner Chat",
})
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix call session failed")
}
})
server.post("/communication/matrix/rooms/general/members/sync", async (req, reply) => { server.post("/communication/matrix/rooms/general/members/sync", async (req, reply) => {
try { try {
return await matrix.syncTenantRoomMembers(req.user.user_id, req.user.tenant_id, { return await matrix.syncTenantRoomMembers(req.user.user_id, req.user.tenant_id, {
@@ -213,6 +224,18 @@ export default async function communicationRoutes(server: FastifyInstance) {
} }
}) })
server.post("/communication/matrix/rooms/:roomKey/call-session", async (req, reply) => {
try {
return await matrix.createLiveKitRoomSession(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req)
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix call session failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/members/sync", async (req, reply) => { server.post("/communication/matrix/rooms/:roomKey/members/sync", async (req, reply) => {
try { try {
return await matrix.syncTenantRoomMembers( return await matrix.syncTenantRoomMembers(

View File

@@ -45,6 +45,8 @@ export let secrets = {
MATRIX_LIVEKIT_URL?: string MATRIX_LIVEKIT_URL?: string
MATRIX_REGISTRATION_SHARED_SECRET?: string MATRIX_REGISTRATION_SHARED_SECRET?: string
MATRIX_SERVICE_USER_LOCALPART?: string MATRIX_SERVICE_USER_LOCALPART?: string
LIVEKIT_KEY?: string
LIVEKIT_SECRET?: string
} }
const secretKeys = [ const secretKeys = [
@@ -84,6 +86,8 @@ const secretKeys = [
"MATRIX_LIVEKIT_URL", "MATRIX_LIVEKIT_URL",
"MATRIX_REGISTRATION_SHARED_SECRET", "MATRIX_REGISTRATION_SHARED_SECRET",
"MATRIX_SERVICE_USER_LOCALPART", "MATRIX_SERVICE_USER_LOCALPART",
"LIVEKIT_KEY",
"LIVEKIT_SECRET",
] as const ] as const
const numberKeys = new Set(["PORT", "MAILER_SMTP_PORT", "DOKUBOX_IMAP_PORT"]) const numberKeys = new Set(["PORT", "MAILER_SMTP_PORT", "DOKUBOX_IMAP_PORT"])

View File

@@ -200,7 +200,7 @@ services:
keys: keys:
${LIVEKIT_KEY:-fedeo-livekit}: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace} ${LIVEKIT_KEY:-fedeo-livekit}: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
room: room:
auto_create: false auto_create: true
EOF EOF
exec /livekit-server --config /tmp/livekit.yaml exec /livekit-server --config /tmp/livekit.yaml
ports: ports:
@@ -334,7 +334,7 @@ services:
keys: keys:
devkey: devsecret-local-matrix-stack-32-chars devkey: devsecret-local-matrix-stack-32-chars
room: room:
auto_create: false auto_create: true
EOF EOF
exec /livekit-server --config /tmp/livekit.yaml exec /livekit-server --config /tmp/livekit.yaml
ports: ports:

View File

@@ -74,6 +74,7 @@
"image-js": "^1.1.0", "image-js": "^1.1.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"license-checker": "^25.0.1", "license-checker": "^25.0.1",
"livekit-client": "^2.19.0",
"maplibre-gl": "^4.7.0", "maplibre-gl": "^4.7.0",
"nuxt-editorjs": "^1.0.4", "nuxt-editorjs": "^1.0.4",
"nuxt-viewport": "^2.0.6", "nuxt-viewport": "^2.0.6",
@@ -1865,6 +1866,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bufbuild/protobuf": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@capacitor-community/bluetooth-le": { "node_modules/@capacitor-community/bluetooth-le": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/@capacitor-community/bluetooth-le/-/bluetooth-le-7.3.0.tgz", "resolved": "https://registry.npmjs.org/@capacitor-community/bluetooth-le/-/bluetooth-le-7.3.0.tgz",
@@ -3159,6 +3166,21 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@livekit/mutex": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.45.8",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@mapbox/geojson-rewind": { "node_modules/@mapbox/geojson-rewind": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
@@ -8696,6 +8718,13 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/dom-mediacapture-record": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
"license": "MIT",
"peer": true
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -12191,7 +12220,6 @@
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.8.x" "node": ">=0.8.x"
@@ -14209,6 +14237,15 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jpeg-js": { "node_modules/jpeg-js": {
"version": "0.4.4", "version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
@@ -14784,6 +14821,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/livekit-client": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.0.tgz",
"integrity": "sha512-aolY1XDAtx0nHKBNm29W9OhzBnSz1CP5kq3phvRhFfi1NbvMXs8tcACjAkZTnIKgihkp+BiJScZZ3tZv0Gz8sA==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.45.8",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
"sdp-transform": "^2.15.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "9.0.5"
},
"peerDependencies": {
"@types/dom-mediacapture-record": "^1"
}
},
"node_modules/local-pkg": { "node_modules/local-pkg": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
@@ -14858,6 +14915,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -18452,6 +18522,16 @@
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-array-concat": { "node_modules/safe-array-concat": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@@ -18609,6 +18689,21 @@
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sdp": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz",
"integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==",
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/secure-json-parse": { "node_modules/secure-json-parse": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
@@ -20164,6 +20259,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"license": "MIT",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -21718,6 +21822,19 @@
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/webrtc-adapter": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/whatwg-url": { "node_modules/whatwg-url": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",

View File

@@ -87,6 +87,7 @@
"image-js": "^1.1.0", "image-js": "^1.1.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"license-checker": "^25.0.1", "license-checker": "^25.0.1",
"livekit-client": "^2.19.0",
"maplibre-gl": "^4.7.0", "maplibre-gl": "^4.7.0",
"nuxt-editorjs": "^1.0.4", "nuxt-editorjs": "^1.0.4",
"nuxt-viewport": "^2.0.6", "nuxt-viewport": "^2.0.6",

View File

@@ -1,7 +1,8 @@
<script setup> <script setup>
import { ConnectionState, Room, RoomEvent, Track } from "livekit-client"
const toast = useToast() const toast = useToast()
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const runtimeConfig = useRuntimeConfig()
const status = ref(null) const status = ref(null)
const identity = ref(null) const identity = ref(null)
@@ -15,7 +16,14 @@ const roomCreateOpen = ref(false)
const matrixCallOpen = ref(false) const matrixCallOpen = ref(false)
const matrixCallMode = ref("video") const matrixCallMode = ref("video")
const matrixCallLoading = ref(false) 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({ const roomCreateForm = ref({
name: "", name: "",
key: "", key: "",
@@ -33,6 +41,8 @@ const lastUpdated = ref(null)
let matrixRefreshInterval = null let matrixRefreshInterval = null
let matrixMessagesRequestActive = false let matrixMessagesRequestActive = false
let matrixMembersRequestActive = false let matrixMembersRequestActive = false
let matrixLiveKitRoom = null
const matrixCallVideoElements = new Map()
const canUseMatrixChat = computed(() => const canUseMatrixChat = computed(() =>
Boolean(status.value?.reachable && status.value?.provisioningConfigured) Boolean(status.value?.reachable && status.value?.provisioningConfigured)
@@ -53,32 +63,18 @@ const activeRoomEndpoint = computed(() =>
`/api/communication/matrix/rooms/${encodeURIComponent(activeRoomKey.value)}` `/api/communication/matrix/rooms/${encodeURIComponent(activeRoomKey.value)}`
) )
const matrixElementUrl = computed(() =>
String(runtimeConfig.public?.matrixElementUrl || "").replace(/\/+$/, "")
)
const activeRoomMatrixAddress = computed(() => const activeRoomMatrixAddress = computed(() =>
activeRoom.value?.roomId || activeRoom.value?.alias || "" 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(() => const canStartMatrixCall = computed(() =>
Boolean(canUseMatrixChat.value && activeRoom.value?.exists && activeRoomElementUrl.value) Boolean(canUseMatrixChat.value && activeRoom.value?.exists)
) )
const matrixCallTitle = computed(() => const matrixCallTitle = computed(() =>
matrixCallMode.value === "audio" ? "Audioanruf" : "Videokonferenz" matrixCallMode.value === "audio" ? "Audioanruf" : "Videokonferenz"
) )
const activeMatrixCallUrl = computed(() =>
matrixCallUrl.value || activeRoomElementUrl.value
)
const roomCreateKeyPreview = computed(() => const roomCreateKeyPreview = computed(() =>
normalizeRoomKey(roomCreateForm.value.key || roomCreateForm.value.name) 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") => { const openMatrixCall = async (mode = "video") => {
if (!canStartMatrixCall.value) { if (!canStartMatrixCall.value) {
toast.add({ toast.add({
@@ -368,25 +353,175 @@ const openMatrixCall = async (mode = "video") => {
matrixCallMode.value = mode matrixCallMode.value = mode
matrixCallLoading.value = true matrixCallLoading.value = true
matrixCallError.value = ""
matrixCallOpen.value = true matrixCallOpen.value = true
try { try {
const session = await $api(`${activeRoomEndpoint.value}/session`, { await leaveMatrixCall()
const session = await $api(`${activeRoomEndpoint.value}/call-session`, {
method: "POST" method: "POST"
}) })
matrixCallUrl.value = buildElementRoomSessionUrl(session) || activeRoomElementUrl.value matrixCallSession.value = session
await connectMatrixCall(session, { video: mode === "video" })
} catch (error) { } catch (error) {
matrixCallUrl.value = activeRoomElementUrl.value matrixCallError.value = error?.data?.error || error?.message || "Verbindung fehlgeschlagen"
toast.add({ toast.add({
title: "Matrix-Anmeldung konnte nicht vorbereitet werden", title: "Besprechung konnte nicht gestartet werden",
description: "Der Raum wird ohne automatische Anmeldung geöffnet.", description: matrixCallError.value,
color: "warning" color: "error"
}) })
} finally { } finally {
matrixCallLoading.value = false 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 } = {}) => { const loadRoomChat = async ({ silent = false, includeMembers = false } = {}) => {
await loadRoomMessages({ silent }) await loadRoomMessages({ silent })
@@ -488,7 +623,10 @@ onMounted(async () => {
startMatrixAutoRefresh() startMatrixAutoRefresh()
}) })
onBeforeUnmount(stopMatrixAutoRefresh) onBeforeUnmount(() => {
stopMatrixAutoRefresh()
leaveMatrixCall()
})
</script> </script>
<template> <template>
@@ -978,20 +1116,36 @@ onBeforeUnmount(stopMatrixAutoRefresh)
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<UButton <UButton
v-if="activeMatrixCallUrl" icon="i-heroicons-microphone"
:to="activeMatrixCallUrl"
target="_blank"
icon="i-heroicons-arrow-top-right-on-square"
color="neutral" 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>
<UButton <UButton
icon="i-heroicons-x-mark" icon="i-heroicons-x-mark"
color="neutral" color="neutral"
variant="ghost" variant="ghost"
@click="matrixCallOpen = false" @click="closeMatrixCall"
/> />
</div> </div>
</header> </header>
@@ -1001,16 +1155,68 @@ onBeforeUnmount(stopMatrixAutoRefresh)
v-if="matrixCallLoading" v-if="matrixCallLoading"
class="flex h-full items-center justify-center text-sm text-muted" class="flex h-full items-center justify-center text-sm text-muted"
> >
Matrix wird geladen... Besprechung wird gestartet...
</div> </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" v-else-if="canStartMatrixCall"
:key="`${activeRoomKey}-${matrixCallMode}`" class="flex h-full flex-col"
:src="activeMatrixCallUrl" >
class="h-full w-full border-0" <div class="flex shrink-0 items-center justify-between border-b border-default bg-default px-4 py-2 text-xs text-muted">
allow="camera; microphone; display-capture; clipboard-read; clipboard-write; fullscreen; autoplay" <span>{{ matrixCallSession?.liveKitRoomName || activeRoom.key }}</span>
referrerpolicy="no-referrer" <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 <div
v-else v-else
class="flex h-full items-center justify-center p-6" class="flex h-full items-center justify-center p-6"
@@ -1020,7 +1226,7 @@ onBeforeUnmount(stopMatrixAutoRefresh)
color="warning" color="warning"
variant="soft" variant="soft"
title="Besprechung nicht verfügbar" 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>
</div> </div>