From 10e6e7a80af3a7738b1cdbf8a851197b94c224f0 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 17 Jun 2026 12:53:03 +0200 Subject: [PATCH] videochat integriert --- ADSENSE.md | 10 +- README-DEPLOYMENT.md | 2 +- android/README.md | 2 +- android/app/build.gradle.kts | 1 + android/app/src/main/AndroidManifest.xml | 1 + .../de/ypchat/android/core/AppContainer.kt | 4 +- .../ypchat/android/data/api/SocketClient.kt | 175 +++ .../de/ypchat/android/data/model/Models.kt | 65 + .../ypchat/android/data/model/SocketEvent.kt | 12 + .../android/data/repository/ChatRepository.kt | 226 +++- .../android/media/AndroidVideoCallManager.kt | 391 ++++++ .../de/ypchat/android/ui/ChatViewModel.kt | 16 + .../java/de/ypchat/android/ui/YpChatRoot.kt | 721 ++++++++-- android/app/src/main/res/values/strings.xml | 31 + client/src/components/ChatWindow.vue | 152 ++- client/src/components/FloatingVideoWindow.vue | 240 ++++ client/src/components/VideoDock.vue | 204 +++ client/src/components/VideoSessionSurface.vue | 102 ++ client/src/stores/chat.js | 1198 +++++++++++------ client/src/views/ChatView.vue | 135 ++ docs/videochat-umsetzungsplan.md | 436 ++++++ server/broadcast.js | 829 +++++++++++- 22 files changed, 4443 insertions(+), 510 deletions(-) create mode 100644 android/app/src/main/java/de/ypchat/android/media/AndroidVideoCallManager.kt create mode 100644 client/src/components/FloatingVideoWindow.vue create mode 100644 client/src/components/VideoDock.vue create mode 100644 client/src/components/VideoSessionSurface.vue create mode 100644 docs/videochat-umsetzungsplan.md diff --git a/ADSENSE.md b/ADSENSE.md index 1955f1f..3bd8737 100644 --- a/ADSENSE.md +++ b/ADSENSE.md @@ -6,11 +6,11 @@ Im Header kann ein Google-AdSense-Banner eingeblendet werden. Die Einbindung ist ## Bereits im Code vorbereitet -- Header-Komponente: [HeaderAdBanner.vue](/mnt/share/torsten/Programs/SingleChat/client/src/components/HeaderAdBanner.vue) +- Header-Komponente: [HeaderAdBanner.vue](/mnt/share/torsten/Programs/YpChat/client/src/components/HeaderAdBanner.vue) - Einbindung in die Kopfzeilen: - - [ChatView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/ChatView.vue) - - [PartnersView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/PartnersView.vue) - - [FeedbackView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/FeedbackView.vue) + - [ChatView.vue](/mnt/share/torsten/Programs/YpChat/client/src/views/ChatView.vue) + - [PartnersView.vue](/mnt/share/torsten/Programs/YpChat/client/src/views/PartnersView.vue) + - [FeedbackView.vue](/mnt/share/torsten/Programs/YpChat/client/src/views/FeedbackView.vue) Aktiv wird der Banner nur mit: @@ -56,7 +56,7 @@ Wichtig: - die Datei muss öffentlich unter `https://ypchat.net/ads.txt` erreichbar sein - Änderungen brauchen oft etwas Zeit, bis Google sie erkennt -Im Projekt liegt aktuell eine Datei unter [docroot/ads.txt](/mnt/share/torsten/Programs/SingleChat/docroot/ads.txt). Diese muss auf deine echte Publisher-ID geprüft und ggf. angepasst werden. +Im Projekt liegt aktuell eine Datei unter [docroot/ads.txt](/mnt/share/torsten/Programs/YpChat/docroot/ads.txt). Diese muss auf deine echte Publisher-ID geprüft und ggf. angepasst werden. ## Was im Projekt erledigt werden muss diff --git a/README-DEPLOYMENT.md b/README-DEPLOYMENT.md index 0bb5658..85915f2 100644 --- a/README-DEPLOYMENT.md +++ b/README-DEPLOYMENT.md @@ -13,7 +13,7 @@ ```bash # Als root oder mit sudo sudo mkdir -p /opt/ypchat -sudo cp -r /home/torsten/Programs/SingleChat/* /opt/ypchat/ +sudo cp -r /home/torsten/Programs/YpChat/* /opt/ypchat/ sudo chown -R www-data:www-data /opt/ypchat ``` diff --git a/android/README.md b/android/README.md index 750ce5e..87affaa 100644 --- a/android/README.md +++ b/android/README.md @@ -1,6 +1,6 @@ # YPChat Android -Native Android-App fuer den bestehenden SingleChat/YPChat-Server. +Native Android-App fuer den bestehenden YPChat-Server. ## Stack diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 21982a2..fa624a3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -113,6 +113,7 @@ dependencies { } implementation("io.coil-kt.coil3:coil-compose:3.4.0") implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") + implementation("io.github.webrtc-sdk:android:125.6422.07") debugImplementation("androidx.compose.ui:ui-tooling") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cf5372a..50d9866 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@  + args.firstJson()?.let { emit(SocketEvent.UnreadChats(it.optInt("count", 0))) } } + s.on("videoConsent:update") { args -> + args.firstJson()?.let { emit(SocketEvent.VideoConsentUpdate(it.toVideoConsentDto())) } + } + s.on("videoCall:invite") { args -> + args.firstJson()?.let { emit(SocketEvent.VideoCallInvite(it.toVideoCallDto())) } + } + s.on("videoCall:incoming") { args -> + args.firstJson()?.let { emit(SocketEvent.VideoCallIncoming(it.toVideoCallDto())) } + } + s.on("videoCall:start") { args -> + args.firstJson()?.let { emit(SocketEvent.VideoCallStart(it.toVideoCallDto())) } + } + s.on("videoCall:update") { args -> + args.firstJson()?.let { emit(SocketEvent.VideoCallUpdate(it.toVideoCallDto())) } + } + s.on("videoCall:reject") { args -> + args.firstJson()?.let { emit(SocketEvent.VideoCallReject(it.toVideoCallDto())) } + } + s.on("videoCall:cancel") { args -> + args.firstJson()?.let { emit(SocketEvent.VideoCallCancel(it.toVideoCallDto())) } + } + s.on("videoCall:end") { args -> + args.firstJson()?.let { emit(SocketEvent.VideoCallEnd(it.toVideoCallDto())) } + } + s.on("videoCall:muteState") { args -> + args.firstJson()?.let { emit(SocketEvent.VideoCallMuteState(it.toVideoCallDto())) } + } + s.on("videoCall:capacity") { args -> + args.firstJson()?.let { emit(SocketEvent.VideoCallCapacity(it.toVideoCapacityDto())) } + } + s.on("videoCall:signal") { args -> + args.firstJson()?.let { emit(SocketEvent.VideoCallSignal(it.toVideoSignalDto())) } + } + s.on("videoCall:error") { args -> + args.firstJson()?.let { json -> + emit( + SocketEvent.VideoCallError( + code = json.optStringOrNull("code"), + message = json.optString("message", "Video-Fehler"), + withUserName = json.optStringOrNull("withUserName"), + callId = json.optStringOrNull("callId") + ) + ) + } + } s.on("userBlocked") { args -> args.firstJson()?.let { emit(SocketEvent.UserBlocked(it.optString("userName"))) } } @@ -210,6 +263,55 @@ class SocketClient( fun requestOpenConversations() = socket?.emit("requestOpenConversations") fun blockUser(userName: String) = socket?.emit("blockUser", JSONObject().put("userName", userName)) fun unblockUser(userName: String) = socket?.emit("unblockUser", JSONObject().put("userName", userName)) + fun setVideoConsent(withUserName: String, allowed: Boolean) = + socket?.emit("videoConsent:set", JSONObject().put("withUserName", withUserName).put("allowed", allowed)) + fun inviteVideoCall(withUserName: String) = + socket?.emit("videoCall:invite", JSONObject().put("withUserName", withUserName)) + fun acceptVideoCall(callId: String) = + socket?.emit("videoCall:accept", JSONObject().put("callId", callId)) + fun rejectVideoCall(callId: String) = + socket?.emit("videoCall:reject", JSONObject().put("callId", callId)) + fun cancelVideoCall(callId: String) = + socket?.emit("videoCall:cancel", JSONObject().put("callId", callId)) + fun endVideoCall(callId: String) = + socket?.emit("videoCall:end", JSONObject().put("callId", callId)) + fun setVideoMuteState(callId: String, muted: Boolean) = + socket?.emit("videoCall:muteState", JSONObject().put("callId", callId).put("muted", muted)) + fun sendVideoSignal(signal: VideoSignalDto) { + val payload = JSONObject() + .put("callId", signal.callId) + .put("signalType", signal.signalType) + + signal.description?.let { + payload.put( + "description", + JSONObject() + .put("type", it.type) + .put("sdp", it.sdp) + ) + } + + signal.candidate?.let { + payload.put( + "candidate", + JSONObject() + .put("candidate", it.candidate) + .put("sdpMid", it.sdpMid) + .put("sdpMLineIndex", it.sdpMLineIndex) + .put("usernameFragment", it.usernameFragment) + .put("type", it.type) + ) + } + + socket?.emit("videoCall:signal", payload) + } + fun setVideoConnectionState(callId: String, connectionState: String) = + socket?.emit( + "videoCall:connectionState", + JSONObject() + .put("callId", callId) + .put("connectionState", connectionState) + ) private fun emit(event: SocketEvent) { scope.launch { _events.emit(event) } @@ -252,6 +354,79 @@ private fun JSONObject.toInboxItemDto(): InboxItemDto = InboxItemDto( unreadCount = optInt("unreadCount", 0) ) +private fun JSONObject.toVideoConsentDto(): VideoConsentDto = VideoConsentDto( + withUserName = optStringOrNull("withUserName"), + localConsent = optBoolean("localConsent", false), + remoteConsent = optBoolean("remoteConsent", false), + videoVisible = optBoolean("videoVisible", false) +) + +private fun JSONObject.toVideoCallDto(): VideoCallDto = VideoCallDto( + callId = optString("callId"), + roomId = optStringOrNull("roomId"), + withUserName = optStringOrNull("withUserName"), + initiatedBy = optStringOrNull("initiatedBy"), + status = optString("status"), + createdAt = optString("createdAt"), + updatedAt = optString("updatedAt"), + endedAt = optStringOrNull("endedAt"), + reason = optStringOrNull("reason"), + localMuted = optBoolean("localMuted", false), + remoteMuted = optBoolean("remoteMuted", false), + connectionState = optString("connectionState", "new"), + remoteConnectionState = optString("remoteConnectionState", "new"), + media = optJSONObject("media")?.toVideoMediaDto() +) + +private fun JSONObject.toVideoCapacityDto(): VideoCapacityDto = VideoCapacityDto( + activeConnections = optInt("activeConnections", 0), + maxConnections = optInt("maxConnections", 3), + reachedMax = optBoolean("reachedMax", false) +) + +private fun JSONObject.toVideoMediaDto(): VideoMediaDto = VideoMediaDto( + mode = optString("mode"), + relayOnly = optBoolean("relayOnly", false), + iceTransportPolicy = optString("iceTransportPolicy", "relay"), + iceServers = optJSONArray("iceServers")?.toObjectList { it.toVideoIceServerDto() }.orEmpty(), + isCaller = optBoolean("isCaller", false) +) + +private fun JSONObject.toVideoIceServerDto(): VideoIceServerDto { + val urlsValue = opt("urls") + val urls = when (urlsValue) { + is JSONArray -> urlsValue.toStringList() + is String -> listOf(urlsValue) + else -> emptyList() + } + return VideoIceServerDto( + urls = urls, + username = optStringOrNull("username"), + credential = optStringOrNull("credential") + ) +} + +private fun JSONObject.toVideoSignalDto(): VideoSignalDto = VideoSignalDto( + callId = optString("callId"), + fromUserName = optStringOrNull("fromUserName"), + signalType = optString("signalType"), + description = optJSONObject("description")?.toVideoSessionDescriptionDto(), + candidate = optJSONObject("candidate")?.toVideoIceCandidateDto() +) + +private fun JSONObject.toVideoSessionDescriptionDto(): VideoSessionDescriptionDto = VideoSessionDescriptionDto( + type = optString("type"), + sdp = optString("sdp") +) + +private fun JSONObject.toVideoIceCandidateDto(): VideoIceCandidateDto = VideoIceCandidateDto( + candidate = optString("candidate"), + sdpMid = optStringOrNull("sdpMid"), + sdpMLineIndex = optInt("sdpMLineIndex", 0), + usernameFragment = optStringOrNull("usernameFragment"), + type = optStringOrNull("type") +) + private fun JSONArray?.toStringList(): List { if (this == null) return emptyList() return List(length()) { index -> opt(index)?.toString().orEmpty() } diff --git a/android/app/src/main/java/de/ypchat/android/data/model/Models.kt b/android/app/src/main/java/de/ypchat/android/data/model/Models.kt index 9cb4181..4a5a929 100644 --- a/android/app/src/main/java/de/ypchat/android/data/model/Models.kt +++ b/android/app/src/main/java/de/ypchat/android/data/model/Models.kt @@ -34,6 +34,71 @@ data class InboxItemDto( val unreadCount: Int = 0 ) +data class VideoConsentDto( + val withUserName: String? = null, + val localConsent: Boolean = false, + val remoteConsent: Boolean = false, + val videoVisible: Boolean = false +) + +data class VideoCallDto( + val callId: String = "", + val roomId: String? = null, + val withUserName: String? = null, + val initiatedBy: String? = null, + val status: String = "", + val createdAt: String = "", + val updatedAt: String = "", + val endedAt: String? = null, + val reason: String? = null, + val localMuted: Boolean = false, + val remoteMuted: Boolean = false, + val connectionState: String = "new", + val remoteConnectionState: String = "new", + val media: VideoMediaDto? = null +) + +data class VideoCapacityDto( + val activeConnections: Int = 0, + val maxConnections: Int = 3, + val reachedMax: Boolean = false +) + +data class VideoMediaDto( + val mode: String = "", + val relayOnly: Boolean = false, + val iceTransportPolicy: String = "relay", + val iceServers: List = emptyList(), + val isCaller: Boolean = false +) + +data class VideoIceServerDto( + val urls: List = emptyList(), + val username: String? = null, + val credential: String? = null +) + +data class VideoSessionDescriptionDto( + val type: String = "", + val sdp: String = "" +) + +data class VideoIceCandidateDto( + val candidate: String = "", + val sdpMid: String? = null, + val sdpMLineIndex: Int = 0, + val usernameFragment: String? = null, + val type: String? = null +) + +data class VideoSignalDto( + val callId: String = "", + val fromUserName: String? = null, + val signalType: String = "", + val description: VideoSessionDescriptionDto? = null, + val candidate: VideoIceCandidateDto? = null +) + data class CountryOption( val englishName: String, val displayName: String, diff --git a/android/app/src/main/java/de/ypchat/android/data/model/SocketEvent.kt b/android/app/src/main/java/de/ypchat/android/data/model/SocketEvent.kt index a03b3ec..c342e8b 100644 --- a/android/app/src/main/java/de/ypchat/android/data/model/SocketEvent.kt +++ b/android/app/src/main/java/de/ypchat/android/data/model/SocketEvent.kt @@ -11,6 +11,18 @@ sealed interface SocketEvent { data class HistoryResults(val results: List) : SocketEvent data class InboxResults(val results: List) : SocketEvent data class UnreadChats(val count: Int) : SocketEvent + data class VideoConsentUpdate(val consent: VideoConsentDto) : SocketEvent + data class VideoCallInvite(val call: VideoCallDto) : SocketEvent + data class VideoCallIncoming(val call: VideoCallDto) : SocketEvent + data class VideoCallStart(val call: VideoCallDto) : SocketEvent + data class VideoCallUpdate(val call: VideoCallDto) : SocketEvent + data class VideoCallReject(val call: VideoCallDto) : SocketEvent + data class VideoCallCancel(val call: VideoCallDto) : SocketEvent + data class VideoCallEnd(val call: VideoCallDto) : SocketEvent + data class VideoCallMuteState(val call: VideoCallDto) : SocketEvent + data class VideoCallCapacity(val capacity: VideoCapacityDto) : SocketEvent + data class VideoCallSignal(val signal: VideoSignalDto) : SocketEvent + data class VideoCallError(val code: String?, val message: String, val withUserName: String? = null, val callId: String? = null) : SocketEvent data class UserBlocked(val userName: String) : SocketEvent data class UserUnblocked(val userName: String) : SocketEvent data class CommandResult(val lines: List, val kind: String) : SocketEvent diff --git a/android/app/src/main/java/de/ypchat/android/data/repository/ChatRepository.kt b/android/app/src/main/java/de/ypchat/android/data/repository/ChatRepository.kt index d2a94fb..289b852 100644 --- a/android/app/src/main/java/de/ypchat/android/data/repository/ChatRepository.kt +++ b/android/app/src/main/java/de/ypchat/android/data/repository/ChatRepository.kt @@ -25,18 +25,27 @@ import de.ypchat.android.data.model.InboxItemDto import de.ypchat.android.data.model.PartnerLinkDto import de.ypchat.android.data.model.SocketEvent import de.ypchat.android.data.model.UserDto +import de.ypchat.android.data.model.VideoCallDto +import de.ypchat.android.data.model.VideoCapacityDto +import de.ypchat.android.data.model.VideoConsentDto +import de.ypchat.android.media.AndroidVideoCallManager +import de.ypchat.android.media.VideoMediaState import okhttp3.MultipartBody +import org.webrtc.EglBase import java.util.Locale class ChatRepository( private val restApi: RestApi, private val socketClient: SocketClient, private val cookieJar: SessionCookieJar, - private val profileStore: ProfileStore + private val profileStore: ProfileStore, + private val videoCallManager: AndroidVideoCallManager ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val _state = MutableStateFlow(ChatState()) val state: StateFlow = _state.asStateFlow() + val videoMediaState: StateFlow = videoCallManager.state + val videoEglBaseContext: EglBase.Context = videoCallManager.eglBaseContext() private var timeoutTickerStarted = false init { @@ -105,6 +114,7 @@ class ChatRepository( runCatching { restApi.logout() } socketClient.disconnect() cookieJar.clear() + videoCallManager.releaseAll() _state.value = ChatState(savedProfile = profileStore.read(), countries = _state.value.countries) } @@ -114,13 +124,21 @@ class ChatRepository( } fun openConversation(userName: String) { - _state.value = _state.value.copy(currentConversation = userName, messages = emptyList()) + _state.value = _state.value.copy( + currentConversation = userName, + messages = emptyList(), + videoConsent = VideoConsentDto(withUserName = userName) + ) socketClient.requestConversation(userName) resetTimeout() } fun closeConversation() { - _state.value = _state.value.copy(currentConversation = null, messages = emptyList()) + _state.value = _state.value.copy( + currentConversation = null, + messages = emptyList(), + videoConsent = VideoConsentDto() + ) } fun sendMessage(text: String) { @@ -287,6 +305,75 @@ class ChatRepository( resetTimeout() } + fun setVideoConsent(allowed: Boolean) { + val target = _state.value.currentConversation ?: return + socketClient.setVideoConsent(target, allowed) + resetTimeout() + } + + fun inviteVideoCall() { + val target = _state.value.currentConversation ?: return + socketClient.inviteVideoCall(target) + resetTimeout() + } + + fun acceptVideoCall(callId: String) { + socketClient.acceptVideoCall(callId) + resetTimeout() + } + + fun rejectVideoCall(callId: String) { + socketClient.rejectVideoCall(callId) + resetTimeout() + } + + fun cancelVideoCall(callId: String) { + socketClient.cancelVideoCall(callId) + resetTimeout() + } + + fun endVideoCall(callId: String) { + socketClient.endVideoCall(callId) + videoCallManager.endCall(callId) + resetTimeout() + } + + fun bringVideoToFront(callId: String) { + _state.value = _state.value.copy(foregroundVideoSessionId = callId) + } + + fun minimizeForegroundVideo() { + _state.value = _state.value.copy(foregroundVideoSessionId = null) + } + + fun updateFloatingVideoPosition(x: Float, y: Float) { + _state.value = _state.value.copy( + floatingVideoOffsetX = x.coerceAtLeast(0f), + floatingVideoOffsetY = y.coerceAtLeast(0f) + ) + } + + fun toggleSelfMuted() { + val nextMuted = !_state.value.selfMuted + _state.value = _state.value.copy(selfMuted = nextMuted) + videoCallManager.updateSelfMuted(nextMuted) + _state.value.videoDockSessions.forEach { session -> + socketClient.setVideoMuteState(session.callId, nextMuted) + } + resetTimeout() + } + + fun toggleSelfCameraEnabled() { + val nextEnabled = !_state.value.selfCameraEnabled + _state.value = _state.value.copy(selfCameraEnabled = nextEnabled) + videoCallManager.updateSelfCameraEnabled(nextEnabled) + resetTimeout() + } + + fun setRuntimeError(message: String) { + _state.value = _state.value.copy(errorMessage = message) + } + private fun startTimeoutTicker() { if (timeoutTickerStarted) return timeoutTickerStarted = true @@ -344,6 +431,7 @@ class ChatRepository( is SocketEvent.Conversation -> current.copy( currentConversation = event.withUserName, messages = event.messages, + videoConsent = current.videoConsent.takeIf { it.withUserName == event.withUserName } ?: VideoConsentDto(withUserName = event.withUserName), unreadChatsCount = maxOf(0, current.unreadChatsCount - 1), remainingSecondsToTimeout = 1800 ) @@ -351,6 +439,56 @@ class ChatRepository( is SocketEvent.HistoryResults -> current.copy(historyResults = event.results) is SocketEvent.InboxResults -> current.copy(inboxResults = event.results) is SocketEvent.UnreadChats -> current.copy(unreadChatsCount = event.count) + is SocketEvent.VideoConsentUpdate -> current.copy( + videoConsent = event.consent.takeIf { it.withUserName == current.currentConversation } ?: current.videoConsent + ) + is SocketEvent.VideoCallInvite -> current.withVideoCall(event.call) + is SocketEvent.VideoCallIncoming -> current.withVideoCall(event.call) + is SocketEvent.VideoCallStart -> current.withVideoCall(event.call).also { + scope.launch { + runCatching { + videoCallManager.ensureCall(event.call, it.selfMuted, it.selfCameraEnabled) + }.onFailure { error -> + _state.value = _state.value.copy(errorMessage = error.message) + } + } + } + is SocketEvent.VideoCallUpdate -> current.withVideoCall(event.call).also { + if ((event.call.status == "connecting" || event.call.status == "active") && event.call.media != null) { + scope.launch { + runCatching { + videoCallManager.ensureCall(event.call, it.selfMuted, it.selfCameraEnabled) + }.onFailure { error -> + _state.value = _state.value.copy(errorMessage = error.message) + } + } + } + } + is SocketEvent.VideoCallReject -> current.withVideoCall(event.call).also { + videoCallManager.endCall(event.call.callId) + } + is SocketEvent.VideoCallCancel -> current.withVideoCall(event.call).also { + videoCallManager.endCall(event.call.callId) + } + is SocketEvent.VideoCallEnd -> current.withVideoCall(event.call).also { + videoCallManager.endCall(event.call.callId) + } + is SocketEvent.VideoCallMuteState -> current.withVideoCall(event.call) + is SocketEvent.VideoCallCapacity -> current.copy( + activeVideoConnectionCount = event.capacity.activeConnections, + maxVideoConnections = event.capacity.maxConnections, + maxVideoConnectionsReached = event.capacity.reachedMax + ) + is SocketEvent.VideoCallSignal -> current.also { + scope.launch { + runCatching { + videoCallManager.handleSignal(event.signal) + }.onFailure { error -> + _state.value = _state.value.copy(errorMessage = error.message) + } + } + } + is SocketEvent.VideoCallError -> current.copy(errorMessage = event.message) is SocketEvent.UserBlocked -> current.copy(errorMessage = "${event.userName} blocked") is SocketEvent.UserUnblocked -> current.copy(errorMessage = "${event.userName} unblocked") is SocketEvent.CommandResult -> current.copy( @@ -371,6 +509,40 @@ class ChatRepository( } } +private fun ChatState.withVideoCall(call: VideoCallDto): ChatState { + val normalized = VideoSessionState.fromDto(call) + val updated = videoDockSessions.toMutableList() + val index = updated.indexOfFirst { it.callId == normalized.callId } + if (index >= 0) { + updated[index] = updated[index].copy( + roomId = normalized.roomId, + withUserName = normalized.withUserName, + initiatedBy = normalized.initiatedBy, + status = normalized.status, + createdAt = normalized.createdAt, + updatedAt = normalized.updatedAt, + endedAt = normalized.endedAt, + reason = normalized.reason, + localMuted = normalized.localMuted, + remoteMuted = normalized.remoteMuted, + connectionState = normalized.connectionState, + remoteConnectionState = normalized.remoteConnectionState, + media = normalized.media + ) + } else { + updated += normalized + } + val visibleSessions = updated.filterNot { it.status in setOf("rejected", "cancelled", "ended", "failed") } + return copy( + videoDockSessions = updated.sortedByDescending { it.updatedAt }.take(3), + foregroundVideoSessionId = when { + foregroundVideoSessionId == null && normalized.status !in setOf("rejected", "cancelled", "ended", "failed") -> normalized.callId + foregroundVideoSessionId == normalized.callId && normalized.status in setOf("rejected", "cancelled", "ended", "failed") -> visibleSessions.firstOrNull()?.callId + else -> foregroundVideoSessionId + } + ) +} + data class CommandTableState( val title: String, val columns: List, @@ -406,5 +578,51 @@ data class ChatState( val isUploadingImage: Boolean = false, val imageUploadMessage: String? = null, val unreadChatsCount: Int = 0, - val errorMessage: String? = null + val errorMessage: String? = null, + val videoConsent: VideoConsentDto = VideoConsentDto(), + val videoDockSessions: List = emptyList(), + val foregroundVideoSessionId: String? = null, + val floatingVideoOffsetX: Float = 24f, + val floatingVideoOffsetY: Float = 24f, + val activeVideoConnectionCount: Int = 0, + val maxVideoConnections: Int = 3, + val maxVideoConnectionsReached: Boolean = false, + val selfMuted: Boolean = false, + val selfCameraEnabled: Boolean = true ) + +data class VideoSessionState( + val callId: String, + val roomId: String? = null, + val withUserName: String? = null, + val initiatedBy: String? = null, + val status: String = "", + val createdAt: String = "", + val updatedAt: String = "", + val endedAt: String? = null, + val reason: String? = null, + val localMuted: Boolean = false, + val remoteMuted: Boolean = false, + val connectionState: String = "new", + val remoteConnectionState: String = "new", + val media: de.ypchat.android.data.model.VideoMediaDto? = null +) { + companion object { + fun fromDto(dto: VideoCallDto) = VideoSessionState( + callId = dto.callId, + roomId = dto.roomId, + withUserName = dto.withUserName, + initiatedBy = dto.initiatedBy, + status = dto.status, + createdAt = dto.createdAt, + updatedAt = dto.updatedAt, + endedAt = dto.endedAt, + reason = dto.reason, + localMuted = dto.localMuted, + remoteMuted = dto.remoteMuted, + connectionState = dto.connectionState, + remoteConnectionState = dto.remoteConnectionState, + media = dto.media + ) + } +} diff --git a/android/app/src/main/java/de/ypchat/android/media/AndroidVideoCallManager.kt b/android/app/src/main/java/de/ypchat/android/media/AndroidVideoCallManager.kt new file mode 100644 index 0000000..aeaee1e --- /dev/null +++ b/android/app/src/main/java/de/ypchat/android/media/AndroidVideoCallManager.kt @@ -0,0 +1,391 @@ +package de.ypchat.android.media + +import android.content.Context +import de.ypchat.android.data.api.SocketClient +import de.ypchat.android.data.model.VideoCallDto +import de.ypchat.android.data.model.VideoIceCandidateDto +import de.ypchat.android.data.model.VideoIceServerDto +import de.ypchat.android.data.model.VideoSessionDescriptionDto +import de.ypchat.android.data.model.VideoSignalDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.webrtc.AudioSource +import org.webrtc.AudioTrack +import org.webrtc.Camera1Enumerator +import org.webrtc.CameraEnumerator +import org.webrtc.Camera2Enumerator +import org.webrtc.CameraVideoCapturer +import org.webrtc.CandidatePairChangeEvent +import org.webrtc.DataChannel +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.EglBase +import org.webrtc.IceCandidate +import org.webrtc.Logging +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpReceiver +import org.webrtc.RtpTransceiver +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription +import org.webrtc.SurfaceTextureHelper +import org.webrtc.VideoCapturer +import org.webrtc.VideoSource +import org.webrtc.VideoTrack +import java.util.concurrent.ConcurrentHashMap +import java.util.regex.Pattern +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +data class VideoMediaState( + val localVideoTrack: VideoTrack? = null, + val remoteVideoTracks: Map = emptyMap(), + val lastError: String? = null +) + +class AndroidVideoCallManager( + context: Context, + private val socketClient: SocketClient +) { + private val appContext = context.applicationContext + private val eglBase: EglBase = EglBase.create() + private val peerConnectionFactory: PeerConnectionFactory + private val peerConnections = ConcurrentHashMap() + private val pendingIceCandidates = ConcurrentHashMap>() + + private var audioSource: AudioSource? = null + private var audioTrack: AudioTrack? = null + private var videoSource: VideoSource? = null + private var videoTrack: VideoTrack? = null + private var videoCapturer: CameraVideoCapturer? = null + private var videoCapturerInitialized = false + private var surfaceTextureHelper: SurfaceTextureHelper? = null + + private val _state = MutableStateFlow(VideoMediaState()) + val state: StateFlow = _state.asStateFlow() + + init { + PeerConnectionFactory.initialize( + PeerConnectionFactory.InitializationOptions.builder(appContext) + .setEnableInternalTracer(false) + .createInitializationOptions() + ) + val options = PeerConnectionFactory.Options() + peerConnectionFactory = PeerConnectionFactory.builder() + .setOptions(options) + .setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true)) + .setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext)) + .createPeerConnectionFactory() + } + + fun eglBaseContext() = eglBase.eglBaseContext + + suspend fun ensureCall(call: VideoCallDto, selfMuted: Boolean, selfCameraEnabled: Boolean) { + val media = call.media ?: return + ensureLocalMedia(selfMuted, selfCameraEnabled) + val existing = peerConnections[call.callId] + if (existing != null) { + if (media.isCaller && existing.localDescription == null) { + createOffer(call.callId, existing) + } + return + } + + val rtcConfig = PeerConnection.RTCConfiguration(media.iceServers.toNativeIceServers()).apply { + iceTransportsType = PeerConnection.IceTransportsType.RELAY + sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN + bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE + rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE + continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY + } + + val peerConnection = peerConnectionFactory.createPeerConnection( + rtcConfig, + createPeerConnectionObserver(call.callId) + ) ?: throw IllegalStateException("PeerConnection konnte nicht erstellt werden.") + + audioTrack?.let { peerConnection.addTrack(it) } + videoTrack?.let { peerConnection.addTrack(it) } + peerConnections[call.callId] = peerConnection + + if (media.isCaller) { + createOffer(call.callId, peerConnection) + } + } + + suspend fun handleSignal(signal: VideoSignalDto) { + val peerConnection = peerConnections[signal.callId] ?: return + when (signal.signalType) { + "description" -> { + val description = signal.description ?: return + peerConnection.setRemoteDescriptionAwait( + SessionDescription(SessionDescription.Type.fromCanonicalForm(description.type), description.sdp) + ) + flushPendingCandidates(signal.callId, peerConnection) + if (description.type == "offer") { + createAnswer(signal.callId, peerConnection) + } + } + "candidate" -> { + val candidate = signal.candidate ?: return + val nativeCandidate = candidate.toNativeIceCandidate() + if (peerConnection.remoteDescription == null) { + pendingIceCandidates.getOrPut(signal.callId) { mutableListOf() }.add(nativeCandidate) + } else { + peerConnection.addIceCandidate(nativeCandidate) + } + } + } + } + + fun updateSelfMuted(muted: Boolean) { + audioTrack?.setEnabled(!muted) + } + + fun updateSelfCameraEnabled(enabled: Boolean) { + videoTrack?.setEnabled(enabled) + } + + fun endCall(callId: String) { + peerConnections.remove(callId)?.let { connection -> + connection.close() + } + pendingIceCandidates.remove(callId) + val nextTracks = _state.value.remoteVideoTracks.toMutableMap().apply { remove(callId) } + _state.value = _state.value.copy(remoteVideoTracks = nextTracks) + } + + fun releaseAll() { + peerConnections.keys.toList().forEach(::endCall) + runCatching { videoCapturer?.stopCapture() } + runCatching { videoCapturer?.dispose() } + runCatching { surfaceTextureHelper?.dispose() } + runCatching { videoSource?.dispose() } + runCatching { audioSource?.dispose() } + runCatching { videoTrack?.dispose() } + runCatching { audioTrack?.dispose() } + surfaceTextureHelper = null + videoCapturer = null + videoSource = null + audioSource = null + videoTrack = null + audioTrack = null + videoCapturerInitialized = false + _state.value = VideoMediaState() + } + + private suspend fun ensureLocalMedia(selfMuted: Boolean, selfCameraEnabled: Boolean) { + if (audioTrack == null) { + audioSource = peerConnectionFactory.createAudioSource(MediaConstraints()) + audioTrack = peerConnectionFactory.createAudioTrack("YPCHAT_AUDIO", audioSource).apply { + setEnabled(!selfMuted) + } + } + if (videoTrack == null) { + val capturer = createVideoCapturer() + ?: throw IllegalStateException("Keine geeignete Kamera für Videochat gefunden.") + videoCapturer = capturer + surfaceTextureHelper = SurfaceTextureHelper.create("YPChatVideoCapture", eglBase.eglBaseContext) + videoSource = peerConnectionFactory.createVideoSource(capturer.isScreencast) + capturer.initialize(surfaceTextureHelper, appContext, videoSource?.capturerObserver) + capturer.startCapture(960, 720, 24) + videoCapturerInitialized = true + videoTrack = peerConnectionFactory.createVideoTrack("YPCHAT_VIDEO", videoSource).apply { + setEnabled(selfCameraEnabled) + } + _state.value = _state.value.copy(localVideoTrack = videoTrack) + } else if (videoCapturerInitialized) { + videoTrack?.setEnabled(selfCameraEnabled) + } + } + + private fun createPeerConnectionObserver(callId: String) = object : PeerConnection.Observer { + override fun onSignalingChange(newState: PeerConnection.SignalingState?) = Unit + override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) { + when (newState) { + PeerConnection.IceConnectionState.CONNECTED, + PeerConnection.IceConnectionState.COMPLETED -> socketClient.setVideoConnectionState(callId, "connected") + PeerConnection.IceConnectionState.DISCONNECTED -> socketClient.setVideoConnectionState(callId, "disconnected") + PeerConnection.IceConnectionState.FAILED -> socketClient.setVideoConnectionState(callId, "failed") + PeerConnection.IceConnectionState.CLOSED -> socketClient.setVideoConnectionState(callId, "closed") + PeerConnection.IceConnectionState.CHECKING -> socketClient.setVideoConnectionState(callId, "connecting") + else -> Unit + } + } + override fun onStandardizedIceConnectionChange(newState: PeerConnection.IceConnectionState?) = Unit + override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) { + when (newState) { + PeerConnection.PeerConnectionState.CONNECTING -> socketClient.setVideoConnectionState(callId, "connecting") + PeerConnection.PeerConnectionState.CONNECTED -> socketClient.setVideoConnectionState(callId, "connected") + PeerConnection.PeerConnectionState.DISCONNECTED -> socketClient.setVideoConnectionState(callId, "disconnected") + PeerConnection.PeerConnectionState.FAILED -> socketClient.setVideoConnectionState(callId, "failed") + PeerConnection.PeerConnectionState.CLOSED -> socketClient.setVideoConnectionState(callId, "closed") + else -> Unit + } + } + override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit + override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) = Unit + override fun onIceCandidate(candidate: IceCandidate?) { + candidate ?: return + socketClient.sendVideoSignal( + VideoSignalDto( + callId = callId, + signalType = "candidate", + candidate = candidate.toDto() + ) + ) + } + override fun onIceCandidatesRemoved(candidates: Array?) = Unit + override fun onAddStream(stream: MediaStream?) { + val remoteTrack = stream?.videoTracks?.firstOrNull() ?: return + val nextTracks = _state.value.remoteVideoTracks.toMutableMap().apply { put(callId, remoteTrack) } + _state.value = _state.value.copy(remoteVideoTracks = nextTracks) + } + override fun onRemoveStream(stream: MediaStream?) { + val nextTracks = _state.value.remoteVideoTracks.toMutableMap().apply { remove(callId) } + _state.value = _state.value.copy(remoteVideoTracks = nextTracks) + } + override fun onDataChannel(dataChannel: DataChannel?) = Unit + override fun onRenegotiationNeeded() = Unit + override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array?) { + val remoteTrack = receiver?.track() as? VideoTrack ?: return + val nextTracks = _state.value.remoteVideoTracks.toMutableMap().apply { put(callId, remoteTrack) } + _state.value = _state.value.copy(remoteVideoTracks = nextTracks) + } + override fun onTrack(transceiver: RtpTransceiver?) { + val remoteTrack = transceiver?.receiver?.track() as? VideoTrack ?: return + val nextTracks = _state.value.remoteVideoTracks.toMutableMap().apply { put(callId, remoteTrack) } + _state.value = _state.value.copy(remoteVideoTracks = nextTracks) + } + override fun onSelectedCandidatePairChanged(event: CandidatePairChangeEvent?) = Unit + } + + private suspend fun createOffer(callId: String, peerConnection: PeerConnection) { + val offer = peerConnection.createOfferAwait( + MediaConstraints().apply { + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")) + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true")) + } + ) + peerConnection.setLocalDescriptionAwait(offer) + socketClient.sendVideoSignal( + VideoSignalDto( + callId = callId, + signalType = "description", + description = VideoSessionDescriptionDto( + type = offer.type.canonicalForm(), + sdp = offer.description + ) + ) + ) + } + + private suspend fun createAnswer(callId: String, peerConnection: PeerConnection) { + val answer = peerConnection.createAnswerAwait(MediaConstraints()) + peerConnection.setLocalDescriptionAwait(answer) + socketClient.sendVideoSignal( + VideoSignalDto( + callId = callId, + signalType = "description", + description = VideoSessionDescriptionDto( + type = answer.type.canonicalForm(), + sdp = answer.description + ) + ) + ) + } + + private suspend fun flushPendingCandidates(callId: String, peerConnection: PeerConnection) { + val candidates = pendingIceCandidates.remove(callId).orEmpty() + candidates.forEach { peerConnection.addIceCandidate(it) } + } + + private fun createVideoCapturer(): CameraVideoCapturer? { + if (Camera2Enumerator.isSupported(appContext)) { + createCameraCapturer(Camera2Enumerator(appContext))?.let { return it } + } + return createCameraCapturer(Camera1Enumerator(false)) + } + + private fun createCameraCapturer(enumerator: CameraEnumerator): CameraVideoCapturer? { + enumerator.deviceNames.firstOrNull(enumerator::isFrontFacing)?.let { deviceName -> + enumerator.createCapturer(deviceName, null)?.let { return it } + } + enumerator.deviceNames.firstOrNull()?.let { deviceName -> + enumerator.createCapturer(deviceName, null)?.let { return it } + } + return null + } +} + +private suspend fun PeerConnection.createOfferAwait(constraints: MediaConstraints): SessionDescription = suspendCoroutine { continuation -> + createOffer(object : SdpObserver { + override fun onCreateSuccess(desc: SessionDescription?) { + if (desc != null) continuation.resume(desc) else continuation.resumeWithException(IllegalStateException("Offer leer")) + } + override fun onSetSuccess() = Unit + override fun onCreateFailure(error: String?) = continuation.resumeWithException(IllegalStateException(error ?: "Offer fehlgeschlagen")) + override fun onSetFailure(error: String?) = Unit + }, constraints) +} + +private suspend fun PeerConnection.createAnswerAwait(constraints: MediaConstraints): SessionDescription = suspendCoroutine { continuation -> + createAnswer(object : SdpObserver { + override fun onCreateSuccess(desc: SessionDescription?) { + if (desc != null) continuation.resume(desc) else continuation.resumeWithException(IllegalStateException("Answer leer")) + } + override fun onSetSuccess() = Unit + override fun onCreateFailure(error: String?) = continuation.resumeWithException(IllegalStateException(error ?: "Answer fehlgeschlagen")) + override fun onSetFailure(error: String?) = Unit + }, constraints) +} + +private suspend fun PeerConnection.setLocalDescriptionAwait(description: SessionDescription): Unit = suspendCoroutine { continuation -> + setLocalDescription(object : SdpObserver { + override fun onCreateSuccess(desc: SessionDescription?) = Unit + override fun onSetSuccess() = continuation.resume(Unit) + override fun onCreateFailure(error: String?) = Unit + override fun onSetFailure(error: String?) = continuation.resumeWithException(IllegalStateException(error ?: "setLocalDescription fehlgeschlagen")) + }, description) +} + +private suspend fun PeerConnection.setRemoteDescriptionAwait(description: SessionDescription): Unit = suspendCoroutine { continuation -> + setRemoteDescription(object : SdpObserver { + override fun onCreateSuccess(desc: SessionDescription?) = Unit + override fun onSetSuccess() = continuation.resume(Unit) + override fun onCreateFailure(error: String?) = Unit + override fun onSetFailure(error: String?) = continuation.resumeWithException(IllegalStateException(error ?: "setRemoteDescription fehlgeschlagen")) + }, description) +} + +private fun List.toNativeIceServers(): List = mapNotNull { dto -> + if (dto.urls.isEmpty()) return@mapNotNull null + PeerConnection.IceServer.builder(dto.urls) + .apply { + dto.username?.let(::setUsername) + dto.credential?.let(::setPassword) + } + .createIceServer() +} + +private val relayPattern = Pattern.compile("\\btyp\\s+(\\w+)\\b", Pattern.CASE_INSENSITIVE) + +private fun IceCandidate.toDto(): VideoIceCandidateDto { + val type = relayPattern.matcher(sdp).let { matcher -> + if (matcher.find()) matcher.group(1) else null + } + return VideoIceCandidateDto( + candidate = sdp, + sdpMid = sdpMid, + sdpMLineIndex = sdpMLineIndex, + usernameFragment = serverUrl, + type = type + ) +} + +private fun VideoIceCandidateDto.toNativeIceCandidate(): IceCandidate = + IceCandidate(sdpMid, sdpMLineIndex, candidate) diff --git a/android/app/src/main/java/de/ypchat/android/ui/ChatViewModel.kt b/android/app/src/main/java/de/ypchat/android/ui/ChatViewModel.kt index fc2ea63..1c75af0 100644 --- a/android/app/src/main/java/de/ypchat/android/ui/ChatViewModel.kt +++ b/android/app/src/main/java/de/ypchat/android/ui/ChatViewModel.kt @@ -10,12 +10,16 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import de.ypchat.android.data.repository.ChatRepository import de.ypchat.android.data.repository.ChatState +import de.ypchat.android.media.VideoMediaState import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody +import org.webrtc.EglBase class ChatViewModel(private val repository: ChatRepository) : ViewModel() { val state: StateFlow = repository.state + val videoMediaState: StateFlow = repository.videoMediaState + val videoEglBaseContext: EglBase.Context = repository.videoEglBaseContext init { viewModelScope.launch { repository.restoreSession() } @@ -111,6 +115,18 @@ class ChatViewModel(private val repository: ChatRepository) : ViewModel() { fun blockCurrentUser() = state.value.currentConversation?.let(repository::blockUser) fun unblockCurrentUser() = state.value.currentConversation?.let(repository::unblockUser) + fun setVideoConsent(allowed: Boolean) = repository.setVideoConsent(allowed) + fun inviteVideoCall() = repository.inviteVideoCall() + fun acceptVideoCall(callId: String) = repository.acceptVideoCall(callId) + fun rejectVideoCall(callId: String) = repository.rejectVideoCall(callId) + fun cancelVideoCall(callId: String) = repository.cancelVideoCall(callId) + fun endVideoCall(callId: String) = repository.endVideoCall(callId) + fun bringVideoToFront(callId: String) = repository.bringVideoToFront(callId) + fun minimizeForegroundVideo() = repository.minimizeForegroundVideo() + fun updateFloatingVideoPosition(x: Float, y: Float) = repository.updateFloatingVideoPosition(x, y) + fun toggleSelfMuted() = repository.toggleSelfMuted() + fun toggleSelfCameraEnabled() = repository.toggleSelfCameraEnabled() + fun setRuntimeError(message: String) = repository.setRuntimeError(message) private companion object { const val MAX_IMAGE_BYTES = 5 * 1024 * 1024 diff --git a/android/app/src/main/java/de/ypchat/android/ui/YpChatRoot.kt b/android/app/src/main/java/de/ypchat/android/ui/YpChatRoot.kt index b176f73..b025f17 100644 --- a/android/app/src/main/java/de/ypchat/android/ui/YpChatRoot.kt +++ b/android/app/src/main/java/de/ypchat/android/ui/YpChatRoot.kt @@ -10,16 +10,21 @@ import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow @@ -54,6 +59,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -64,10 +70,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import coil3.compose.AsyncImage import de.ypchat.android.R import de.ypchat.android.data.model.ChatMessageDto @@ -77,12 +86,18 @@ import de.ypchat.android.data.model.PartnerLinkDto import de.ypchat.android.data.model.UserDto import de.ypchat.android.data.repository.ChatState import de.ypchat.android.data.repository.CommandTableState +import de.ypchat.android.data.repository.VideoSessionState import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.io.File import java.io.FileOutputStream +import kotlin.math.roundToInt +import org.webrtc.EglBase +import org.webrtc.RendererCommon +import org.webrtc.SurfaceViewRenderer +import org.webrtc.VideoTrack enum class AppTab(val labelRes: Int) { Online(R.string.tab_online), @@ -773,6 +788,20 @@ private fun ChatScreen(state: ChatState, viewModel: ChatViewModel) { var draft by remember { mutableStateOf("") } var showSmileys by remember { mutableStateOf(false) } val context = LocalContext.current + val videoMediaState by viewModel.videoMediaState.collectAsState() + val liveVideoSessions = remember(state.videoDockSessions) { + state.videoDockSessions.filter { it.status == "ringing" || it.status == "connecting" || it.status == "active" } + } + val currentVideoSession = remember(state.currentConversation, liveVideoSessions) { + liveVideoSessions.firstOrNull { it.withUserName == state.currentConversation } + } + val foregroundSession = remember(state.foregroundVideoSessionId, liveVideoSessions) { + liveVideoSessions.firstOrNull { it.callId == state.foregroundVideoSessionId } + } + val canStartVideo = state.currentConversation != null && + state.videoConsent.videoVisible && + currentVideoSession == null && + !state.maxVideoConnectionsReached val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> if (uri != null) viewModel.sendImage(context, uri) } @@ -795,95 +824,623 @@ private fun ChatScreen(state: ChatState, viewModel: ChatViewModel) { viewModel.setImageUploadMessage("Camera permission denied") } } + var pendingVideoAction by remember { mutableStateOf<(() -> Unit)?>(null) } + val videoPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> + val allGranted = grants[Manifest.permission.CAMERA] == true && grants[Manifest.permission.RECORD_AUDIO] == true + val action = pendingVideoAction + pendingVideoAction = null + if (allGranted) { + action?.invoke() + } else { + viewModel.setRuntimeError("Camera and microphone permission are required for video chat") + } + } - Column(modifier = Modifier.fillMaxSize()) { - Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - TextButton(onClick = viewModel::closeConversation) { Text(stringResource(R.string.back)) } - Text(state.currentConversation.orEmpty(), fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) - TextButton(onClick = viewModel::blockCurrentUser) { Text(stringResource(R.string.block)) } - TextButton(onClick = viewModel::unblockCurrentUser) { Text(stringResource(R.string.unblock)) } + fun runVideoAction(action: () -> Unit) { + val hasCamera = context.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + val hasAudio = context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + if (hasCamera && hasAudio) { + action() + } else { + pendingVideoAction = action + videoPermissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)) } - HorizontalDivider() - LazyColumn(modifier = Modifier.weight(1f).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(state.messages) { message -> MessageBubble(message, state.currentUser?.userName.orEmpty()) } - } - if (state.isUploadingImage || !state.imageUploadMessage.isNullOrBlank()) { - UploadStatusBanner(state) - } - Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - draft, - { draft = it }, - modifier = Modifier.weight(1f), - placeholder = { Text(stringResource(R.string.message_placeholder)) }, - colors = appTextFieldColors(), - enabled = !state.isUploadingImage - ) - Spacer(Modifier.width(8.dp)) - IconImageButton( - iconRes = R.drawable.smileys_button, - contentDescription = stringResource(R.string.button_smileys), - onClick = { showSmileys = !showSmileys }, - enabled = !state.isUploadingImage - ) - Spacer(Modifier.width(8.dp)) - IconImageButton( - iconRes = R.drawable.image_button, - contentDescription = stringResource(R.string.button_image), - onClick = { imagePicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, - enabled = !state.isUploadingImage - ) - Spacer(Modifier.width(8.dp)) - TextButton( - onClick = { - if (context.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { - cameraLauncher.launch(null) - } else { - cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val showRightDock = liveVideoSessions.isNotEmpty() && maxWidth >= 900.dp + + Box(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = viewModel::closeConversation) { Text(stringResource(R.string.back)) } + Text(state.currentConversation.orEmpty(), fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + TextButton(onClick = viewModel::blockCurrentUser) { Text(stringResource(R.string.block)) } + TextButton(onClick = viewModel::unblockCurrentUser) { Text(stringResource(R.string.unblock)) } } - }, - enabled = !state.isUploadingImage, - colors = ButtonDefaults.textButtonColors( - containerColor = Primary100, - contentColor = Primary700, - disabledContainerColor = SurfaceSubtle, - disabledContentColor = TextMuted - ) - ) { - Text(stringResource(R.string.button_camera), fontWeight = FontWeight.Bold) - } - Spacer(Modifier.width(8.dp)) - Button( - onClick = { viewModel.sendMessage(draft); draft = "" }, - colors = ButtonDefaults.buttonColors(containerColor = Primary600), - enabled = draft.isNotBlank() && !state.isUploadingImage - ) { - Text(stringResource(R.string.button_send)) - } - } - if (showSmileys) { - LazyRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(SmileyItems) { smiley -> - Text( - text = smileyEmoji(smiley.hexCode), - modifier = Modifier - .background(Primary100, RoundedCornerShape(999.dp)) - .clickable { - draft += smiley.token - showSmileys = false + HorizontalDivider() + if (state.currentConversation != null) { + VideoConversationHeader( + state = state, + currentVideoSession = currentVideoSession, + canStartVideo = canStartVideo, + onToggleConsent = { viewModel.setVideoConsent(!state.videoConsent.localConsent) }, + onInvite = { runVideoAction(viewModel::inviteVideoCall) } + ) + } + if (currentVideoSession != null) { + VideoSessionBanner( + session = currentVideoSession, + currentUserName = state.currentUser?.userName.orEmpty(), + onAccept = { callId -> runVideoAction { viewModel.acceptVideoCall(callId) } }, + onReject = viewModel::rejectVideoCall, + onCancel = viewModel::cancelVideoCall, + onBringToFront = viewModel::bringVideoToFront + ) + } + LazyColumn(modifier = Modifier.weight(1f).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(state.messages) { message -> MessageBubble(message, state.currentUser?.userName.orEmpty()) } + } + if (!showRightDock && liveVideoSessions.isNotEmpty()) { + VideoDockRow( + state = state, + sessions = liveVideoSessions, + viewModel = viewModel, + videoMediaState = videoMediaState, + eglBaseContext = viewModel.videoEglBaseContext, + onAccept = { callId -> runVideoAction { viewModel.acceptVideoCall(callId) } } + ) + } + if (state.isUploadingImage || !state.imageUploadMessage.isNullOrBlank()) { + UploadStatusBanner(state) + } + Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + draft, + { draft = it }, + modifier = Modifier.weight(1f), + placeholder = { Text(stringResource(R.string.message_placeholder)) }, + colors = appTextFieldColors(), + enabled = !state.isUploadingImage + ) + Spacer(Modifier.width(8.dp)) + IconImageButton( + iconRes = R.drawable.smileys_button, + contentDescription = stringResource(R.string.button_smileys), + onClick = { showSmileys = !showSmileys }, + enabled = !state.isUploadingImage + ) + Spacer(Modifier.width(8.dp)) + IconImageButton( + iconRes = R.drawable.image_button, + contentDescription = stringResource(R.string.button_image), + onClick = { imagePicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, + enabled = !state.isUploadingImage + ) + Spacer(Modifier.width(8.dp)) + TextButton( + onClick = { + if (context.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + cameraLauncher.launch(null) + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + }, + enabled = !state.isUploadingImage, + colors = ButtonDefaults.textButtonColors( + containerColor = Primary100, + contentColor = Primary700, + disabledContainerColor = SurfaceSubtle, + disabledContentColor = TextMuted + ) + ) { + Text(stringResource(R.string.button_camera), fontWeight = FontWeight.Bold) + } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { viewModel.sendMessage(draft); draft = "" }, + colors = ButtonDefaults.buttonColors(containerColor = Primary600), + enabled = draft.isNotBlank() && !state.isUploadingImage + ) { + Text(stringResource(R.string.button_send)) + } + } + if (showSmileys) { + LazyRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(SmileyItems) { smiley -> + Text( + text = smileyEmoji(smiley.hexCode), + modifier = Modifier + .background(Primary100, RoundedCornerShape(999.dp)) + .clickable { + draft += smiley.token + showSmileys = false + } + .padding(horizontal = 12.dp, vertical = 8.dp), + color = Primary700, + fontWeight = FontWeight.Bold + ) } - .padding(horizontal = 12.dp, vertical = 8.dp), - color = Primary700, - fontWeight = FontWeight.Bold + } + } + state.errorMessage?.let { Text(localizeRuntimeMessage(it), modifier = Modifier.padding(horizontal = 12.dp), color = Danger) } + Spacer(Modifier.height(8.dp)) + } + + if (showRightDock) { + VideoDockSidebar( + state = state, + sessions = liveVideoSessions, + viewModel = viewModel, + videoMediaState = videoMediaState, + eglBaseContext = viewModel.videoEglBaseContext, + onAccept = { callId -> runVideoAction { viewModel.acceptVideoCall(callId) } } ) } } + + if (foregroundSession != null) { + FloatingVideoWindow( + state = state, + session = foregroundSession, + viewModel = viewModel, + videoMediaState = videoMediaState, + eglBaseContext = viewModel.videoEglBaseContext, + modifier = Modifier.absoluteOffset { + IntOffset(state.floatingVideoOffsetX.roundToInt(), state.floatingVideoOffsetY.roundToInt()) + } + ) + } } - state.errorMessage?.let { Text(localizeRuntimeMessage(it), modifier = Modifier.padding(horizontal = 12.dp), color = Danger) } - Spacer(Modifier.height(8.dp)) } } +@Composable +private fun VideoConversationHeader( + state: ChatState, + currentVideoSession: VideoSessionState?, + canStartVideo: Boolean, + onToggleConsent: () -> Unit, + onInvite: () -> Unit +) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = onToggleConsent, + colors = ButtonDefaults.textButtonColors( + containerColor = if (state.videoConsent.localConsent) SurfaceSoftGreen else Primary100, + contentColor = Primary700 + ) + ) { + Text( + stringResource( + if (state.videoConsent.localConsent) R.string.button_video_allowed else R.string.button_video_allow + ), + fontWeight = FontWeight.Bold + ) + } + if (state.videoConsent.videoVisible) { + Button( + onClick = onInvite, + enabled = canStartVideo, + colors = ButtonDefaults.buttonColors(containerColor = Primary600) + ) { + Text(stringResource(R.string.button_video_open)) + } + } + } + Card( + colors = CardDefaults.cardColors(containerColor = SurfaceSubtle), + shape = RoundedCornerShape(12.dp) + ) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) { + Text( + text = if (state.videoConsent.remoteConsent) { + stringResource(R.string.video_status_partner_allowed) + } else { + stringResource(R.string.video_status_partner_pending) + }, + color = TextMuted + ) + if (state.maxVideoConnectionsReached) { + Text(stringResource(R.string.video_status_capacity_reached), color = Danger, fontWeight = FontWeight.Bold) + } else if (currentVideoSession != null) { + Text(videoStatusLabel(currentVideoSession.status), color = Primary700, fontWeight = FontWeight.Bold) + } + } + } + } +} + +@Composable +private fun VideoSessionBanner( + session: VideoSessionState, + currentUserName: String, + onAccept: (String) -> Unit, + onReject: (String) -> Unit, + onCancel: (String) -> Unit, + onBringToFront: (String) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + colors = CardDefaults.cardColors(containerColor = SurfaceSubtle), + shape = RoundedCornerShape(12.dp) + ) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(session.withUserName.orEmpty(), color = TextStrong, fontWeight = FontWeight.Bold) + Text( + "${videoStatusLabel(session.status)} · ${ + if (session.remoteMuted) stringResource(R.string.video_mic_off) else stringResource(R.string.video_mic_on) + }", + color = TextMuted + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (session.status == "ringing" && session.initiatedBy != currentUserName) { + Button(onClick = { onAccept(session.callId) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { + Text(stringResource(R.string.button_video_accept)) + } + TextButton(onClick = { onReject(session.callId) }, colors = ButtonDefaults.textButtonColors(containerColor = SurfaceSoftRed, contentColor = Danger)) { + Text(stringResource(R.string.button_video_reject), fontWeight = FontWeight.Bold) + } + } else if (session.status == "ringing" && session.initiatedBy == currentUserName) { + TextButton(onClick = { onCancel(session.callId) }, colors = ButtonDefaults.textButtonColors(containerColor = Primary100, contentColor = Primary700)) { + Text(stringResource(R.string.button_video_cancel), fontWeight = FontWeight.Bold) + } + } else if (session.status == "connecting" || session.status == "active") { + TextButton(onClick = { onBringToFront(session.callId) }, colors = ButtonDefaults.textButtonColors(containerColor = Primary100, contentColor = Primary700)) { + Text(stringResource(R.string.button_video_foreground), fontWeight = FontWeight.Bold) + } + } + } + } + } +} + +@Composable +private fun VideoDockSidebar( + state: ChatState, + sessions: List, + viewModel: ChatViewModel, + videoMediaState: de.ypchat.android.media.VideoMediaState, + eglBaseContext: EglBase.Context, + onAccept: (String) -> Unit +) { + Column( + modifier = Modifier + .width(220.dp) + .fillMaxHeight() + .background(Color(0xFFF0F4F1)) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + VideoSelfPreviewCard(state, videoMediaState.localVideoTrack, eglBaseContext) + sessions.take(3).forEach { session -> + VideoPartnerPreviewCard( + session = session, + currentUserName = state.currentUser?.userName.orEmpty(), + viewModel = viewModel, + remoteVideoTrack = videoMediaState.remoteVideoTracks[session.callId], + eglBaseContext = eglBaseContext, + onAccept = onAccept + ) + } + } +} + +@Composable +private fun VideoDockRow( + state: ChatState, + sessions: List, + viewModel: ChatViewModel, + videoMediaState: de.ypchat.android.media.VideoMediaState, + eglBaseContext: EglBase.Context, + onAccept: (String) -> Unit +) { + LazyRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + item { VideoSelfPreviewCard(state, videoMediaState.localVideoTrack, eglBaseContext, modifier = Modifier.width(170.dp)) } + items(sessions.take(3)) { session -> + VideoPartnerPreviewCard( + session = session, + currentUserName = state.currentUser?.userName.orEmpty(), + viewModel = viewModel, + remoteVideoTrack = videoMediaState.remoteVideoTracks[session.callId], + eglBaseContext = eglBaseContext, + onAccept = onAccept, + modifier = Modifier.width(190.dp) + ) + } + } +} + +@Composable +private fun VideoSelfPreviewCard( + state: ChatState, + localVideoTrack: VideoTrack?, + eglBaseContext: EglBase.Context, + modifier: Modifier = Modifier +) { + Card(modifier = modifier, shape = RoundedCornerShape(14.dp), colors = CardDefaults.cardColors(containerColor = SurfaceColor)) { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .background(Color(0xFF111916)), + contentAlignment = Alignment.Center + ) { + if (localVideoTrack != null && state.selfCameraEnabled) { + WebRtcVideoSurface( + videoTrack = localVideoTrack, + eglBaseContext = eglBaseContext, + mirror = true, + modifier = Modifier.fillMaxSize() + ) + } else { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(R.string.video_self_preview), color = Color.White, fontWeight = FontWeight.Bold) + Text( + if (state.selfCameraEnabled) stringResource(R.string.video_mic_on) else stringResource(R.string.video_self_preview_camera_off), + color = Color(0xFFD6E2DA) + ) + } + } + } + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Text(stringResource(R.string.video_self_preview), color = TextStrong, fontWeight = FontWeight.Bold) + Text( + if (state.selfMuted) stringResource(R.string.video_mic_off) else stringResource(R.string.video_mic_on), + color = TextMuted + ) + } + } + } +} + +@Composable +private fun VideoPartnerPreviewCard( + session: VideoSessionState, + currentUserName: String, + viewModel: ChatViewModel, + remoteVideoTrack: VideoTrack?, + eglBaseContext: EglBase.Context, + onAccept: (String) -> Unit, + modifier: Modifier = Modifier +) { + Card(modifier = modifier, shape = RoundedCornerShape(14.dp), colors = CardDefaults.cardColors(containerColor = SurfaceColor)) { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .background(Color(0xFF101712)), + contentAlignment = Alignment.Center + ) { + if (remoteVideoTrack != null) { + WebRtcVideoSurface( + videoTrack = remoteVideoTrack, + eglBaseContext = eglBaseContext, + mirror = false, + modifier = Modifier.fillMaxSize() + ) + } else { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(session.withUserName.orEmpty(), color = Color.White, fontWeight = FontWeight.Bold) + Text(videoStatusLabel(session.status), color = Color(0xFFD6E2DA)) + } + } + } + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(session.withUserName.orEmpty(), color = TextStrong, fontWeight = FontWeight.Bold) + Text( + if (session.remoteMuted) stringResource(R.string.video_mic_off) else stringResource(R.string.video_mic_on), + color = TextMuted + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { viewModel.bringVideoToFront(session.callId) }, + colors = ButtonDefaults.textButtonColors(containerColor = Primary100, contentColor = Primary700) + ) { + Text(stringResource(R.string.button_video_foreground), fontWeight = FontWeight.Bold) + } + when { + session.status == "ringing" && session.initiatedBy != currentUserName -> { + TextButton( + onClick = { onAccept(session.callId) }, + colors = ButtonDefaults.textButtonColors(containerColor = SurfaceSoftGreen, contentColor = Primary700) + ) { + Text(stringResource(R.string.button_video_accept), fontWeight = FontWeight.Bold) + } + } + session.status == "ringing" && session.initiatedBy == currentUserName -> { + TextButton( + onClick = { viewModel.cancelVideoCall(session.callId) }, + colors = ButtonDefaults.textButtonColors(containerColor = Primary100, contentColor = Primary700) + ) { + Text(stringResource(R.string.button_video_cancel), fontWeight = FontWeight.Bold) + } + } + session.status == "connecting" || session.status == "active" -> { + TextButton( + onClick = { viewModel.endVideoCall(session.callId) }, + colors = ButtonDefaults.textButtonColors(containerColor = SurfaceSoftRed, contentColor = Danger) + ) { + Text(stringResource(R.string.button_video_end), fontWeight = FontWeight.Bold) + } + } + } + } + } + } + } +} + +@Composable +private fun FloatingVideoWindow( + state: ChatState, + session: VideoSessionState, + viewModel: ChatViewModel, + videoMediaState: de.ypchat.android.media.VideoMediaState, + eglBaseContext: EglBase.Context, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.width(300.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = SurfaceColor) + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF19211D)) + .pointerInput(state.floatingVideoOffsetX, state.floatingVideoOffsetY) { + detectDragGestures { change, dragAmount -> + change.consume() + viewModel.updateFloatingVideoPosition( + state.floatingVideoOffsetX + dragAmount.x, + state.floatingVideoOffsetY + dragAmount.y + ) + } + } + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(session.withUserName.orEmpty(), color = Color.White, fontWeight = FontWeight.Bold) + Text( + if (session.remoteMuted) stringResource(R.string.video_mic_off) else stringResource(R.string.video_mic_on), + color = Color(0xFFD1DED6) + ) + } + TextButton(onClick = viewModel::minimizeForegroundVideo) { Text(stringResource(R.string.button_video_minimize), color = Color.White) } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(190.dp) + .background(Color(0xFF0A110D)) + ) { + val remoteTrack = videoMediaState.remoteVideoTracks[session.callId] + if (remoteTrack != null) { + WebRtcVideoSurface( + videoTrack = remoteTrack, + eglBaseContext = eglBaseContext, + mirror = false, + modifier = Modifier.fillMaxSize() + ) + } else { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text(session.withUserName.orEmpty(), color = Color.White, fontWeight = FontWeight.Bold) + Text(videoStatusLabel(session.status), color = Color(0xFFD6E2DA)) + } + } + Card( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(12.dp) + .size(width = 110.dp, height = 82.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFF1B2721)) + ) { + val localTrack = videoMediaState.localVideoTrack + if (localTrack != null && state.selfCameraEnabled) { + WebRtcVideoSurface( + videoTrack = localTrack, + eglBaseContext = eglBaseContext, + mirror = true, + modifier = Modifier.fillMaxSize() + ) + } else { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Text(stringResource(R.string.video_self_preview), color = Color.White, fontWeight = FontWeight.Bold) + } + } + } + } + Column(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + if (session.remoteMuted) stringResource(R.string.video_partner_mic_off) else stringResource(R.string.video_partner_mic_on), + color = TextMuted + ) + Text( + if (state.selfMuted) stringResource(R.string.video_self_mic_off) else stringResource(R.string.video_self_mic_on), + color = TextMuted + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = viewModel::toggleSelfMuted, + colors = ButtonDefaults.textButtonColors(containerColor = Primary100, contentColor = Primary700) + ) { + Text( + stringResource(if (state.selfMuted) R.string.button_video_unmute else R.string.button_video_mute), + fontWeight = FontWeight.Bold + ) + } + TextButton( + onClick = viewModel::toggleSelfCameraEnabled, + colors = ButtonDefaults.textButtonColors(containerColor = Primary100, contentColor = Primary700) + ) { + Text( + stringResource(if (state.selfCameraEnabled) R.string.button_video_camera_off else R.string.button_video_camera_on), + fontWeight = FontWeight.Bold + ) + } + TextButton( + onClick = { viewModel.endVideoCall(session.callId) }, + colors = ButtonDefaults.textButtonColors(containerColor = SurfaceSoftRed, contentColor = Danger) + ) { + Text(stringResource(R.string.button_video_end), fontWeight = FontWeight.Bold) + } + } + } + } + } +} + +@Composable +private fun WebRtcVideoSurface( + videoTrack: VideoTrack, + eglBaseContext: EglBase.Context, + mirror: Boolean, + modifier: Modifier = Modifier +) { + AndroidView( + modifier = modifier, + factory = { context -> + SurfaceViewRenderer(context).apply { + init(eglBaseContext, null) + setEnableHardwareScaler(true) + setMirror(mirror) + setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL) + videoTrack.addSink(this) + } + }, + update = { renderer -> + renderer.setMirror(mirror) + } + ) + + DisposableEffect(videoTrack) { + onDispose {} + } +} + +@Composable +private fun videoStatusLabel(status: String): String = when (status) { + "ringing" -> stringResource(R.string.video_status_ringing) + "connecting" -> stringResource(R.string.video_status_connecting) + "active" -> stringResource(R.string.video_status_active) + else -> status +} + private fun saveCameraBitmap(context: Context, bitmap: Bitmap): Uri? { return runCatching { val file = File(context.cacheDir, "singlechat-photo-${System.currentTimeMillis()}.jpg") @@ -1092,6 +1649,14 @@ private fun localizeRuntimeMessage(message: String): String { stringResource(R.string.camera_permission_denied) message == "Camera capture failed" -> stringResource(R.string.camera_capture_failed) + message == "Videochat ist erst nach beidseitiger Freigabe sichtbar." -> + stringResource(R.string.video_consent_required) + message == "Maximal drei Videoverbindungen gleichzeitig erlaubt." -> + stringResource(R.string.video_capacity_reached) + message == "Der Gesprächspartner hat bereits die maximale Anzahl an Videoverbindungen erreicht." -> + stringResource(R.string.video_partner_capacity_reached) + message == "Für diesen Gesprächspartner existiert bereits ein laufender Videochat." -> + stringResource(R.string.video_call_exists) message.endsWith(" blocked") -> stringResource(R.string.user_blocked, message.removeSuffix(" blocked")) message.endsWith(" unblocked") -> diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e622be5..fa89d8a 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -52,6 +52,33 @@ Photo Send Smileys + Allow video + Video allowed + Open video chat + Bring forward + Accept + Reject + Cancel + End + Minimize + Mute microphone + Unmute microphone + Camera off + Camera on + Partner allowed video + Partner has not allowed video yet + Maximum of three video connections allowed + You + Camera inactive + Microphone on + Microphone off + Partner: microphone on + Partner: microphone off + You: microphone on + You: microphone off + Ringing + Connecting + Active Image message Uploading image... Image uploaded. @@ -65,6 +92,10 @@ Country list could not be loaded: %1$s %1$s has been blocked %1$s has been unblocked + Video chat becomes visible after both users allow it. + Maximum of three video connections allowed. + The partner already reached the maximum number of video connections. + A video chat with this partner already exists. Feedback Comment Send feedback diff --git a/client/src/components/ChatWindow.vue b/client/src/components/ChatWindow.vue index 89b8b9a..dc836ef 100644 --- a/client/src/components/ChatWindow.vue +++ b/client/src/components/ChatWindow.vue @@ -23,8 +23,48 @@ - +
+
+
+ {{ currentVideoSession.withUserName }} + {{ statusLabel(currentVideoSession.status) }} · {{ currentVideoSession.remoteMuted ? 'Mikro aus' : 'Mikro an' }} +
+
+ + + + +
+
+
{{ message.from }}: - - - +
+
Vergrößertes Bild
-
diff --git a/client/src/components/FloatingVideoWindow.vue b/client/src/components/FloatingVideoWindow.vue new file mode 100644 index 0000000..b2cd01d --- /dev/null +++ b/client/src/components/FloatingVideoWindow.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/client/src/components/VideoDock.vue b/client/src/components/VideoDock.vue new file mode 100644 index 0000000..92091b5 --- /dev/null +++ b/client/src/components/VideoDock.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/client/src/components/VideoSessionSurface.vue b/client/src/components/VideoSessionSurface.vue new file mode 100644 index 0000000..ee761fd --- /dev/null +++ b/client/src/components/VideoSessionSurface.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/client/src/stores/chat.js b/client/src/stores/chat.js index 0cb0d5c..3912966 100644 --- a/client/src/stores/chat.js +++ b/client/src/stores/chat.js @@ -1,11 +1,39 @@ +import { computed, ref, shallowRef } from 'vue'; import { defineStore } from 'pinia'; -import { ref, computed } from 'vue'; import { io } from 'socket.io-client'; -export const useChatStore = defineStore('chat', () => { - const LOGOUT_MARKER_KEY = 'singlechat_logged_out'; +const LOGOUT_MARKER_KEY = 'singlechat_logged_out'; +const TIMEOUT_SECONDS = 1800; +const MAX_VIDEO_CONNECTIONS_DEFAULT = 3; +const VIDEO_TERMINAL_STATUSES = new Set(['rejected', 'cancelled', 'ended', 'failed']); +const VIDEO_LIVE_STATUSES = new Set(['ringing', 'connecting', 'active']); +const WEBRTC_CONNECTION_STATES = new Set(['new', 'connecting', 'connected', 'disconnected', 'failed', 'closed']); - // State +function createEmptySearchData() { + return { + nameIncludes: '', + minAge: null, + maxAge: null, + genders: [], + selectedCountries: [], + selectedCountriesEnglish: [] + }; +} + +function createDefaultVideoConsent(withUserName = null) { + return { + withUserName, + localConsent: false, + remoteConsent: false, + videoVisible: false + }; +} + +function createDefaultFloatingPosition() { + return { x: 24, y: 24 }; +} + +export const useChatStore = defineStore('chat', () => { const isLoggedIn = ref(false); const userName = ref(''); const gender = ref(''); @@ -13,7 +41,7 @@ export const useChatStore = defineStore('chat', () => { const country = ref(''); const isoCountryCode = ref(''); const sessionId = ref(''); - const socket = ref(null); + const socket = shallowRef(null); const users = ref([]); const currentConversation = ref(null); const messages = ref([]); @@ -24,203 +52,442 @@ export const useChatStore = defineStore('chat', () => { const unreadChatsCount = ref(0); const errorMessage = ref(null); const commandTable = ref(null); - const remainingSecondsToTimeout = ref(1800); + const remainingSecondsToTimeout = ref(TIMEOUT_SECONDS); const awaitingLoginUsername = ref(false); const awaitingLoginPassword = ref(false); - const searchData = ref({ - nameIncludes: '', - minAge: null, - maxAge: null, - genders: [], - selectedCountries: [], // Übersetzte Namen (für UI) - selectedCountriesEnglish: [] // Englische Namen (für Server) - }); + const searchData = ref(createEmptySearchData()); + + const videoConsent = ref(createDefaultVideoConsent()); + const videoSessions = ref([]); + const activeVideoConnections = ref(0); + const maxVideoConnections = ref(MAX_VIDEO_CONNECTIONS_DEFAULT); + const maxVideoConnectionsReached = ref(false); + const foregroundVideoSessionId = ref(null); + const floatingVideoPosition = ref(createDefaultFloatingPosition()); + const selfPreviewStream = shallowRef(null); + const remoteStreams = ref({}); + const selfMuted = ref(false); + const selfCameraEnabled = ref(true); + let timeoutTimer = null; - const TIMEOUT_SECONDS = 1800; // 30 Minuten + const peerConnections = new Map(); - // Computed - const currentConversationWith = computed(() => { + const currentConversationWith = computed(() => currentConversation.value || null); + const currentConversationVideoSession = computed(() => { if (!currentConversation.value) return null; - return currentConversation.value; + return videoSessions.value.find((session) => ( + session.withUserName === currentConversation.value && + VIDEO_LIVE_STATUSES.has(session.status) + )) || null; }); + const dockVideoSessions = computed(() => ( + videoSessions.value + .filter((session) => VIDEO_LIVE_STATUSES.has(session.status)) + .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))) + .slice(0, MAX_VIDEO_CONNECTIONS_DEFAULT) + )); + const foregroundVideoSession = computed(() => ( + dockVideoSessions.value.find((session) => session.callId === foregroundVideoSessionId.value) || null + )); + const hasVideoSessions = computed(() => dockVideoSessions.value.length > 0); + const canToggleVideoConsent = computed(() => !!currentConversation.value); + const canStartVideoCall = computed(() => ( + !!currentConversation.value && + videoConsent.value.videoVisible && + !currentConversationVideoSession.value && + !maxVideoConnectionsReached.value + )); - // Actions - function connectWebSocket() { - return new Promise((resolve, reject) => { - // Schließe alte Verbindung, falls vorhanden - if (socket.value) { - try { - socket.value.disconnect(); - } catch (e) { - // Ignoriere Fehler beim Schließen + function setTemporaryError(message, duration = 5000) { + errorMessage.value = message; + if (!duration) return; + setTimeout(() => { + if (errorMessage.value === message) { + errorMessage.value = null; + } + }, duration); + } + + function updateVideoCapacity(data = {}) { + activeVideoConnections.value = Number.isFinite(data.activeConnections) ? data.activeConnections : 0; + maxVideoConnections.value = Number.isFinite(data.maxConnections) ? data.maxConnections : MAX_VIDEO_CONNECTIONS_DEFAULT; + maxVideoConnectionsReached.value = !!data.reachedMax; + } + + function normalizeVideoSession(data = {}) { + return { + callId: data.callId, + roomId: data.roomId || null, + withUserName: data.withUserName || null, + initiatedBy: data.initiatedBy || null, + status: data.status || 'ringing', + createdAt: data.createdAt || new Date().toISOString(), + updatedAt: data.updatedAt || new Date().toISOString(), + endedAt: data.endedAt || null, + reason: data.reason || null, + localMuted: !!data.localMuted, + remoteMuted: !!data.remoteMuted, + connectionState: data.connectionState || 'new', + remoteConnectionState: data.remoteConnectionState || 'new', + media: data.media ? { + mode: data.media.mode || 'webrtc-relay', + relayOnly: !!data.media.relayOnly, + iceTransportPolicy: data.media.iceTransportPolicy || 'relay', + iceServers: Array.isArray(data.media.iceServers) ? data.media.iceServers : [], + isCaller: !!data.media.isCaller + } : null + }; + } + + function getRemoteStream(callId) { + return remoteStreams.value[callId] || null; + } + + function setRemoteStream(callId, stream) { + remoteStreams.value = { + ...remoteStreams.value, + [callId]: stream + }; + } + + function removeRemoteStream(callId) { + if (!remoteStreams.value[callId]) return; + const next = { ...remoteStreams.value }; + delete next[callId]; + remoteStreams.value = next; + } + + function emitConnectionState(callId, connectionState) { + if (!socket.value?.connected || !callId || !WEBRTC_CONNECTION_STATES.has(connectionState)) return; + socket.value.emit('videoCall:connectionState', { callId, connectionState }); + } + + function emitVideoSignal(callId, signalType, payload = {}) { + if (!socket.value?.connected || !callId) return; + socket.value.emit('videoCall:signal', { + callId, + signalType, + ...payload + }); + } + + function cleanupPeerConnection(callId) { + const runtime = peerConnections.get(callId); + if (!runtime) { + removeRemoteStream(callId); + return; + } + + try { + runtime.pc.ontrack = null; + runtime.pc.onicecandidate = null; + runtime.pc.onconnectionstatechange = null; + runtime.pc.oniceconnectionstatechange = null; + runtime.pc.close(); + } catch {} + + peerConnections.delete(callId); + removeRemoteStream(callId); + } + + function pruneTerminalVideoSession(callId, delay = 2600) { + window.setTimeout(() => { + const session = videoSessions.value.find((entry) => entry.callId === callId); + if (session && VIDEO_TERMINAL_STATUSES.has(session.status)) { + cleanupPeerConnection(callId); + videoSessions.value = videoSessions.value.filter((entry) => entry.callId !== callId); + if (foregroundVideoSessionId.value === callId) { + foregroundVideoSessionId.value = dockVideoSessions.value[0]?.callId || null; } - socket.value = null; + maybeReleaseLocalPreview(); } - - let url; - - if (import.meta.env.DEV) { - // Socket.IO läuft jetzt auf dem gleichen Port wie Express - url = 'http://localhost:3300'; - } else { - // In Production: Socket.IO läuft über den gleichen Host/Port wie die Webseite - // Apache leitet alles an den Backend-Server weiter - url = window.location.origin; + }, delay); + } + + function upsertVideoSession(data) { + const next = normalizeVideoSession(data); + const index = videoSessions.value.findIndex((entry) => entry.callId === next.callId); + + if (index >= 0) { + videoSessions.value[index] = { + ...videoSessions.value[index], + ...next + }; + videoSessions.value = [...videoSessions.value]; + } else { + videoSessions.value = [...videoSessions.value, next]; + } + + if (!foregroundVideoSessionId.value && VIDEO_LIVE_STATUSES.has(next.status)) { + foregroundVideoSessionId.value = next.callId; + } + + if (VIDEO_TERMINAL_STATUSES.has(next.status)) { + pruneTerminalVideoSession(next.callId); + } + } + + function clearVideoSessions() { + for (const callId of Array.from(peerConnections.keys())) { + cleanupPeerConnection(callId); + } + videoSessions.value = []; + remoteStreams.value = {}; + foregroundVideoSessionId.value = null; + activeVideoConnections.value = 0; + maxVideoConnectionsReached.value = false; + } + + function clearVideoState() { + videoConsent.value = createDefaultVideoConsent(currentConversation.value); + clearVideoSessions(); + releaseLocalPreviewStream(); + selfMuted.value = false; + selfCameraEnabled.value = true; + floatingVideoPosition.value = createDefaultFloatingPosition(); + } + + async function ensureLocalPreviewStream() { + if (selfPreviewStream.value) return selfPreviewStream.value; + if (!navigator.mediaDevices?.getUserMedia) return null; + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: 'user', + width: { ideal: 960 }, + height: { ideal: 720 } + }, + audio: true + }); + selfPreviewStream.value = stream; + selfMuted.value = false; + selfCameraEnabled.value = true; + return stream; + } catch (error) { + console.error('Lokale Video-Vorschau konnte nicht gestartet werden:', error); + setTemporaryError('Lokale Kamera/Vorschau konnte nicht gestartet werden.'); + return null; + } + } + + function releaseLocalPreviewStream() { + if (!selfPreviewStream.value) return; + for (const track of selfPreviewStream.value.getTracks()) { + track.stop(); + } + selfPreviewStream.value = null; + } + + function maybeReleaseLocalPreview() { + if (!hasVideoSessions.value) { + releaseLocalPreviewStream(); + } + } + + async function ensurePeerConnectionForSession(session) { + if (!session?.callId || !session.media) return null; + + const existing = peerConnections.get(session.callId); + if (existing) { + return existing; + } + + const localStream = await ensureLocalPreviewStream(); + if (!localStream) { + throw new Error('Lokaler Medienstream ist nicht verfügbar.'); + } + + if (typeof RTCPeerConnection === 'undefined') { + throw new Error('WebRTC wird in diesem Browser nicht unterstützt.'); + } + + const pc = new RTCPeerConnection({ + iceServers: session.media.iceServers || [], + iceTransportPolicy: session.media.iceTransportPolicy || 'relay', + bundlePolicy: 'max-bundle', + rtcpMuxPolicy: 'require' + }); + + const runtime = { + pc, + localStream, + pendingCandidates: [], + offerCreated: false + }; + + for (const track of localStream.getTracks()) { + pc.addTrack(track, localStream); + } + + pc.ontrack = (event) => { + const [stream] = event.streams || []; + if (stream) { + setRemoteStream(session.callId, stream); + return; } - - console.log('=== Socket.IO-Verbindung ==='); - console.log('Versuche Socket.IO-Verbindung zu:', url); - console.log('Aktuelle Seite:', window.location.href); - console.log('DEV-Modus:', import.meta.env.DEV); - - let timeoutId; - let resolved = false; - + + const remoteStream = getRemoteStream(session.callId) || new MediaStream(); + if (!remoteStream.getTracks().some((track) => track.id === event.track.id)) { + remoteStream.addTrack(event.track); + } + setRemoteStream(session.callId, remoteStream); + }; + + pc.onicecandidate = (event) => { + if (!event.candidate) return; + emitVideoSignal(session.callId, 'candidate', { + candidate: event.candidate.toJSON() + }); + }; + + pc.onconnectionstatechange = () => { + const state = pc.connectionState; + if (WEBRTC_CONNECTION_STATES.has(state)) { + emitConnectionState(session.callId, state); + } + }; + + pc.oniceconnectionstatechange = () => { + if (pc.iceConnectionState === 'failed') { + emitConnectionState(session.callId, 'failed'); + } + }; + + peerConnections.set(session.callId, runtime); + syncLocalTrackState(); + return runtime; + } + + async function flushPendingRemoteCandidates(runtime) { + if (!runtime?.pendingCandidates?.length) return; + const queue = [...runtime.pendingCandidates]; + runtime.pendingCandidates.length = 0; + for (const candidate of queue) { try { - const socketInstance = io(url, { - transports: ['polling'], // Nur Polling verwenden, um WebSocket-Probleme zu vermeiden - reconnection: true, - reconnectionAttempts: 5, - reconnectionDelay: 1000, - upgrade: false, // Kein Upgrade zu WebSocket - rememberUpgrade: false, - withCredentials: true // Wichtig für Cookies/Sessions - }); - - // Timeout nach 5 Sekunden - timeoutId = setTimeout(() => { - if (!resolved) { - resolved = true; - socketInstance.disconnect(); - reject(new Error('Socket.IO-Verbindung-Timeout: Server antwortet nicht. Bitte stelle sicher, dass der Server auf Port 3300 läuft.')); - } - }, 5000); - - socketInstance.on('connect', async () => { - if (!resolved) { - resolved = true; - clearTimeout(timeoutId); - console.log('Socket.IO-Verbindung erfolgreich'); - socket.value = socketInstance; - - // Hole Express-Session-ID und sende sie an den Server - try { - const response = await fetch('/api/session', { - credentials: 'include' - }); - if (response.ok) { - const data = await response.json(); - if (data.sessionId) { - console.log('Socket.IO Connect - Sende Express-Session-ID:', data.sessionId); - socketInstance.emit('setSessionId', { expressSessionId: data.sessionId }); - } - } - } catch (error) { - console.error('Fehler beim Abrufen der Session-ID:', error); - } - - resolve(socketInstance); - } - }); - - socketInstance.on('connected', (data) => { - console.log('Connected-Nachricht empfangen:', data); - sessionId.value = data.sessionId; - - // Wenn bereits eingeloggt, Login-Status wiederherstellen - if (data.loggedIn && data.user) { - isLoggedIn.value = true; - userName.value = data.user.userName; - gender.value = data.user.gender; - age.value = data.user.age; - country.value = data.user.country; - isoCountryCode.value = data.user.isoCountryCode; - startTimeoutTimer(); - } - }); - - socketInstance.on('disconnect', (reason) => { - console.log('Socket.IO-Verbindung getrennt:', reason); - socket.value = null; - }); - - socketInstance.on('connect_error', (error) => { - console.error('Socket.IO Verbindungsfehler:', error); - if (!resolved) { - resolved = true; - clearTimeout(timeoutId); - reject(new Error('Socket.IO-Verbindung fehlgeschlagen: ' + error.message)); - } - }); - - // Event-Handler für verschiedene Nachrichtentypen - socketInstance.on('loginSuccess', (data) => { - handleWebSocketMessage({ type: 'loginSuccess', ...data }); - }); - - socketInstance.on('userList', (data) => { - handleWebSocketMessage({ type: 'userList', ...data }); - }); - - socketInstance.on('message', (data) => { - handleWebSocketMessage({ type: 'message', ...data }); - }); - - socketInstance.on('messageSent', (data) => { - handleWebSocketMessage({ type: 'messageSent', ...data }); - }); - - socketInstance.on('messageSent', (data) => { - handleWebSocketMessage({ type: 'messageSent', ...data }); - }); - - socketInstance.on('conversation', (data) => { - handleWebSocketMessage({ type: 'conversation', ...data }); - }); - - socketInstance.on('searchResults', (data) => { - handleWebSocketMessage({ type: 'searchResults', ...data }); - }); - - socketInstance.on('inboxResults', (data) => { - handleWebSocketMessage({ type: 'inboxResults', ...data }); - }); - - socketInstance.on('historyResults', (data) => { - handleWebSocketMessage({ type: 'historyResults', ...data }); - }); - - socketInstance.on('commandResult', (data) => { - handleWebSocketMessage({ type: 'commandResult', ...data }); - }); - - socketInstance.on('commandTable', (data) => { - handleWebSocketMessage({ type: 'commandTable', ...data }); - }); - - socketInstance.on('unreadChats', (data) => { - handleWebSocketMessage({ type: 'unreadChats', ...data }); - }); - - socketInstance.on('error', (data) => { - handleWebSocketMessage({ type: 'error', ...data }); - }); - - console.log('Socket.IO-Objekt erstellt'); + await runtime.pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (error) { - if (timeoutId) clearTimeout(timeoutId); - console.error('Fehler beim Erstellen der Socket.IO-Verbindung:', error); - reject(new Error('Fehler beim Erstellen der Socket.IO-Verbindung: ' + error.message)); + console.error('ICE-Kandidat konnte nicht angewendet werden:', error); + } + } + } + + async function maybeCreateOffer(session) { + if (!session?.media?.isCaller) return; + const runtime = await ensurePeerConnectionForSession(session); + if (!runtime || runtime.offerCreated) return; + + runtime.offerCreated = true; + const offer = await runtime.pc.createOffer({ + offerToReceiveAudio: true, + offerToReceiveVideo: true + }); + await runtime.pc.setLocalDescription(offer); + emitVideoSignal(session.callId, 'description', { + description: { + type: offer.type, + sdp: offer.sdp } }); } + async function startVideoMediaForSession(session) { + if (!session?.media) return; + try { + await ensurePeerConnectionForSession(session); + await maybeCreateOffer(session); + } catch (error) { + console.error('Video-Medienpfad konnte nicht gestartet werden:', error); + setTemporaryError(error.message || 'Videochat konnte nicht gestartet werden.'); + if (session.callId) { + endVideoCall(session.callId); + } + } + } + + async function handleIncomingVideoSignal(data = {}) { + const callId = data.callId; + const signalType = data.signalType; + const session = videoSessions.value.find((entry) => entry.callId === callId); + if (!session || !session.media) return; + + try { + const runtime = await ensurePeerConnectionForSession(session); + if (!runtime) return; + + if (signalType === 'description' && data.description?.type && data.description?.sdp) { + await runtime.pc.setRemoteDescription(new RTCSessionDescription(data.description)); + await flushPendingRemoteCandidates(runtime); + + if (data.description.type === 'offer') { + const answer = await runtime.pc.createAnswer(); + await runtime.pc.setLocalDescription(answer); + emitVideoSignal(callId, 'description', { + description: { + type: answer.type, + sdp: answer.sdp + } + }); + } + return; + } + + if (signalType === 'candidate' && data.candidate) { + if (runtime.pc.remoteDescription) { + await runtime.pc.addIceCandidate(new RTCIceCandidate(data.candidate)); + } else { + runtime.pendingCandidates.push(data.candidate); + } + } + } catch (error) { + console.error('Video-Signalisierung konnte nicht verarbeitet werden:', error); + setTemporaryError('Video-Signalisierung fehlgeschlagen.'); + if (callId) { + endVideoCall(callId); + } + } + } + + function emitMuteState(callId, muted) { + if (!socket.value?.connected || !callId) return; + socket.value.emit('videoCall:muteState', { callId, muted }); + } + + function broadcastSelfMuteState() { + for (const session of dockVideoSessions.value) { + emitMuteState(session.callId, selfMuted.value); + } + } + + function syncLocalTrackState() { + if (!selfPreviewStream.value) return; + for (const track of selfPreviewStream.value.getAudioTracks()) { + track.enabled = !selfMuted.value; + } + for (const track of selfPreviewStream.value.getVideoTracks()) { + track.enabled = selfCameraEnabled.value; + } + } + + function setCurrentConversationVideoConsentFallback() { + videoConsent.value = createDefaultVideoConsent(currentConversation.value); + } + function handleWebSocketMessage(data) { console.log('WebSocket-Nachricht empfangen:', data.type); - + switch (data.type) { case 'connected': sessionId.value = data.sessionId; + if (data.loggedIn && data.user) { + isLoggedIn.value = true; + userName.value = data.user.userName; + gender.value = data.user.gender; + age.value = data.user.age; + country.value = data.user.country; + isoCountryCode.value = data.user.isoCountryCode; + startTimeoutTimer(); + } break; case 'loginSuccess': isLoggedIn.value = true; @@ -230,61 +497,41 @@ export const useChatStore = defineStore('chat', () => { country.value = data.user.country; isoCountryCode.value = data.user.isoCountryCode; sessionId.value = data.sessionId; - // Wichtig: Beim frischen Login den Timeout-Timer starten startTimeoutTimer(); break; case 'userList': users.value = data.users; - // Aktualisiere Suchergebnisse, falls eine Suche aktiv ist updateSearchResults(); break; case 'message': - // Debug-Logging für empfangene Nachrichten - if (data.isImage) { - console.log('[Bild empfangen] Von:', data.from, 'URL:', data.imageUrl || data.message); - } - - // Sound abspielen bei neuer Nachricht (nur wenn nicht selbst gesendet) if (!data.self) { try { const audio = new Audio('/static/newmessage.mp3'); - audio.play().catch(err => { - // Ignoriere Fehler (z.B. wenn Browser Auto-Play blockiert) - console.log('Sound konnte nicht abgespielt werden:', err); - }); - } catch (error) { - console.log('Fehler beim Abspielen des Sounds:', error); - } + audio.play().catch(() => {}); + } catch {} } - + if (currentConversation.value === data.from) { - const newMessage = { + messages.value.push({ from: data.from, - message: data.imageUrl || data.message, // Verwende URL für Bilder + message: data.imageUrl || data.message, timestamp: data.timestamp, self: false, isImage: data.isImage || false, imageType: data.imageType || null, imageUrl: data.imageUrl || null, imageCode: data.imageCode || null - }; - - console.log('[Nachricht hinzugefügt]', newMessage); - messages.value.push(newMessage); - } else { - console.log('[Nachricht ignoriert] Aktuelle Konversation:', currentConversation.value, 'Nachricht von:', data.from); + }); } - // Timeout zurücksetzen bei empfangener Nachricht resetTimeoutTimer(); break; case 'messageSent': - // Bestätigung, dass Nachricht gesendet wurde break; case 'conversation': currentConversation.value = data.with; - messages.value = data.messages.map(msg => ({ + messages.value = data.messages.map((msg) => ({ from: msg.from, - message: msg.imageUrl || msg.message, // Verwende URL für Bilder + message: msg.imageUrl || msg.message, timestamp: msg.timestamp, self: msg.from === userName.value, isImage: msg.isImage || false, @@ -292,6 +539,7 @@ export const useChatStore = defineStore('chat', () => { imageUrl: msg.imageUrl || null, imageCode: msg.imageCode || null })); + setCurrentConversationVideoConsentFallback(); break; case 'searchResults': searchResults.value = data.results; @@ -317,106 +565,243 @@ export const useChatStore = defineStore('chat', () => { awaitingLoginPassword.value = false; } - // Command-Ausgaben immer global anzeigen, nicht im Chatverlauf. - errorMessage.value = lines.join(' | '); - setTimeout(() => { - errorMessage.value = null; - }, 5000); + setTemporaryError(lines.join(' | ')); break; } - case 'commandTable': { - const title = data.title || 'Ausgabe'; - const columns = Array.isArray(data.columns) ? data.columns : []; - const rows = Array.isArray(data.rows) ? data.rows : []; - commandTable.value = { title, columns, rows }; - // Tabelle ist persistent; temporäre Fehlermeldung löschen + case 'commandTable': + commandTable.value = { + title: data.title || 'Ausgabe', + columns: Array.isArray(data.columns) ? data.columns : [], + rows: Array.isArray(data.rows) ? data.rows : [] + }; errorMessage.value = null; break; - } case 'unreadChats': unreadChatsCount.value = data.count || 0; break; case 'error': console.error('Server-Fehler:', data.message); - errorMessage.value = data.message; - // Fehlermeldung nach 5 Sekunden automatisch entfernen - setTimeout(() => { - errorMessage.value = null; - }, 5000); + setTemporaryError(data.message); + break; + case 'videoConsent:update': + if (!currentConversation.value || currentConversation.value === data.withUserName) { + videoConsent.value = { + withUserName: data.withUserName || currentConversation.value, + localConsent: !!data.localConsent, + remoteConsent: !!data.remoteConsent, + videoVisible: !!data.videoVisible + }; + } + break; + case 'videoCall:capacity': + updateVideoCapacity(data); + break; + case 'videoCall:invite': + case 'videoCall:incoming': + case 'videoCall:muteState': + upsertVideoSession(data); + if (currentConversation.value && currentConversation.value === data.withUserName) { + videoConsent.value.videoVisible = true; + } + break; + case 'videoCall:start': { + const session = normalizeVideoSession(data); + upsertVideoSession(session); + if (currentConversation.value && currentConversation.value === session.withUserName) { + videoConsent.value.videoVisible = true; + } + startVideoMediaForSession(session); + break; + } + case 'videoCall:update': { + const session = normalizeVideoSession(data); + upsertVideoSession(session); + if ((session.status === 'connecting' || session.status === 'active') && session.media) { + startVideoMediaForSession(session); + } + break; + } + case 'videoCall:reject': + case 'videoCall:cancel': + case 'videoCall:end': + upsertVideoSession(data); + break; + case 'videoCall:signal': + handleIncomingVideoSignal(data); + break; + case 'videoCall:error': + setTemporaryError(data.message || 'Videochat-Fehler'); + if (data.code === 'VIDEO_CAPACITY_REACHED') { + maxVideoConnectionsReached.value = true; + } + break; + default: break; } } + function connectWebSocket() { + return new Promise((resolve, reject) => { + if (socket.value) { + try { + socket.value.disconnect(); + } catch {} + socket.value = null; + } + + const url = import.meta.env.DEV ? 'http://localhost:3300' : window.location.origin; + let timeoutId; + let resolved = false; + + try { + const socketInstance = io(url, { + transports: ['polling'], + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + upgrade: false, + rememberUpgrade: false, + withCredentials: true + }); + + timeoutId = setTimeout(() => { + if (!resolved) { + resolved = true; + socketInstance.disconnect(); + reject(new Error('Socket.IO-Verbindung-Timeout: Server antwortet nicht.')); + } + }, 5000); + + socketInstance.on('connect', async () => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + socket.value = socketInstance; + + try { + const response = await fetch('/api/session', { credentials: 'include' }); + if (response.ok) { + const data = await response.json(); + if (data.sessionId) { + socketInstance.emit('setSessionId', { expressSessionId: data.sessionId }); + } + } + } catch (error) { + console.error('Fehler beim Abrufen der Session-ID:', error); + } + + resolve(socketInstance); + }); + + socketInstance.on('disconnect', (reason) => { + console.log('Socket.IO-Verbindung getrennt:', reason); + socket.value = null; + clearVideoSessions(); + maybeReleaseLocalPreview(); + }); + + socketInstance.on('connect_error', (error) => { + console.error('Socket.IO Verbindungsfehler:', error); + if (!resolved) { + resolved = true; + clearTimeout(timeoutId); + reject(new Error(`Socket.IO-Verbindung fehlgeschlagen: ${error.message}`)); + } + }); + + const eventMap = { + connected: 'connected', + loginSuccess: 'loginSuccess', + userList: 'userList', + message: 'message', + messageSent: 'messageSent', + conversation: 'conversation', + searchResults: 'searchResults', + inboxResults: 'inboxResults', + historyResults: 'historyResults', + commandResult: 'commandResult', + commandTable: 'commandTable', + unreadChats: 'unreadChats', + error: 'error', + 'videoConsent:update': 'videoConsent:update', + 'videoCall:invite': 'videoCall:invite', + 'videoCall:incoming': 'videoCall:incoming', + 'videoCall:start': 'videoCall:start', + 'videoCall:update': 'videoCall:update', + 'videoCall:reject': 'videoCall:reject', + 'videoCall:cancel': 'videoCall:cancel', + 'videoCall:end': 'videoCall:end', + 'videoCall:error': 'videoCall:error', + 'videoCall:capacity': 'videoCall:capacity', + 'videoCall:muteState': 'videoCall:muteState', + 'videoCall:signal': 'videoCall:signal' + }; + + for (const [eventName, type] of Object.entries(eventMap)) { + socketInstance.on(eventName, (payload) => { + handleWebSocketMessage({ type, ...payload }); + }); + } + } catch (error) { + if (timeoutId) clearTimeout(timeoutId); + reject(new Error(`Fehler beim Erstellen der Socket.IO-Verbindung: ${error.message}`)); + } + }); + } + async function login(userNameVal, genderVal, ageVal, countryVal) { try { window.localStorage.removeItem(LOGOUT_MARKER_KEY); - } catch (error) { - console.warn('Logout-Marker konnte nicht entfernt werden:', error); - } + } catch {} - // Stelle sicher, dass Socket.IO verbunden ist - if (!socket.value || !socket.value.connected) { - console.log('Socket.IO nicht verbunden, versuche Verbindung herzustellen...'); + if (!socket.value?.connected) { try { await connectWebSocket(); - // Warte kurz, damit die Verbindung vollständig hergestellt ist - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); } catch (error) { console.error('Fehler beim Verbinden mit Socket.IO:', error); alert('Verbindung zum Server fehlgeschlagen. Bitte stelle sicher, dass der Server läuft.'); return; } } - - // Prüfe nochmal, ob die Verbindung jetzt besteht - if (!socket.value || !socket.value.connected) { - console.error('Socket.IO-Verbindung konnte nicht hergestellt werden'); + + if (!socket.value?.connected) { alert('Verbindung zum Server fehlgeschlagen. Bitte stelle sicher, dass der Server läuft.'); return; } - - // Hole Express-Session-ID vom Server + let expressSessionId = null; try { - const response = await fetch('/api/session', { - credentials: 'include' - }); + const response = await fetch('/api/session', { credentials: 'include' }); if (response.ok) { const data = await response.json(); expressSessionId = data.sessionId; - console.log('Login - Express-Session-ID erhalten:', expressSessionId); } } catch (error) { console.error('Fehler beim Abrufen der Session-ID:', error); } - + socket.value.emit('login', { userName: userNameVal, gender: genderVal, age: ageVal, country: countryVal, - expressSessionId: expressSessionId + expressSessionId }); } function sendMessage(toUserName, message, options = {}) { - if (!socket.value || !socket.value.connected) { + if (!socket.value?.connected) { console.error('Socket.IO nicht verbunden'); return; } - + const messageId = Date.now().toString(); const trimmed = message.trim(); const isCommand = trimmed.startsWith('/'); - socket.value.emit('message', { - toUserName, - message: trimmed, - messageId - }); - - const suppressLocal = !!options.suppressLocal || isCommand || awaitingLoginUsername.value || awaitingLoginPassword.value; + socket.value.emit('message', { toUserName, message: trimmed, messageId }); - // Lokal hinzufügen (außer bei Commands oder sensiblen Eingaben wie Login) + const suppressLocal = !!options.suppressLocal || isCommand || awaitingLoginUsername.value || awaitingLoginPassword.value; if (!isCommand && !suppressLocal) { messages.value.push({ from: userName.value, @@ -425,145 +810,107 @@ export const useChatStore = defineStore('chat', () => { self: true }); } - - // Timeout zurücksetzen bei Aktivität + resetTimeoutTimer(); } function sendImage(toUserName, imageCode, imageUrl) { - if (!socket.value || !socket.value.connected) { + if (!socket.value?.connected) { console.error('Socket.IO nicht verbunden'); return; } - if (!toUserName) { console.error('Empfänger fehlt'); return; } - + const messageId = Date.now().toString(); socket.value.emit('message', { toUserName, - message: imageCode, // Nur der Code, nicht das gesamte Bild + message: imageCode, messageId, isImage: true, - imageUrl: imageUrl // URL für das Bild + imageUrl }); - - // Lokal hinzufügen (mit URL) + messages.value.push({ from: userName.value, - message: imageUrl, // Verwende URL statt Code für lokale Anzeige + message: imageUrl, timestamp: new Date().toISOString(), self: true, isImage: true, - imageCode: imageCode + imageCode }); - - // Timeout zurücksetzen bei Aktivität + resetTimeoutTimer(); } function requestConversation(withUserName) { - if (!socket.value || !socket.value.connected) { - console.error('Socket.IO nicht verbunden'); - errorMessage.value = 'Socket.IO nicht verbunden'; - setTimeout(() => { - errorMessage.value = null; - }, 5000); + if (!socket.value?.connected) { + setTemporaryError('Socket.IO nicht verbunden'); return; } - - // Fehlermeldung zurücksetzen + errorMessage.value = null; - - socket.value.emit('requestConversation', { - withUserName - }); - + socket.value.emit('requestConversation', { withUserName }); currentConversation.value = withUserName; currentView.value = 'chat'; + videoConsent.value = createDefaultVideoConsent(withUserName); } function userSearch(searchDataPayload) { - if (!socket.value || !socket.value.connected) { - console.error('Socket.IO nicht verbunden'); - errorMessage.value = 'Socket.IO nicht verbunden'; - setTimeout(() => { - errorMessage.value = null; - }, 5000); + if (!socket.value?.connected) { + setTemporaryError('Socket.IO nicht verbunden'); return; } - - // Fehlermeldung zurücksetzen + errorMessage.value = null; - - // Speichere Suchparameter für spätere Aktualisierungen - // Hinweis: selectedCountries wird in SearchView.vue verwaltet und enthält übersetzte Namen - // Für updateSearchResults speichern wir auch die englischen Länder-Namen searchData.value.nameIncludes = searchDataPayload.nameIncludes || ''; searchData.value.minAge = searchDataPayload.minAge || null; searchData.value.maxAge = searchDataPayload.maxAge || null; searchData.value.genders = searchDataPayload.genders || []; - // Speichere die englischen Länder-Namen für spätere Aktualisierungen if (searchDataPayload.countries) { searchData.value.selectedCountriesEnglish = searchDataPayload.countries; } - + socket.value.emit('userSearch', searchDataPayload); - resetTimeoutTimer(); // Aktivität zurücksetzen + resetTimeoutTimer(); } function updateSearchResults() { - // Aktualisiere Suchergebnisse nur, wenn eine Suche aktiv ist - // Prüfe, ob der Benutzer auf der Suchseite ist UND ob es bereits Suchergebnisse gibt - const hasSearchResults = searchResults.value && searchResults.value.length > 0; - const hasSearchParams = searchData.value.nameIncludes || - searchData.value.minAge || - searchData.value.maxAge || - (searchData.value.selectedCountries && searchData.value.selectedCountries.length > 0) || - (searchData.value.genders && searchData.value.genders.length > 0); - - // Aktualisiere nur, wenn der Benutzer auf der Suchseite ist UND (Ergebnisse vorhanden ODER Parameter gesetzt) + const hasSearchResults = !!(searchResults.value && searchResults.value.length > 0); + const hasSearchParams = ( + searchData.value.nameIncludes || + searchData.value.minAge || + searchData.value.maxAge || + (searchData.value.selectedCountries && searchData.value.selectedCountries.length > 0) || + (searchData.value.genders && searchData.value.genders.length > 0) + ); + if (currentView.value !== 'search' || (!hasSearchResults && !hasSearchParams)) { return; } - - // Führe die Suche erneut aus, um aktuelle Ergebnisse zu erhalten - // Verwende die englischen Länder-Namen, die beim letzten Suchvorgang gespeichert wurden + const searchPayload = { nameIncludes: searchData.value.nameIncludes || null, minAge: searchData.value.minAge || null, maxAge: searchData.value.maxAge || null, - countries: searchData.value.selectedCountriesEnglish && searchData.value.selectedCountriesEnglish.length > 0 - ? searchData.value.selectedCountriesEnglish - : null, - genders: searchData.value.genders && searchData.value.genders.length > 0 - ? searchData.value.genders - : null + countries: searchData.value.selectedCountriesEnglish?.length > 0 ? searchData.value.selectedCountriesEnglish : null, + genders: searchData.value.genders?.length > 0 ? searchData.value.genders : null }; - - // Sende Suchanfrage erneut an den Server - if (socket.value && socket.value.connected) { + + if (socket.value?.connected) { socket.value.emit('userSearch', searchPayload); } } function requestHistory() { - if (!socket.value || !socket.value.connected) { - console.error('Socket.IO nicht verbunden'); - return; - } - + if (!socket.value?.connected) return; socket.value.emit('requestHistory'); } function requestOpenConversations() { - if (!socket.value || !socket.value.connected) { - console.error('Socket.IO nicht verbunden'); - return; - } - + if (!socket.value?.connected) return; socket.value.emit('requestOpenConversations'); } @@ -573,11 +920,7 @@ export const useChatStore = defineStore('chat', () => { function setView(view) { currentView.value = view; - - // Search-Ergebnisse NICHT zurücksetzen, damit sie beim Zurückkehren erhalten bleiben if (view === 'search') { - // Wenn zur Suchseite zurückgekehrt wird, aktualisiere die Suchergebnisse - // falls bereits Suchparameter vorhanden sind updateSearchResults(); } else if (view === 'inbox') { requestOpenConversations(); @@ -586,12 +929,80 @@ export const useChatStore = defineStore('chat', () => { } } + function setVideoConsent(allowed) { + if (!socket.value?.connected || !currentConversation.value) return; + socket.value.emit('videoConsent:set', { + withUserName: currentConversation.value, + allowed + }); + } + + async function inviteVideoCall() { + if (!socket.value?.connected || !currentConversation.value || !canStartVideoCall.value) return; + const stream = await ensureLocalPreviewStream(); + if (!stream) { + setTemporaryError('Kamera/Mikrofon konnten nicht für den Videochat gestartet werden.'); + return; + } + socket.value.emit('videoCall:invite', { withUserName: currentConversation.value }); + } + + async function acceptVideoCall(callId) { + if (!socket.value?.connected || !callId) return; + const stream = await ensureLocalPreviewStream(); + if (!stream) { + setTemporaryError('Kamera/Mikrofon konnten nicht für den Videochat gestartet werden.'); + return; + } + socket.value.emit('videoCall:accept', { callId }); + } + + function rejectVideoCall(callId) { + if (!socket.value?.connected || !callId) return; + socket.value.emit('videoCall:reject', { callId }); + } + + function cancelVideoCall(callId) { + if (!socket.value?.connected || !callId) return; + socket.value.emit('videoCall:cancel', { callId }); + } + + function endVideoCall(callId) { + if (!socket.value?.connected || !callId) return; + socket.value.emit('videoCall:end', { callId }); + } + + function bringVideoSessionToFront(callId) { + if (!callId) return; + foregroundVideoSessionId.value = callId; + } + + function minimizeForegroundVideo() { + foregroundVideoSessionId.value = null; + } + + function updateFloatingVideoPosition(position) { + floatingVideoPosition.value = { + x: Math.max(0, Math.round(position.x)), + y: Math.max(0, Math.round(position.y)) + }; + } + + function toggleSelfMute() { + selfMuted.value = !selfMuted.value; + syncLocalTrackState(); + broadcastSelfMuteState(); + } + + function toggleSelfCamera() { + selfCameraEnabled.value = !selfCameraEnabled.value; + syncLocalTrackState(); + } + async function logout() { try { window.localStorage.setItem(LOGOUT_MARKER_KEY, '1'); - } catch (error) { - console.warn('Logout-Marker konnte nicht gespeichert werden:', error); - } + } catch {} try { await fetch('/api/logout', { @@ -618,15 +1029,9 @@ export const useChatStore = defineStore('chat', () => { inboxResults.value = []; historyResults.value = []; commandTable.value = null; - searchData.value = { - nameIncludes: '', - minAge: null, - maxAge: null, - genders: [], - selectedCountries: [], - selectedCountriesEnglish: [] - }; - + searchData.value = createEmptySearchData(); + clearVideoState(); + if (socket.value) { socket.value.disconnect(); socket.value = null; @@ -634,27 +1039,23 @@ export const useChatStore = defineStore('chat', () => { } function startTimeoutTimer() { - stopTimeoutTimer(); // Stoppe alten Timer, falls vorhanden + stopTimeoutTimer(); remainingSecondsToTimeout.value = TIMEOUT_SECONDS; - timeoutTimer = setInterval(() => { - remainingSecondsToTimeout.value--; - + remainingSecondsToTimeout.value -= 1; if (remainingSecondsToTimeout.value <= 0) { stopTimeoutTimer(); - // Auto-Logout - console.log('Timeout erreicht - automatischer Logout'); logout(); } - }, 1000); // Jede Sekunde aktualisieren + }, 1000); } - + function resetTimeoutTimer() { if (isLoggedIn.value && timeoutTimer) { remainingSecondsToTimeout.value = TIMEOUT_SECONDS; } } - + function stopTimeoutTimer() { if (timeoutTimer) { clearInterval(timeoutTimer); @@ -667,29 +1068,15 @@ export const useChatStore = defineStore('chat', () => { try { try { if (window.localStorage.getItem(LOGOUT_MARKER_KEY) === '1') { - console.log('restoreSession: Automatische Wiederherstellung nach Logout unterdrueckt'); return false; } - } catch (error) { - console.warn('Logout-Marker konnte nicht gelesen werden:', error); - } + } catch {} + + const response = await fetch('/api/session', { credentials: 'include' }); + if (!response.ok) return false; - console.log('restoreSession: Starte Session-Wiederherstellung...'); - const response = await fetch('/api/session', { - credentials: 'include' // Wichtig für Cookies - }); - - if (!response.ok) { - console.log('restoreSession: Response nicht OK:', response.status); - return false; - } - const data = await response.json(); - console.log('restoreSession: Antwort vom Server:', data); - if (data.loggedIn && data.user) { - console.log('restoreSession: Session gefunden, stelle Login-Status wieder her...'); - // Session wiederherstellen isLoggedIn.value = true; userName.value = data.user.userName; gender.value = data.user.gender; @@ -697,24 +1084,17 @@ export const useChatStore = defineStore('chat', () => { country.value = data.user.country; isoCountryCode.value = data.user.isoCountryCode; sessionId.value = data.user.sessionId; - - console.log('restoreSession: Login-Status wiederhergestellt:', { - userName: userName.value, - sessionId: sessionId.value - }); - - // WebSocket-Verbindung herstellen + try { await connectWebSocket(); startTimeoutTimer(); } catch (error) { console.error('Fehler beim Wiederherstellen der WebSocket-Verbindung:', error); } - + return true; } - - console.log('restoreSession: Keine gültige Session gefunden'); + return false; } catch (error) { console.error('Fehler beim Wiederherstellen der Session:', error); @@ -723,7 +1103,6 @@ export const useChatStore = defineStore('chat', () => { } return { - // State isLoggedIn, userName, gender, @@ -736,19 +1115,35 @@ export const useChatStore = defineStore('chat', () => { currentConversation, messages, currentView, - searchResults, - inboxResults, - historyResults, - unreadChatsCount, - remainingSecondsToTimeout, - errorMessage, - commandTable, - searchData, - awaitingLoginUsername, - awaitingLoginPassword, - // Computed + searchResults, + inboxResults, + historyResults, + unreadChatsCount, + remainingSecondsToTimeout, + errorMessage, + commandTable, + searchData, + awaitingLoginUsername, + awaitingLoginPassword, + videoConsent, + videoSessions, + activeVideoConnections, + maxVideoConnections, + maxVideoConnectionsReached, + foregroundVideoSessionId, + floatingVideoPosition, + selfPreviewStream, + remoteStreams, + selfMuted, + selfCameraEnabled, currentConversationWith, - // Actions + currentConversationVideoSession, + dockVideoSessions, + foregroundVideoSession, + hasVideoSessions, + canToggleVideoConsent, + canStartVideoCall, + getRemoteStream, connectWebSocket, login, sendMessage, @@ -760,6 +1155,17 @@ export const useChatStore = defineStore('chat', () => { clearCommandTable, setView, logout, - restoreSession + restoreSession, + setVideoConsent, + inviteVideoCall, + acceptVideoCall, + rejectVideoCall, + cancelVideoCall, + endVideoCall, + bringVideoSessionToFront, + minimizeForegroundVideo, + updateFloatingVideoPosition, + toggleSelfMute, + toggleSelfCamera }; }); diff --git a/client/src/views/ChatView.vue b/client/src/views/ChatView.vue index 6f2ee79..6013713 100644 --- a/client/src/views/ChatView.vue +++ b/client/src/views/ChatView.vue @@ -108,6 +108,36 @@ {{ currentUserInfo.age }} · {{ currentUserInfo.gender }} +
+ + +
+ +
+ + {{ chatStore.videoConsent.remoteConsent ? 'Partner hat Video freigegeben' : 'Partner hat Video noch nicht freigegeben' }} + + + Maximal drei Videoverbindungen gleichzeitig erlaubt + + + {{ videoStatusLabel(chatStore.currentConversationVideoSession.status) }} +
@@ -115,9 +145,11 @@ + + @@ -157,6 +189,8 @@ import InboxView from '../components/InboxView.vue'; import HistoryView from '../components/HistoryView.vue'; import ImprintContainer from '../components/ImprintContainer.vue'; import HeaderAdBanner from '../components/HeaderAdBanner.vue'; +import VideoDock from '../components/VideoDock.vue'; +import FloatingVideoWindow from '../components/FloatingVideoWindow.vue'; const chatStore = useChatStore(); @@ -202,6 +236,19 @@ onMounted(async () => { } } }); + +function videoStatusLabel(status) { + switch (status) { + case 'ringing': + return 'Videoanruf klingelt'; + case 'connecting': + return 'Videoanruf verbindet'; + case 'active': + return 'Videoanruf aktiv'; + default: + return ''; + } +} diff --git a/docs/videochat-umsetzungsplan.md b/docs/videochat-umsetzungsplan.md new file mode 100644 index 0000000..ea1d053 --- /dev/null +++ b/docs/videochat-umsetzungsplan.md @@ -0,0 +1,436 @@ +# Videochat-Umsetzungsplan + +## Ziel + +SingleChat soll Videochat innerhalb einer bestehenden 1:1-Konversation unterstützen. + +Rahmenbedingungen: + +- Videochat ist nur aus einer bestehenden Chat-Konversation heraus erreichbar. +- Die Videochat-Funktion soll nur sichtbar sein, wenn beide Gesprächspartner sie für diese Konversation erlaubt haben. +- Es darf keine Direktverbindung zwischen den Endgeräten geben. +- Sämtliche Videoverbindungen laufen über Server-Infrastruktur, damit keine Peer-IP-Adressen offengelegt werden. + +## Empfohlene Architektur + +### 1. Trennung zwischen Chat-Signaling und Medien-Relay + +Die bestehende Socket.IO-Infrastruktur bleibt für: + +- Sichtbarkeit der Video-Funktion +- Freigabe-Status pro Konversation +- Einladung / Annahme / Ablehnung +- Klingelstatus +- Gesprächsstatus +- Fehler- und Abbruchsignale + +Der Austausch der Grunddaten und Anfragen darf über Socket.IO laufen. + +Die eigentlichen Audio-/Videoströme sollen ausdrücklich nicht über die bestehenden Chat-Sockets und nicht über die Chat-Message-Logik laufen, sondern über eine dedizierte serverseitige Medienebene. + +### 2. Keine Peer-to-Peer-Verbindung + +Empfehlung: + +- WebRTC weiterverwenden, aber ausschließlich mit server-relaytem Medientransport +- kein P2P-Mesh +- keine Host-/srflx-Kandidaten verwenden +- nur relay-Kandidaten zulassen oder direkt eine SFU/Media-Server-Lösung einsetzen + +Praktisch heißt das: + +- Signaling kommt aus `server/broadcast.js` +- Medien laufen über einen separaten Media-Server oder eine integrierte SFU +- der Browser bzw. die mobilen Clients sehen nur die Server-Endpunkte, nicht die Gegenstelle +- Socket.IO transportiert nur Status, Einladungen, Freigaben und Session-Metadaten +- Audio/Video läuft ausschließlich über den Medienpfad + +### 3. Bevorzugte Zielarchitektur + +Für SingleChat ist eine SFU-basierte 1:1-Lösung sinnvoller als vollständiges Server-Transcoding: + +- bessere Latenz als kompletter zentraler Decode/Encode-Relay +- trotzdem keine direkte Verbindung zwischen Nutzern +- sauber erweiterbar für Android, iOS und Web + +Planungsentscheidung: + +- Node-Backend bleibt Orchestrator und Berechtigungsinstanz +- Video-Medienserver wird als eigene Komponente vorgesehen +- pro Videochat wird ein kurzlebiger Raum erzeugt +- Zutritt nur für die beiden durch den Chat verknüpften Nutzer + +## Produktlogik + +### 1. Sichtbarkeit + +Die Videochat-Aktion ist nur sichtbar, wenn: + +- `currentConversation` gesetzt ist +- beide Nutzer online bzw. erreichbar sind +- keine Blockierung aktiv ist +- beide Nutzer für genau diese Konversation `videoAllowed = true` gesetzt haben + +Empfehlung: + +- Freigabe nicht global pro Account, sondern pro Konversation speichern +- Freigabe explizit und widerrufbar machen +- Sichtbarkeit im Chat-Header und optional zusätzlich im Composer + +### 2. Verbindungsgrenze pro Nutzer + +Pro Nutzer dürfen maximal drei gleichzeitige Videoverbindungen aktiv sein. + +Wenn ein vierter Videochat gestartet oder angenommen werden soll: + +- lehnt der Server den Vorgang ab +- der anfragende Client erhält einen klaren Fehlerstatus +- die UI zeigt eine verständliche Meldung wie "Maximal drei Videoverbindungen gleichzeitig erlaubt" + +Die Begrenzung muss serverseitig erzwungen werden, nicht nur in der UI. + +### 3. Zustandsmodell pro Konversation + +Pro Benutzerpaar wird zusätzlicher Konversationszustand benötigt: + +- `localVideoConsent` +- `remoteVideoConsent` +- `videoVisible` +- `activeCallState` +- `incomingCall` +- `outgoingCall` +- `callRoomId` + +Empfohlene Server-Zustände: + +- `disabled` +- `local_allowed` +- `mutual_allowed` +- `ringing` +- `connecting` +- `active` +- `ended` + +Zusätzlich pro Nutzer: + +- `activeVideoConnectionCount` +- Liste aktiver Video-Sessions +- welches Video aktuell im Vordergrund ist + +### 4. Gesprächsablauf + +1. Nutzer A öffnet eine bestehende Konversation. +2. Nutzer A aktiviert "Videochat erlauben". +3. Nutzer B aktiviert dieselbe Freigabe. +4. Erst jetzt wird der Videochat-Button sichtbar. +5. Nutzer A startet einen Anruf. +6. Server sendet Einladung an Nutzer B. +7. Nutzer B nimmt an oder lehnt ab. +8. Bei Annahme erzeugt der Server einen kurzlebigen Call-Raum und gibt signierte Join-Daten an beide Clients zurück. +9. Beide Clients verbinden sich mit dem Medienserver. +10. Auflegen, Timeout oder Disconnect beendet Raum und Status. + +Wenn ein Nutzer bereits drei aktive Videoverbindungen hat, scheitert Schritt 5 oder 7 mit einem Serverfehler. + +## Anzeige- und Layoutkonzept + +### 1. Rechte Preview-Spalte + +Alle aktiven Videoverbindungen eines Nutzers werden rechts als Preview angezeigt. + +Vorgaben: + +- Position rechts im Browserfenster +- Breite etwa `1/5` des Browserfensters +- ganz oben ein festes Self-Preview des eigenen Video-Streams +- darunter maximal drei Video-Previews fremder aktiver Verbindungen +- damit insgesamt bis zu vier Previews sichtbar +- jede Preview zeigt mindestens: + - Videobild + - Name des Partners + - Mute-Zustand + - Status + - Aktion zum In-den-Vordergrund-Holen, außer beim Self-Preview + - Aktion zum Beenden + +Empfehlung: + +- eigener Container im Web-Layout, nicht im normalen Chat-Message-Flow +- auf kleineren Viewports responsiv umschalten, aber Desktop bleibt Referenz +- das Self-Preview ist rein informativ und kann nicht in den Vordergrund geholt werden + +### 2. Vordergrund-Video als schwebendes Fenster + +Ein aktives Video kann in den Vordergrund geholt werden. + +Vorgaben: + +- Darstellung als schwebendes Fenster +- frei verschiebbar +- über der normalen Chat-Oberfläche +- pro Zeitpunkt genau ein Vordergrundfenster +- Rückweg in die rechte Preview-Leiste möglich + +Das Vordergrundfenster sollte enthalten: + +- großes Videobild der ausgewählten Verbindung +- Name des Partners sichtbar im Fenster +- Mute-Zustand sichtbar im Fenster +- optional eigenes kleines Self-Preview +- Drag-Handle +- Schließen / minimieren +- Mute / Kamera aus +- Auflegen + +### 3. Zustände in der UI + +Die UI braucht zusätzlich zu den Call-States: + +- `videoDockSessions` +- `selfPreviewStream` +- `foregroundVideoSessionId` +- `floatingVideoPosition` +- `maxVideoConnectionsReached` + +## Technischer Zuschnitt + +## Phasenstatus + +- [x] Phase 1: Signaling und Zustände im Backend +- [x] Phase 2: Web-Client +- [x] Phase 3: Android +- [ ] Phase 4: Medienserver-Integration und End-to-End-Test + - [x] Server- und Web-Relaypfad via WebRTC mit Relay-Only-Signaling + - [x] Native Android-Medienebene + +### Phase 1: Signaling und Zustände im Backend + +Erweiterung von `server/broadcast.js` um neue Event-Familien: + +- `videoConsent:set` +- `videoConsent:update` +- `videoCall:invite` +- `videoCall:incoming` +- `videoCall:accept` +- `videoCall:reject` +- `videoCall:cancel` +- `videoCall:start` +- `videoCall:end` +- `videoCall:error` +- `videoCall:capacity` + +Zusätzliche In-Memory-Strukturen: + +- Konversations-Metadaten getrennt von `conversations` +- aktive Call-Sessions +- Mapping `conversationKey -> consent/call state` +- Mapping `userName -> aktive Video-Sessions` + +Wichtig: + +- Call-Zustand an stabile Benutzeridentitäten koppeln, nicht nur an Socket-IDs +- Blocklisten auch für Video-Einladungen durchsetzen +- bei Reconnect Call-Zustand defensiv neu synchronisieren +- Verbindungsobergrenze von drei aktiven Video-Sessions pro Nutzer serverseitig prüfen + +Status: + +- erledigt + +### Phase 2: Web-Client + +Im Web-Store `client/src/stores/chat.js` ergänzen: + +- Video-Consent-Status pro aktueller Konversation +- neue Socket-Listener für Video-Events +- Actions zum Setzen der Freigabe +- Actions zum Starten, Annehmen, Ablehnen und Beenden eines Calls +- UI-Flags für Ringing, Connecting, Active, Failed +- Verwaltung von bis zu drei parallelen aktiven Video-Sessions +- Auswahl, welches Video im Vordergrund schwebt +- Position des schwebenden Fensters +- Fehlerzustand für Kapazitätsgrenze + +UI-Einstiegspunkte: + +- `client/src/views/ChatView.vue` + - Sichtbarer Videochat-Button im Header nur bei `mutual_allowed` + - rechte Preview-Spalte für aktive Videos +- `client/src/components/ChatInput.vue` + - optionaler Toggle "Video erlauben" oder sekundärer Einstieg +- `client/src/components/ChatWindow.vue` + - Statusbanner für Einladung, Verbindungsaufbau, aktiv, beendet + +Zusätzliche neue Web-Komponente: + +- `client/src/components/VideoCallPanel.vue` + - lokales Vorschaufenster + - entferntes Video + - Annehmen / Ablehnen + - Kamera/Mikro muten + - Auflegen +- `client/src/components/VideoDock.vue` + - rechte Preview-Spalte mit festem Self-Preview plus bis zu drei Partner-Previews +- `client/src/components/FloatingVideoWindow.vue` + - verschiebbares Vordergrundfenster + +Status: + +- erledigt + +### Phase 3: Android + +Erweiterungen: + +- `android/app/src/main/java/de/ypchat/android/data/model/SocketEvent.kt` +- `android/app/src/main/java/de/ypchat/android/data/model/Models.kt` +- `android/app/src/main/java/de/ypchat/android/data/api/SocketClient.kt` +- `android/app/src/main/java/de/ypchat/android/data/repository/ChatRepository.kt` +- `android/app/src/main/java/de/ypchat/android/ui/YpChatRoot.kt` +- `android/app/src/main/java/de/ypchat/android/ui/ChatViewModel.kt` + +Benötigt werden: + +- neue Eventklassen für Consent und Call-Status +- Repository-State für Sichtbarkeit, Einladung und aktiven Call +- UI für Toggle, Rufannahme und laufenden Call +- UI für bis zu drei Previews und ein hervorgehobenes Vordergrundvideo +- Medienintegration über dieselbe server-relayte Architektur wie im Web + +Status: + +- erledigt + +### Phase 4: Medienserver-Integration und End-to-End-Test + +Umfang: + +- Auswahl und Einbindung der Relay-/SFU-Lösung +- Ausgabe serverseitiger Join-Daten für Calls +- Medienpfad an Web und Android anbinden +- End-to-End-Test aller Zustände +- Datenschutz- und Kapazitätsprüfungen abschließen + +Aktueller Stand: + +- Server und Web nutzen jetzt Relay-Only-WebRTC mit Signaling über Socket.IO +- tatsächliche Medien laufen nicht über Chat-Sockets +- Voraussetzung ist eine konfigurierte TURN-/Relay-Infrastruktur über Umgebungsvariablen +- Android nutzt jetzt ebenfalls den Relay-Only-WebRTC-Medienpfad mit nativer Laufzeit und Compose-Rendering +- offen bleibt vor allem der End-to-End-Test mit echter TURN-Konfiguration und Mehrgeräte-Verifikation + +Status: + +- offen + +Benötigte Umgebungsvariablen für den Relay-Betrieb: + +- `VIDEO_TURN_URLS` +- `VIDEO_TURN_USERNAME` +- `VIDEO_TURN_CREDENTIAL` +- optional `VIDEO_STUN_URLS` +- alternativ `VIDEO_ICE_SERVERS_JSON` als vollständige ICE-Serverliste + +## Server-seitige Persistenzentscheidung + +Für einen ersten Schritt kann der Consent-Zustand im RAM gehalten werden, analog zur aktuellen Chat-Architektur. + +Empfehlung für produktiven Ausbau: + +- Consent pro Benutzerpaar persistent speichern +- aktive Calls nur flüchtig speichern + +Begründung: + +- Sichtbarkeit des Video-Buttons soll nach Reconnect nicht zufällig verloren gehen +- aktive Calls dürfen bei Server-Neustart beendet werden, aber Consent sollte stabiler sein + +## Sicherheits- und Datenschutzregeln + +- Videochat nur für eingeloggte, aktive Chat-Teilnehmer +- keine Join-Daten ohne bestehende Konversation +- keine Join-Daten ohne gegenseitige Freigabe +- Blockierungen sperren auch Videochat +- Call-Räume sind kurzlebig und nur für zwei Teilnehmer gültig +- Tokens für Media-Join serverseitig signieren und kurz halten +- keine Offenlegung von Peer-IP-Adressen an Clients +- Logging nur auf Betriebsniveau, keine Speicherung von Medieninhalten + +## Offene Architekturentscheidung + +Vor der Implementierung sollte genau eine Medienstrategie festgelegt werden: + +### Empfohlene Richtung + +Server-relayter WebRTC-Videochat mit SFU. + +Warum: + +- erfüllt die Anforderung "keine Direktverbindung" +- ist für Web, Android und iOS gemeinsam nutzbar +- passt besser zu Echtzeit als Datei- oder Socket-Binary-Transfer + +### Nicht empfohlen + +- Video als Upload-/Download-Mechanik wie bei Bildern +- vollständiger Eigenbau eines Medienprotokolls über Socket.IO +- klassisches P2P-WebRTC mit STUN-only + +## Kritische Dateien für die spätere Umsetzung + +- `server/broadcast.js` +- `server/index.js` +- `client/src/stores/chat.js` +- `client/src/views/ChatView.vue` +- `client/src/components/ChatInput.vue` +- `client/src/components/ChatWindow.vue` +- `android/app/src/main/java/de/ypchat/android/data/model/SocketEvent.kt` +- `android/app/src/main/java/de/ypchat/android/data/model/Models.kt` +- `android/app/src/main/java/de/ypchat/android/data/api/SocketClient.kt` +- `android/app/src/main/java/de/ypchat/android/data/repository/ChatRepository.kt` +- `android/app/src/main/java/de/ypchat/android/ui/YpChatRoot.kt` +- `android/app/src/main/java/de/ypchat/android/ui/ChatViewModel.kt` + +## Verifikation + +### Funktional + +1. Zwei Nutzer bauen eine normale Konversation auf. +2. Nur ein Nutzer erlaubt Videochat. +3. Prüfen, dass keine Videochat-Aktion sichtbar ist. +4. Zweiter Nutzer erlaubt Videochat. +5. Prüfen, dass die Aktion jetzt bei beiden sichtbar ist. +6. Nutzer A startet einen Call. +7. Nutzer B erhält Einladung. +8. Nutzer B lehnt ab, Status muss sauber zurückspringen. +9. Nutzer A startet erneut, Nutzer B nimmt an. +10. Beide verbinden sich ausschließlich über den Server-Relay-Pfad. +11. Drei parallele Videoverbindungen für einen Nutzer aufbauen. +12. Vierte Verbindung starten oder annehmen und korrekte Fehlermeldung prüfen. +13. Prüfen, dass rechts ein Self-Preview plus maximal drei Partner-Previews sichtbar sind. +14. Prüfen, dass das Self-Preview nicht vergrößert werden kann. +15. Prüfen, dass unter jedem Partner-Preview der Name und der Mute-Zustand sichtbar sind. +16. Ein Partner-Preview in den Vordergrund holen und als schwebendes Fenster verschieben. +17. Prüfen, dass auch im großen Fenster Name und Mute-Zustand sichtbar sind. +18. Minimieren und Rückkehr in die Preview-Leiste prüfen. +19. Auflegen, Browser-Reload, App-Hintergrundwechsel und Reconnect prüfen. + +### Datenschutz + +1. Prüfen, dass keine direkte Peer-Verbindung aufgebaut wird. +2. Prüfen, dass nur Relay-/Server-Kandidaten verwendet werden. +3. Prüfen, dass Blockierungen auch Video-Einladungen unterbinden. + +### Regression + +1. Textnachrichten weiter senden/empfangen. +2. Bildversand weiter senden/empfangen. +3. History, Inbox und Conversation-Reload dürfen unverändert funktionieren. + +## Empfohlene Implementierungsreihenfolge + +1. Event- und Zustandsmodell für Consent und Call-Lifecycle definieren. +2. Backend-Signaling in `server/broadcast.js` ergänzen. +3. Web-Client vollständig integrieren und als Referenzfluss stabilisieren. +4. Danach Android und iOS auf denselben Event-Vertrag heben. +5. Erst dann Medienserver produktionsnah anbinden und End-to-End testen. diff --git a/server/broadcast.js b/server/broadcast.js index 0dd0015..26042e8 100644 --- a/server/broadcast.js +++ b/server/broadcast.js @@ -4,6 +4,59 @@ import { join } from 'path'; import axios from 'axios'; const TIMEOUT_SECONDS = 1800; // 30 Minuten +const MAX_ACTIVE_VIDEO_CONNECTIONS = 3; +const ACTIVE_VIDEO_SESSION_STATUSES = new Set(['ringing', 'connecting', 'active']); +const TERMINAL_VIDEO_SESSION_STATUSES = new Set(['rejected', 'cancelled', 'ended', 'failed']); +const VIDEO_CONNECTION_STATES = new Set(['new', 'connecting', 'connected', 'disconnected', 'failed', 'closed']); + +function parseDelimitedEnvList(rawValue) { + return String(rawValue || '') + .split(/[,\n]/) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function buildVideoIceServers() { + const jsonConfig = String(process.env.VIDEO_ICE_SERVERS_JSON || '').trim(); + if (jsonConfig) { + try { + const parsed = JSON.parse(jsonConfig); + return Array.isArray(parsed) ? parsed : []; + } catch (error) { + console.error('Ungültiges VIDEO_ICE_SERVERS_JSON:', error.message); + return []; + } + } + + const iceServers = []; + const stunUrls = parseDelimitedEnvList(process.env.VIDEO_STUN_URLS); + if (stunUrls.length > 0) { + iceServers.push({ urls: stunUrls }); + } + + const turnUrls = parseDelimitedEnvList(process.env.VIDEO_TURN_URLS); + const turnUsername = String(process.env.VIDEO_TURN_USERNAME || '').trim(); + const turnCredential = String(process.env.VIDEO_TURN_CREDENTIAL || '').trim(); + if (turnUrls.length > 0 && turnUsername && turnCredential) { + iceServers.push({ + urls: turnUrls, + username: turnUsername, + credential: turnCredential + }); + } + + return iceServers; +} + +const VIDEO_ICE_SERVERS = buildVideoIceServers(); +const VIDEO_MEDIA_RELAY_CONFIGURED = VIDEO_ICE_SERVERS.some((server) => { + const urls = Array.isArray(server?.urls) ? server.urls : [server?.urls]; + return urls.some((url) => String(url || '').startsWith('turn:') || String(url || '').startsWith('turns:')); +}); + +if (!VIDEO_MEDIA_RELAY_CONFIGURED) { + console.warn('Video-Medienpfad ist nicht vollständig konfiguriert. Setze VIDEO_TURN_URLS, VIDEO_TURN_USERNAME und VIDEO_TURN_CREDENTIAL.'); +} class Client { constructor(sessionId) { @@ -149,6 +202,8 @@ function parseLoginRecord(line) { let clients = new Map(); let conversations = new Map(); // Key: "user1:user2" (alphabetisch sortiert) +let videoConversations = new Map(); // Key: "user1:user2" +let videoSessions = new Map(); // Key: callId // Map: Socket-ID -> Express-Session-ID (für Session-Wiederherstellung) let socketToSessionMap = new Map(); @@ -222,6 +277,306 @@ function getConversationKey(user1, user2) { return [user1, user2].sort().join(':'); } +function isSocketConnected(socket) { + return !!(socket && socket.connected); +} + +function isClientOnline(client) { + return !!(client && client.userName && isSocketConnected(client.socket)); +} + +function getClientByUserName(userName) { + for (const client of clients.values()) { + if (client.userName === userName) { + return client; + } + } + return null; +} + +function getOrCreateVideoConversation(user1, user2) { + const users = [user1, user2].sort(); + const convKey = users.join(':'); + let state = videoConversations.get(convKey); + if (!state) { + state = { + convKey, + users, + consents: { + [users[0]]: false, + [users[1]]: false + }, + activeCallIds: new Set() + }; + videoConversations.set(convKey, state); + } else { + for (const user of users) { + if (typeof state.consents[user] !== 'boolean') { + state.consents[user] = false; + } + } + if (!(state.activeCallIds instanceof Set)) { + state.activeCallIds = new Set(Array.isArray(state.activeCallIds) ? state.activeCallIds : []); + } + } + return state; +} + +function getExistingVideoConversation(user1, user2) { + return videoConversations.get(getConversationKey(user1, user2)) || null; +} + +function getVideoConversationForRead(user1, user2) { + const existing = getExistingVideoConversation(user1, user2); + if (existing) { + return existing; + } + const users = [user1, user2].sort(); + return { + convKey: users.join(':'), + users, + consents: { + [users[0]]: false, + [users[1]]: false + }, + activeCallIds: new Set() + }; +} + +function removeVideoConversationIfEmpty(user1, user2) { + const convKey = getConversationKey(user1, user2); + const state = videoConversations.get(convKey); + if (!state) return; + + const hasConsent = Object.values(state.consents || {}).some(Boolean); + const hasActiveCalls = Array.from(state.activeCallIds || []).some((callId) => { + const session = videoSessions.get(callId); + return session && ACTIVE_VIDEO_SESSION_STATUSES.has(session.status); + }); + + if (!hasConsent && !hasActiveCalls) { + videoConversations.delete(convKey); + } +} + +function getOtherParticipant(session, userName) { + return session.participants.find((participant) => participant !== userName) || null; +} + +function buildVideoConsentPayload(viewerName, state) { + const otherUserName = state.users.find((user) => user !== viewerName) || null; + return { + withUserName: otherUserName, + localConsent: !!state.consents[viewerName], + remoteConsent: otherUserName ? !!state.consents[otherUserName] : false, + videoVisible: !!(otherUserName && state.consents[viewerName] && state.consents[otherUserName]) + }; +} + +function emitVideoConsentStateToUser(userName, otherUserName) { + const client = getClientByUserName(userName); + if (!isClientOnline(client)) return; + + const state = getVideoConversationForRead(userName, otherUserName); + client.socket.emit('videoConsent:update', buildVideoConsentPayload(userName, state)); +} + +function emitVideoConsentStateToParticipants(user1, user2) { + emitVideoConsentStateToUser(user1, user2); + emitVideoConsentStateToUser(user2, user1); +} + +function buildVideoCallPayloadForUser(session, userName) { + const otherUserName = getOtherParticipant(session, userName); + const muteStates = session.muteStates || {}; + const connectionStates = session.connectionStates || {}; + const includeMedia = session.status === 'connecting' || session.status === 'active'; + return { + callId: session.callId, + roomId: session.roomId, + withUserName: otherUserName, + initiatedBy: session.initiatedBy, + status: session.status, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + endedAt: session.endedAt || null, + reason: session.reason || null, + localMuted: !!muteStates[userName], + remoteMuted: otherUserName ? !!muteStates[otherUserName] : false, + connectionState: connectionStates[userName] || 'new', + remoteConnectionState: otherUserName ? (connectionStates[otherUserName] || 'new') : 'new', + media: includeMedia ? { + mode: 'webrtc-relay', + relayOnly: true, + iceTransportPolicy: 'relay', + iceServers: VIDEO_ICE_SERVERS, + isCaller: session.initiatedBy === userName + } : null + }; +} + +function emitVideoCallEventToUser(userName, eventName, session) { + const client = getClientByUserName(userName); + if (!isClientOnline(client)) return; + client.socket.emit(eventName, buildVideoCallPayloadForUser(session, userName)); +} + +function emitVideoCapacityToUser(userName) { + const client = getClientByUserName(userName); + if (!isClientOnline(client)) return; + const activeConnections = getActiveVideoSessionsForUser(userName).length; + client.socket.emit('videoCall:capacity', { + activeConnections, + maxConnections: MAX_ACTIVE_VIDEO_CONNECTIONS, + reachedMax: activeConnections >= MAX_ACTIVE_VIDEO_CONNECTIONS + }); +} + +function getActiveVideoSessionsForUser(userName) { + return Array.from(videoSessions.values()).filter((session) => ( + session.participants.includes(userName) && + ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) + )); +} + +function hasVideoCapacity(userName) { + return getActiveVideoSessionsForUser(userName).length < MAX_ACTIVE_VIDEO_CONNECTIONS; +} + +function findActiveVideoSessionBetween(user1, user2) { + return Array.from(videoSessions.values()).find((session) => ( + ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) && + session.participants.includes(user1) && + session.participants.includes(user2) + )) || null; +} + +function createVideoSession(callerUserName, calleeUserName) { + const now = new Date().toISOString(); + const callId = crypto.randomUUID(); + return { + callId, + roomId: `video-${callId}`, + participants: [callerUserName, calleeUserName], + initiatedBy: callerUserName, + status: 'ringing', + muteStates: { + [callerUserName]: false, + [calleeUserName]: false + }, + connectionStates: { + [callerUserName]: 'new', + [calleeUserName]: 'new' + }, + createdAt: now, + updatedAt: now, + endedAt: null, + reason: null + }; +} + +function touchVideoSession(session, status, reason = null) { + session.status = status; + session.updatedAt = new Date().toISOString(); + if (reason) { + session.reason = reason; + } + if (TERMINAL_VIDEO_SESSION_STATUSES.has(status)) { + session.endedAt = session.updatedAt; + } +} + +function registerVideoSession(session) { + videoSessions.set(session.callId, session); + const state = getOrCreateVideoConversation(session.participants[0], session.participants[1]); + state.activeCallIds.add(session.callId); + return state; +} + +function finalizeVideoSession(session) { + const state = getExistingVideoConversation(session.participants[0], session.participants[1]); + if (state) { + state.activeCallIds.delete(session.callId); + } + videoSessions.delete(session.callId); + removeVideoConversationIfEmpty(session.participants[0], session.participants[1]); +} + +function sendVideoCallError(socket, code, message, details = {}) { + socket.emit('videoCall:error', { + code, + message, + ...details + }); +} + +function emitVideoCallUpdateToParticipants(session) { + emitVideoCallEventToUser(session.participants[0], 'videoCall:update', session); + emitVideoCallEventToUser(session.participants[1], 'videoCall:update', session); +} + +function buildVideoSignalPayload(callId, fromUserName, signalType, details = {}) { + return { + callId, + fromUserName, + signalType, + ...details + }; +} + +function isRelayIceCandidate(candidate) { + if (!candidate || typeof candidate !== 'object') { + return false; + } + if (candidate.type) { + return String(candidate.type).toLowerCase() === 'relay'; + } + const candidateLine = String(candidate.candidate || ''); + return /\btyp relay\b/i.test(candidateLine); +} + +function endVideoSession(session, reason = 'ended', initiatorUserName = null) { + if (!session || TERMINAL_VIDEO_SESSION_STATUSES.has(session.status)) { + return; + } + + touchVideoSession(session, 'ended', reason); + for (const participant of session.participants) { + const payload = buildVideoCallPayloadForUser(session, participant); + if (initiatorUserName) { + payload.endedBy = initiatorUserName; + } + const client = getClientByUserName(participant); + if (isClientOnline(client)) { + client.socket.emit('videoCall:end', payload); + } + } + finalizeVideoSession(session); + for (const participant of session.participants) { + emitVideoCapacityToUser(participant); + } +} + +function endVideoSessionsForPair(user1, user2, reason = 'ended', initiatorUserName = null) { + for (const session of Array.from(videoSessions.values())) { + if ( + ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) && + session.participants.includes(user1) && + session.participants.includes(user2) + ) { + endVideoSession(session, reason, initiatorUserName); + } + } +} + +function endAllVideoSessionsForUser(userName, reason = 'ended', initiatorUserName = null) { + for (const session of Array.from(videoSessions.values())) { + if (ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) && session.participants.includes(userName)) { + endVideoSession(session, reason, initiatorUserName); + } + } +} + function logClientLogin(client, __dirname) { try { const logsDir = join(__dirname, '../logs'); @@ -808,6 +1163,7 @@ export function setupBroadcast(io, __dirname) { // Speichere Socket-ID mit Session-ID socket.data.sessionId = sessionId; + socketToSessionMap.set(socket.id, sessionId); let client = clients.get(sessionId); if (!client) { @@ -836,10 +1192,15 @@ export function setupBroadcast(io, __dirname) { socket.emit('connected', connectedData); socket.on('disconnect', (reason) => { - console.log(`[Disconnect] Socket getrennt für Session-ID: ${sessionId}, Grund: ${reason}`); - const client = clients.get(sessionId); + const currentSessionId = socket.data.sessionId || sessionId; + socketToSessionMap.delete(socket.id); + console.log(`[Disconnect] Socket getrennt für Session-ID: ${currentSessionId}, Grund: ${reason}`); + const client = clients.get(currentSessionId); if (client) { console.log(`[Disconnect] Client gefunden: ${client.userName || 'unbekannt'}, Socket war verbunden: ${client.socket ? client.socket.connected : 'null'}`); + if (client.userName) { + endAllVideoSessionsForUser(client.userName, 'disconnect', client.userName); + } // Setze Socket auf null, damit keine Nachrichten mehr an diesen Client gesendet werden // ABER: Lösche den Client NICHT, damit die Session beim Reload wiederhergestellt werden kann @@ -866,6 +1227,7 @@ export function setupBroadcast(io, __dirname) { if (expressSessionId) { console.log('setSessionId - Express-Session-ID erhalten:', expressSessionId); const currentSessionId = socket.data.sessionId; + socketToSessionMap.set(socket.id, expressSessionId); if (currentSessionId !== expressSessionId) { console.log('setSessionId - Aktualisiere Session-ID von', currentSessionId, 'zu', expressSessionId); @@ -921,6 +1283,7 @@ export function setupBroadcast(io, __dirname) { loggedIn: true, user: existingClient.toJSON() }); + emitVideoCapacityToUser(existingClient.userName); // Aktualisiere Userliste für alle Clients, damit der wiederhergestellte Client die Liste erhält broadcastUserList(); @@ -1065,6 +1428,141 @@ export function setupBroadcast(io, __dirname) { } }); + socket.on('videoConsent:set', async (data) => { + try { + const currentClient = clients.get(socket.data.sessionId); + if (!currentClient) { + socket.emit('error', { message: 'Client nicht gefunden' }); + return; + } + currentClient.setActivity(); + handleVideoConsentSet(socket, currentClient, data); + } catch (error) { + console.error('Fehler beim Verarbeiten von videoConsent:set:', error); + sendVideoCallError(socket, 'VIDEO_CONSENT_SET_FAILED', error.message); + } + }); + + socket.on('videoCall:invite', async (data) => { + try { + const currentClient = clients.get(socket.data.sessionId); + if (!currentClient) { + socket.emit('error', { message: 'Client nicht gefunden' }); + return; + } + currentClient.setActivity(); + handleVideoCallInvite(socket, currentClient, data); + } catch (error) { + console.error('Fehler beim Verarbeiten von videoCall:invite:', error); + sendVideoCallError(socket, 'VIDEO_INVITE_FAILED', error.message); + } + }); + + socket.on('videoCall:accept', async (data) => { + try { + const currentClient = clients.get(socket.data.sessionId); + if (!currentClient) { + socket.emit('error', { message: 'Client nicht gefunden' }); + return; + } + currentClient.setActivity(); + handleVideoCallAccept(socket, currentClient, data); + } catch (error) { + console.error('Fehler beim Verarbeiten von videoCall:accept:', error); + sendVideoCallError(socket, 'VIDEO_ACCEPT_FAILED', error.message); + } + }); + + socket.on('videoCall:reject', async (data) => { + try { + const currentClient = clients.get(socket.data.sessionId); + if (!currentClient) { + socket.emit('error', { message: 'Client nicht gefunden' }); + return; + } + currentClient.setActivity(); + handleVideoCallReject(socket, currentClient, data); + } catch (error) { + console.error('Fehler beim Verarbeiten von videoCall:reject:', error); + sendVideoCallError(socket, 'VIDEO_REJECT_FAILED', error.message); + } + }); + + socket.on('videoCall:cancel', async (data) => { + try { + const currentClient = clients.get(socket.data.sessionId); + if (!currentClient) { + socket.emit('error', { message: 'Client nicht gefunden' }); + return; + } + currentClient.setActivity(); + handleVideoCallCancel(socket, currentClient, data); + } catch (error) { + console.error('Fehler beim Verarbeiten von videoCall:cancel:', error); + sendVideoCallError(socket, 'VIDEO_CANCEL_FAILED', error.message); + } + }); + + socket.on('videoCall:end', async (data) => { + try { + const currentClient = clients.get(socket.data.sessionId); + if (!currentClient) { + socket.emit('error', { message: 'Client nicht gefunden' }); + return; + } + currentClient.setActivity(); + handleVideoCallEnd(socket, currentClient, data); + } catch (error) { + console.error('Fehler beim Verarbeiten von videoCall:end:', error); + sendVideoCallError(socket, 'VIDEO_END_FAILED', error.message); + } + }); + + socket.on('videoCall:muteState', async (data) => { + try { + const currentClient = clients.get(socket.data.sessionId); + if (!currentClient) { + socket.emit('error', { message: 'Client nicht gefunden' }); + return; + } + currentClient.setActivity(); + handleVideoCallMuteState(socket, currentClient, data); + } catch (error) { + console.error('Fehler beim Verarbeiten von videoCall:muteState:', error); + sendVideoCallError(socket, 'VIDEO_MUTE_STATE_FAILED', error.message); + } + }); + + socket.on('videoCall:signal', async (data) => { + try { + const currentClient = clients.get(socket.data.sessionId); + if (!currentClient) { + socket.emit('error', { message: 'Client nicht gefunden' }); + return; + } + currentClient.setActivity(); + handleVideoCallSignal(socket, currentClient, data); + } catch (error) { + console.error('Fehler beim Verarbeiten von videoCall:signal:', error); + sendVideoCallError(socket, 'VIDEO_SIGNAL_FAILED', error.message); + } + }); + + socket.on('videoCall:connectionState', async (data) => { + try { + const currentClient = clients.get(socket.data.sessionId); + if (!currentClient) { + socket.emit('error', { message: 'Client nicht gefunden' }); + return; + } + currentClient.setActivity(); + handleVideoCallConnectionState(socket, currentClient, data); + } catch (error) { + console.error('Fehler beim Verarbeiten von videoCall:connectionState:', error); + sendVideoCallError(socket, 'VIDEO_CONNECTION_STATE_FAILED', error.message); + } + }); + }); async function handleLogin(socket, client, data) { @@ -1154,6 +1652,7 @@ export function setupBroadcast(io, __dirname) { sessionId: client.sessionId, user: client.toJSON() }); + emitVideoCapacityToUser(client.userName); } function handleMessage(socket, client, data) { @@ -1305,6 +1804,322 @@ export function setupBroadcast(io, __dirname) { imageType: msg.imageType || null })) }); + emitVideoConsentStateToUser(client.userName, withUserName); + + const session = findActiveVideoSessionBetween(client.userName, withUserName); + if (session) { + socket.emit('videoCall:update', buildVideoCallPayloadForUser(session, client.userName)); + } + } + + function handleVideoConsentSet(socket, client, data) { + if (!client.userName) { + socket.emit('error', { message: 'Nicht eingeloggt' }); + return; + } + + const withUserName = String(data?.withUserName || '').trim(); + const allowed = !!data?.allowed; + + if (!withUserName || withUserName === client.userName) { + sendVideoCallError(socket, 'VIDEO_INVALID_PARTNER', 'Ungültiger Gesprächspartner.'); + return; + } + + const state = getOrCreateVideoConversation(client.userName, withUserName); + state.consents[client.userName] = allowed; + + if (!allowed) { + endVideoSessionsForPair(client.userName, withUserName, 'consent_revoked', client.userName); + } + + emitVideoConsentStateToParticipants(client.userName, withUserName); + } + + function assertCanUseVideoWith(socket, client, targetUserName) { + if (!client.userName) { + sendVideoCallError(socket, 'VIDEO_NOT_LOGGED_IN', 'Nicht eingeloggt.'); + return null; + } + + const targetClient = getClientByUserName(targetUserName); + if (!targetClient || !targetClient.userName) { + sendVideoCallError(socket, 'VIDEO_PARTNER_NOT_FOUND', 'Gesprächspartner nicht gefunden.', { withUserName: targetUserName }); + return null; + } + + if (!isClientOnline(targetClient)) { + sendVideoCallError(socket, 'VIDEO_PARTNER_OFFLINE', 'Gesprächspartner ist nicht online.', { withUserName: targetUserName }); + return null; + } + + if (targetClient.blockedUsers.has(client.userName)) { + sendVideoCallError(socket, 'VIDEO_BLOCKED_BY_PARTNER', 'Du wurdest von diesem Benutzer blockiert.', { withUserName: targetUserName }); + return null; + } + + if (client.blockedUsers.has(targetUserName)) { + sendVideoCallError(socket, 'VIDEO_PARTNER_BLOCKED', 'Du hast diesen Benutzer blockiert.', { withUserName: targetUserName }); + return null; + } + + const state = getOrCreateVideoConversation(client.userName, targetUserName); + if (!state.consents[client.userName] || !state.consents[targetUserName]) { + sendVideoCallError(socket, 'VIDEO_CONSENT_REQUIRED', 'Videochat ist erst nach beidseitiger Freigabe sichtbar.', { withUserName: targetUserName }); + return null; + } + + return { targetClient, state }; + } + + function handleVideoCallInvite(socket, client, data) { + const withUserName = String(data?.withUserName || '').trim(); + if (!withUserName || withUserName === client.userName) { + sendVideoCallError(socket, 'VIDEO_INVALID_PARTNER', 'Ungültiger Gesprächspartner.'); + return; + } + + if (!VIDEO_MEDIA_RELAY_CONFIGURED) { + sendVideoCallError(socket, 'VIDEO_MEDIA_NOT_CONFIGURED', 'Video-Medienserver ist derzeit nicht konfiguriert.'); + return; + } + + const check = assertCanUseVideoWith(socket, client, withUserName); + if (!check) return; + + if (!hasVideoCapacity(client.userName)) { + emitVideoCapacityToUser(client.userName); + sendVideoCallError(socket, 'VIDEO_CAPACITY_REACHED', 'Maximal drei Videoverbindungen gleichzeitig erlaubt.', { + withUserName, + activeConnections: getActiveVideoSessionsForUser(client.userName).length, + maxConnections: MAX_ACTIVE_VIDEO_CONNECTIONS + }); + return; + } + + if (!hasVideoCapacity(withUserName)) { + emitVideoCapacityToUser(withUserName); + sendVideoCallError(socket, 'VIDEO_PARTNER_CAPACITY_REACHED', 'Der Gesprächspartner hat bereits die maximale Anzahl an Videoverbindungen erreicht.', { + withUserName + }); + return; + } + + const existingSession = findActiveVideoSessionBetween(client.userName, withUserName); + if (existingSession) { + sendVideoCallError(socket, 'VIDEO_CALL_ALREADY_EXISTS', 'Für diesen Gesprächspartner existiert bereits ein laufender Videochat.', { + withUserName, + callId: existingSession.callId + }); + return; + } + + const session = createVideoSession(client.userName, withUserName); + registerVideoSession(session); + + emitVideoCallEventToUser(client.userName, 'videoCall:invite', session); + emitVideoCallEventToUser(withUserName, 'videoCall:incoming', session); + emitVideoCapacityToUser(client.userName); + emitVideoCapacityToUser(withUserName); + } + + function handleVideoCallAccept(socket, client, data) { + const callId = String(data?.callId || '').trim(); + const session = videoSessions.get(callId); + + if (!session || session.status !== 'ringing' || !session.participants.includes(client.userName)) { + sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); + return; + } + + if (session.initiatedBy === client.userName) { + sendVideoCallError(socket, 'VIDEO_CALL_INVALID_ACCEPT', 'Der Anrufer kann den eigenen Anruf nicht annehmen.', { callId }); + return; + } + + if (!VIDEO_MEDIA_RELAY_CONFIGURED) { + sendVideoCallError(socket, 'VIDEO_MEDIA_NOT_CONFIGURED', 'Video-Medienserver ist derzeit nicht konfiguriert.', { callId }); + return; + } + + const otherUserName = getOtherParticipant(session, client.userName); + const check = assertCanUseVideoWith(socket, client, otherUserName); + if (!check) { + touchVideoSession(session, 'failed', 'preconditions_failed'); + emitVideoCallEventToUser(session.participants[0], 'videoCall:end', session); + emitVideoCallEventToUser(session.participants[1], 'videoCall:end', session); + finalizeVideoSession(session); + emitVideoCapacityToUser(session.participants[0]); + emitVideoCapacityToUser(session.participants[1]); + return; + } + + touchVideoSession(session, 'connecting'); + emitVideoCallEventToUser(session.participants[0], 'videoCall:start', session); + emitVideoCallEventToUser(session.participants[1], 'videoCall:start', session); + emitVideoCapacityToUser(session.participants[0]); + emitVideoCapacityToUser(session.participants[1]); + } + + function handleVideoCallReject(socket, client, data) { + const callId = String(data?.callId || '').trim(); + const session = videoSessions.get(callId); + + if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) { + sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); + return; + } + + if (session.status !== 'ringing' || session.initiatedBy === client.userName) { + sendVideoCallError(socket, 'VIDEO_CALL_INVALID_REJECT', 'Nur der Angerufene kann einen klingelnden Anruf ablehnen.', { callId }); + return; + } + + touchVideoSession(session, 'rejected', 'rejected'); + emitVideoCallEventToUser(session.participants[0], 'videoCall:reject', session); + emitVideoCallEventToUser(session.participants[1], 'videoCall:reject', session); + finalizeVideoSession(session); + emitVideoCapacityToUser(session.participants[0]); + emitVideoCapacityToUser(session.participants[1]); + } + + function handleVideoCallCancel(socket, client, data) { + const callId = String(data?.callId || '').trim(); + const session = videoSessions.get(callId); + + if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) { + sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); + return; + } + + if (session.status !== 'ringing') { + sendVideoCallError(socket, 'VIDEO_CALL_INVALID_CANCEL', 'Nur klingelnde Anrufe können abgebrochen werden.', { callId }); + return; + } + + if (session.initiatedBy !== client.userName) { + sendVideoCallError(socket, 'VIDEO_CALL_INVALID_CANCEL', 'Nur der Anrufer kann einen klingelnden Anruf abbrechen.', { callId }); + return; + } + + touchVideoSession(session, 'cancelled', 'cancelled'); + emitVideoCallEventToUser(session.participants[0], 'videoCall:cancel', session); + emitVideoCallEventToUser(session.participants[1], 'videoCall:cancel', session); + finalizeVideoSession(session); + emitVideoCapacityToUser(session.participants[0]); + emitVideoCapacityToUser(session.participants[1]); + } + + function handleVideoCallEnd(socket, client, data) { + const callId = String(data?.callId || '').trim(); + const session = videoSessions.get(callId); + + if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) { + sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); + return; + } + + endVideoSession(session, 'ended', client.userName); + } + + function handleVideoCallMuteState(socket, client, data) { + const callId = String(data?.callId || '').trim(); + const muted = !!data?.muted; + const session = videoSessions.get(callId); + + if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) { + sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); + return; + } + + session.muteStates = session.muteStates || {}; + session.muteStates[client.userName] = muted; + session.updatedAt = new Date().toISOString(); + + emitVideoCallEventToUser(session.participants[0], 'videoCall:muteState', session); + emitVideoCallEventToUser(session.participants[1], 'videoCall:muteState', session); + } + + function handleVideoCallSignal(socket, client, data) { + const callId = String(data?.callId || '').trim(); + const signalType = String(data?.signalType || '').trim(); + const session = videoSessions.get(callId); + + if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) { + sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); + return; + } + + const otherUserName = getOtherParticipant(session, client.userName); + const targetClient = getClientByUserName(otherUserName); + if (!isClientOnline(targetClient)) { + endVideoSession(session, 'partner_disconnected', client.userName); + return; + } + + if (signalType === 'description') { + const description = data?.description; + const descriptionType = String(description?.type || '').trim(); + const sdp = String(description?.sdp || '').trim(); + if (!descriptionType || !sdp || !['offer', 'answer'].includes(descriptionType)) { + sendVideoCallError(socket, 'VIDEO_SIGNAL_INVALID_DESCRIPTION', 'Ungültige Video-Signalisierung.', { callId }); + return; + } + + targetClient.socket.emit('videoCall:signal', buildVideoSignalPayload(callId, client.userName, signalType, { + description: { + type: descriptionType, + sdp + } + })); + return; + } + + if (signalType === 'candidate') { + const candidate = data?.candidate; + if (!isRelayIceCandidate(candidate)) { + sendVideoCallError(socket, 'VIDEO_SIGNAL_NON_RELAY_CANDIDATE', 'Nur Relay-Kandidaten sind für Videochat erlaubt.', { callId }); + return; + } + targetClient.socket.emit('videoCall:signal', buildVideoSignalPayload(callId, client.userName, signalType, { candidate })); + return; + } + + sendVideoCallError(socket, 'VIDEO_SIGNAL_UNSUPPORTED', 'Nicht unterstütztes Video-Signalisierungsformat.', { callId }); + } + + function handleVideoCallConnectionState(socket, client, data) { + const callId = String(data?.callId || '').trim(); + const nextState = String(data?.connectionState || '').trim().toLowerCase(); + const session = videoSessions.get(callId); + + if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) { + sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); + return; + } + + if (!VIDEO_CONNECTION_STATES.has(nextState)) { + sendVideoCallError(socket, 'VIDEO_CONNECTION_STATE_INVALID', 'Ungültiger Video-Verbindungsstatus.', { callId }); + return; + } + + session.connectionStates = session.connectionStates || {}; + session.connectionStates[client.userName] = nextState; + session.updatedAt = new Date().toISOString(); + + if (nextState === 'failed' || nextState === 'closed') { + endVideoSession(session, 'media_connection_failed', client.userName); + return; + } + + if ( + session.status === 'connecting' && + session.participants.every((participant) => session.connectionStates[participant] === 'connected') + ) { + touchVideoSession(session, 'active'); + } + + emitVideoCallUpdateToParticipants(session); } function handleUserSearch(socket, client, data) { @@ -1406,6 +2221,9 @@ export function setupBroadcast(io, __dirname) { const { userName } = data; client.blockedUsers.add(userName); + getOrCreateVideoConversation(client.userName, userName).consents[client.userName] = false; + endVideoSessionsForPair(client.userName, userName, 'blocked', client.userName); + emitVideoConsentStateToParticipants(client.userName, userName); socket.emit('userBlocked', { userName @@ -1420,6 +2238,7 @@ export function setupBroadcast(io, __dirname) { const { userName } = data; client.blockedUsers.delete(userName); + emitVideoConsentStateToParticipants(client.userName, userName); socket.emit('userUnblocked', { userName @@ -1488,6 +2307,12 @@ export function setupBroadcast(io, __dirname) { for (const [sid, client] of clients.entries()) { if (client.activitiesTimedOut()) { console.log(`Client ${client.userName} hat Timeout erreicht`); + if (client.userName) { + endAllVideoSessionsForUser(client.userName, 'timeout', client.userName); + } + if (client.socket?.id) { + socketToSessionMap.delete(client.socket.id); + } clients.delete(sid); broadcastUserList(); }