videochat integriert
All checks were successful
Deploy SingleChat / deploy (push) Successful in 27s

This commit is contained in:
Torsten Schulz (local)
2026-06-17 12:53:03 +02:00
parent 8c9a600645
commit 10e6e7a80a
22 changed files with 4443 additions and 510 deletions

View File

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

View File

@@ -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
```

View File

@@ -1,6 +1,6 @@
# YPChat Android
Native Android-App fuer den bestehenden SingleChat/YPChat-Server.
Native Android-App fuer den bestehenden YPChat-Server.
## Stack

View File

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

View File

@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:name=".YpChatApp"

View File

@@ -4,6 +4,7 @@ import android.content.Context
import de.ypchat.android.data.api.RestApi
import de.ypchat.android.data.api.SocketClient
import de.ypchat.android.data.repository.ChatRepository
import de.ypchat.android.media.AndroidVideoCallManager
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
@@ -28,5 +29,6 @@ class AppContainer(context: Context) {
val restApi: RestApi = retrofit.create(RestApi::class.java)
val socketClient = SocketClient(AppConfig.baseUrl, okHttpClient)
val chatRepository = ChatRepository(restApi, socketClient, cookieJar, profileStore)
val videoCallManager = AndroidVideoCallManager(context, socketClient)
val chatRepository = ChatRepository(restApi, socketClient, cookieJar, profileStore, videoCallManager)
}

View File

@@ -15,6 +15,14 @@ import de.ypchat.android.data.model.HistoryItemDto
import de.ypchat.android.data.model.InboxItemDto
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.data.model.VideoIceCandidateDto
import de.ypchat.android.data.model.VideoIceServerDto
import de.ypchat.android.data.model.VideoMediaDto
import de.ypchat.android.data.model.VideoSessionDescriptionDto
import de.ypchat.android.data.model.VideoSignalDto
import okhttp3.OkHttpClient
import org.json.JSONArray
import org.json.JSONObject
@@ -113,6 +121,51 @@ class SocketClient(
s.on("unreadChats") { args ->
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<String> {
if (this == null) return emptyList()
return List(length()) { index -> opt(index)?.toString().orEmpty() }

View File

@@ -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<VideoIceServerDto> = emptyList(),
val isCaller: Boolean = false
)
data class VideoIceServerDto(
val urls: List<String> = 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,

View File

@@ -11,6 +11,18 @@ sealed interface SocketEvent {
data class HistoryResults(val results: List<HistoryItemDto>) : SocketEvent
data class InboxResults(val results: List<InboxItemDto>) : 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<String>, val kind: String) : SocketEvent

View File

@@ -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<ChatState> = _state.asStateFlow()
val videoMediaState: StateFlow<VideoMediaState> = 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<String>,
@@ -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<VideoSessionState> = 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
)
}
}

View File

@@ -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<String, VideoTrack> = 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<String, PeerConnection>()
private val pendingIceCandidates = ConcurrentHashMap<String, MutableList<IceCandidate>>()
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<VideoMediaState> = _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<out IceCandidate>?) = 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<out MediaStream>?) {
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<VideoIceServerDto>.toNativeIceServers(): List<PeerConnection.IceServer> = 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)

View File

@@ -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<ChatState> = repository.state
val videoMediaState: StateFlow<VideoMediaState> = 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

View File

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

View File

@@ -52,6 +52,33 @@
<string name="button_camera">Photo</string>
<string name="button_send">Send</string>
<string name="button_smileys">Smileys</string>
<string name="button_video_allow">Allow video</string>
<string name="button_video_allowed">Video allowed</string>
<string name="button_video_open">Open video chat</string>
<string name="button_video_foreground">Bring forward</string>
<string name="button_video_accept">Accept</string>
<string name="button_video_reject">Reject</string>
<string name="button_video_cancel">Cancel</string>
<string name="button_video_end">End</string>
<string name="button_video_minimize">Minimize</string>
<string name="button_video_mute">Mute microphone</string>
<string name="button_video_unmute">Unmute microphone</string>
<string name="button_video_camera_off">Camera off</string>
<string name="button_video_camera_on">Camera on</string>
<string name="video_status_partner_allowed">Partner allowed video</string>
<string name="video_status_partner_pending">Partner has not allowed video yet</string>
<string name="video_status_capacity_reached">Maximum of three video connections allowed</string>
<string name="video_self_preview">You</string>
<string name="video_self_preview_camera_off">Camera inactive</string>
<string name="video_mic_on">Microphone on</string>
<string name="video_mic_off">Microphone off</string>
<string name="video_partner_mic_on">Partner: microphone on</string>
<string name="video_partner_mic_off">Partner: microphone off</string>
<string name="video_self_mic_on">You: microphone on</string>
<string name="video_self_mic_off">You: microphone off</string>
<string name="video_status_ringing">Ringing</string>
<string name="video_status_connecting">Connecting</string>
<string name="video_status_active">Active</string>
<string name="image_message">Image message</string>
<string name="image_upload_in_progress">Uploading image...</string>
<string name="image_upload_success">Image uploaded.</string>
@@ -65,6 +92,10 @@
<string name="countries_load_error">Country list could not be loaded: %1$s</string>
<string name="user_blocked">%1$s has been blocked</string>
<string name="user_unblocked">%1$s has been unblocked</string>
<string name="video_consent_required">Video chat becomes visible after both users allow it.</string>
<string name="video_capacity_reached">Maximum of three video connections allowed.</string>
<string name="video_partner_capacity_reached">The partner already reached the maximum number of video connections.</string>
<string name="video_call_exists">A video chat with this partner already exists.</string>
<string name="feedback_title">Feedback</string>
<string name="feedback_comment">Comment</string>
<string name="feedback_send">Send feedback</string>

View File

@@ -25,6 +25,46 @@
</div>
<div v-else class="messages-container">
<div v-if="currentVideoSession" class="video-call-banner">
<div class="video-call-banner-copy">
<strong>{{ currentVideoSession.withUserName }}</strong>
<span>{{ statusLabel(currentVideoSession.status) }} · {{ currentVideoSession.remoteMuted ? 'Mikro aus' : 'Mikro an' }}</span>
</div>
<div class="video-call-banner-actions">
<button
v-if="currentVideoSession.status === 'ringing' && currentVideoSession.initiatedBy !== chatStore.userName"
type="button"
@click="chatStore.acceptVideoCall(currentVideoSession.callId)"
>
Annehmen
</button>
<button
v-if="currentVideoSession.status === 'ringing' && currentVideoSession.initiatedBy !== chatStore.userName"
type="button"
class="danger"
@click="chatStore.rejectVideoCall(currentVideoSession.callId)"
>
Ablehnen
</button>
<button
v-if="currentVideoSession.status === 'ringing' && currentVideoSession.initiatedBy === chatStore.userName"
type="button"
class="secondary"
@click="chatStore.cancelVideoCall(currentVideoSession.callId)"
>
Abbrechen
</button>
<button
v-if="currentVideoSession.status === 'connecting' || currentVideoSession.status === 'active'"
type="button"
class="secondary"
@click="chatStore.bringVideoSessionToFront(currentVideoSession.callId)"
>
Vordergrund
</button>
</div>
</div>
<div
v-for="(message, index) in chatStore.messages"
:key="index"
@@ -41,25 +81,25 @@
/>
</span>
<span v-else v-html="replaceSmileys(message.message)"></span>
</div>
<!-- Bild-Modal -->
<div v-if="selectedImage" class="image-modal-overlay" @click="closeImageModal">
<div class="image-modal-content" @click.stop>
<button class="image-modal-close" @click="closeImageModal" title="Schließen">×</button>
<img :src="selectedImage" alt="Vergrößertes Bild" class="image-modal-image" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useChatStore } from '../stores/chat';
const chatStore = useChatStore();
const selectedImage = ref(null);
const currentVideoSession = computed(() => chatStore.currentConversationVideoSession);
function openImageModal(imageSrc) {
selectedImage.value = imageSrc;
@@ -69,7 +109,6 @@ function closeImageModal() {
selectedImage.value = null;
}
// Smiley-Definitionen (wie im Original)
const smileys = {
':)': { code: '1F642' },
':D': { code: '1F600' },
@@ -96,15 +135,12 @@ const smileys = {
function replaceSmileys(text) {
if (!text) return '';
// HTML-Sonderzeichen escapen
let outputText = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Smileys ersetzen (längere Codes zuerst, um Überschneidungen zu vermeiden)
const sortedCodes = Object.keys(smileys).sort((a, b) => b.length - a.length);
for (const code of sortedCodes) {
const regex = new RegExp(escapeRegex(code), 'g');
outputText = outputText.replace(regex, `&#x${smileys[code].code};`);
@@ -121,6 +157,19 @@ function formatTime(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
function statusLabel(status) {
switch (status) {
case 'ringing':
return 'Videoanruf klingelt';
case 'connecting':
return 'Videoanruf verbindet';
case 'active':
return 'Videoanruf aktiv';
default:
return status;
}
}
</script>
<style scoped>
@@ -212,15 +261,63 @@ function formatTime(timestamp) {
font-size: 11px;
}
@media (max-width: 620px) {
.empty-stats {
grid-template-columns: 1fr;
}
}
.messages-container {
display: flex;
flex-direction: column;
gap: 10px;
}
.video-call-banner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid #d8e1da;
border-radius: 10px;
background: #f7fbf8;
}
.video-call-banner-copy {
display: flex;
flex-direction: column;
gap: 3px;
}
.video-call-banner-copy strong {
color: #223026;
}
.video-call-banner-copy span {
color: #58685d;
font-size: 13px;
}
.video-call-banner-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.video-call-banner-actions button {
min-height: 34px;
border: 0;
border-radius: 8px;
padding: 0 12px;
background: #1d6a42;
color: #ffffff;
font-weight: 700;
cursor: pointer;
}
.video-call-banner-actions button.secondary {
background: #edf2ee;
color: #1d6a42;
border: 1px solid #ccd9cf;
}
.video-call-banner-actions button.danger {
background: #b03737;
}
.chat-image {
@@ -298,4 +395,15 @@ function formatTime(timestamp) {
object-fit: contain;
border-radius: 4px;
}
@media (max-width: 620px) {
.empty-stats {
grid-template-columns: 1fr;
}
.video-call-banner {
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@@ -0,0 +1,240 @@
<template>
<div
v-if="session"
class="floating-video-window"
:style="windowStyle"
>
<header class="floating-video-header" @mousedown="startDrag">
<div class="floating-video-title">
<strong>{{ session.withUserName }}</strong>
<span>{{ session.remoteMuted ? 'Mikro aus' : 'Mikro an' }}</span>
</div>
<div class="floating-video-header-actions">
<button type="button" class="secondary" @click="chatStore.minimizeForegroundVideo()">Minimieren</button>
<button type="button" class="danger" @click="chatStore.endVideoCall(session.callId)">Beenden</button>
</div>
</header>
<div class="floating-video-body">
<div class="floating-video-stage">
<VideoSessionSurface v-if="session" :session="session" :muted="false" />
</div>
<div class="floating-self-preview">
<video ref="selfVideoRef" autoplay muted playsinline></video>
</div>
</div>
<footer class="floating-video-footer">
<div class="floating-video-footer-state">
<span>{{ session.remoteMuted ? 'Partner: Mikro aus' : 'Partner: Mikro an' }}</span>
<span>{{ chatStore.selfMuted ? 'Du: Mikro aus' : 'Du: Mikro an' }}</span>
</div>
<div class="floating-video-footer-actions">
<button type="button" @click="chatStore.toggleSelfMute()">
{{ chatStore.selfMuted ? 'Mikro aktivieren' : 'Mikro stummschalten' }}
</button>
<button type="button" class="secondary" @click="chatStore.toggleSelfCamera()">
{{ chatStore.selfCameraEnabled ? 'Kamera aus' : 'Kamera an' }}
</button>
</div>
</footer>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useChatStore } from '../stores/chat';
import VideoSessionSurface from './VideoSessionSurface.vue';
const chatStore = useChatStore();
const session = computed(() => chatStore.foregroundVideoSession);
const selfVideoRef = ref(null);
const windowStyle = computed(() => ({
left: `${chatStore.floatingVideoPosition.x}px`,
top: `${chatStore.floatingVideoPosition.y}px`
}));
watch(
() => chatStore.selfPreviewStream,
(stream) => {
if (selfVideoRef.value) {
selfVideoRef.value.srcObject = stream || null;
}
},
{ immediate: true }
);
function statusLabel(status) {
switch (status) {
case 'ringing':
return 'Klingelt';
case 'connecting':
return 'Verbindet';
case 'active':
return 'Aktiv';
default:
return status;
}
}
function startDrag(event) {
const startX = event.clientX;
const startY = event.clientY;
const { x, y } = chatStore.floatingVideoPosition;
const handleMove = (moveEvent) => {
chatStore.updateFloatingVideoPosition({
x: x + (moveEvent.clientX - startX),
y: y + (moveEvent.clientY - startY)
});
};
const handleUp = () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleUp);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
}
onBeforeUnmount(() => {
if (selfVideoRef.value) {
selfVideoRef.value.srcObject = null;
}
});
</script>
<style scoped>
.floating-video-window {
position: fixed;
z-index: 1300;
width: min(46vw, 720px);
min-width: 340px;
border-radius: 16px;
border: 1px solid #cad5ce;
background: #ffffff;
box-shadow: 0 24px 60px rgba(10, 19, 14, 0.28);
overflow: hidden;
}
.floating-video-header {
min-height: 58px;
padding: 0 14px;
display: flex;
align-items: center;
justify-content: space-between;
background: #1a211d;
color: #eef5f0;
cursor: move;
}
.floating-video-title {
display: flex;
flex-direction: column;
gap: 2px;
}
.floating-video-title strong {
font-size: 16px;
}
.floating-video-title span {
font-size: 12px;
opacity: 0.82;
}
.floating-video-header-actions,
.floating-video-footer-actions {
display: flex;
gap: 8px;
}
.floating-video-header-actions button,
.floating-video-footer-actions button {
min-height: 34px;
border: 0;
border-radius: 8px;
padding: 0 12px;
font-weight: 700;
cursor: pointer;
}
.floating-video-header-actions button.secondary,
.floating-video-footer-actions button.secondary {
background: #edf2ee;
color: #214f36;
}
.floating-video-header-actions button.danger {
background: #b13838;
color: #ffffff;
}
.floating-video-body {
position: relative;
background: #08100b;
}
.floating-video-stage {
aspect-ratio: 16 / 9;
position: relative;
}
.floating-self-preview {
position: absolute;
right: 18px;
bottom: 18px;
width: min(24%, 150px);
aspect-ratio: 4 / 3;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.25);
background: #16211a;
}
.floating-self-preview video {
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1);
}
.floating-video-footer {
padding: 12px 14px 14px;
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.floating-video-footer-state {
display: flex;
flex-direction: column;
gap: 3px;
color: #4e5d53;
font-size: 13px;
}
.floating-video-footer-actions button {
background: #1d6a42;
color: #ffffff;
}
@media (max-width: 860px) {
.floating-video-window {
width: calc(100vw - 24px);
min-width: 0;
left: 12px !important;
top: 12px !important;
}
.floating-video-footer {
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@@ -0,0 +1,204 @@
<template>
<aside v-if="chatStore.hasVideoSessions" class="video-dock">
<section class="video-dock-card video-dock-card-self">
<div class="video-card-frame video-card-frame-self">
<video ref="selfVideoRef" autoplay muted playsinline></video>
<div v-if="!chatStore.selfPreviewStream" class="video-card-placeholder">
<strong>Eigene Vorschau</strong>
<span>Kamera nicht aktiv</span>
</div>
</div>
<div class="video-card-meta">
<strong>Du</strong>
<span>{{ chatStore.selfMuted ? 'Mikro aus' : 'Mikro an' }}</span>
</div>
</section>
<section
v-for="session in chatStore.dockVideoSessions"
:key="session.callId"
class="video-dock-card"
>
<div class="video-card-frame">
<VideoSessionSurface :session="session" :muted="true" />
</div>
<div class="video-card-meta">
<strong>{{ session.withUserName }}</strong>
<span>{{ session.remoteMuted ? 'Mikro aus' : 'Mikro an' }}</span>
</div>
<div class="video-card-actions">
<button type="button" @click="chatStore.bringVideoSessionToFront(session.callId)">
In den Vordergrund
</button>
<button
v-if="session.status === 'ringing' && session.initiatedBy !== chatStore.userName"
type="button"
class="secondary"
@click="chatStore.acceptVideoCall(session.callId)"
>
Annehmen
</button>
<button
v-else-if="session.status === 'ringing' && session.initiatedBy === chatStore.userName"
type="button"
class="secondary"
@click="chatStore.cancelVideoCall(session.callId)"
>
Abbrechen
</button>
<button
v-else-if="session.status === 'connecting' || session.status === 'active'"
type="button"
class="secondary"
@click="chatStore.endVideoCall(session.callId)"
>
Beenden
</button>
<button
v-if="session.status === 'ringing' && session.initiatedBy !== chatStore.userName"
type="button"
class="danger"
@click="chatStore.rejectVideoCall(session.callId)"
>
Ablehnen
</button>
</div>
</section>
</aside>
</template>
<script setup>
import { onBeforeUnmount, ref, watch } from 'vue';
import { useChatStore } from '../stores/chat';
import VideoSessionSurface from './VideoSessionSurface.vue';
const chatStore = useChatStore();
const selfVideoRef = ref(null);
watch(
() => chatStore.selfPreviewStream,
(stream) => {
if (selfVideoRef.value) {
selfVideoRef.value.srcObject = stream || null;
}
},
{ immediate: true }
);
onBeforeUnmount(() => {
if (selfVideoRef.value) {
selfVideoRef.value.srcObject = null;
}
});
function statusLabel(status) {
switch (status) {
case 'ringing':
return 'Klingelt';
case 'connecting':
return 'Verbindet';
case 'active':
return 'Aktiv';
default:
return status;
}
}
</script>
<style scoped>
.video-dock {
width: min(20vw, 320px);
min-width: 220px;
max-width: 320px;
padding: 12px;
border-left: 1px solid #dfe6e1;
background: linear-gradient(180deg, #f5f8f6 0%, #edf3ef 100%);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.video-dock-card {
border: 1px solid #d5dfd8;
border-radius: 12px;
background: #ffffff;
overflow: hidden;
box-shadow: 0 10px 20px rgba(25, 39, 31, 0.07);
}
.video-card-frame {
aspect-ratio: 16 / 10;
background: #0e1511;
position: relative;
}
.video-card-frame video {
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1);
}
.video-card-frame-self video {
object-fit: cover;
}
.video-card-meta {
padding: 10px 12px 6px;
display: flex;
flex-direction: column;
gap: 3px;
}
.video-card-meta strong {
color: #1d2821;
font-size: 14px;
}
.video-card-meta span {
color: #59685e;
font-size: 12px;
}
.video-card-actions {
padding: 0 12px 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.video-card-actions button {
min-height: 34px;
border: 0;
border-radius: 8px;
padding: 0 10px;
background: #1d6a42;
color: #ffffff;
font-weight: 700;
cursor: pointer;
}
.video-card-actions button.secondary {
background: #edf2ee;
color: #1d6a42;
border: 1px solid #c9d7cd;
}
.video-card-actions button.danger {
background: #a23333;
}
@media (max-width: 1100px) {
.video-dock {
width: 240px;
min-width: 240px;
}
}
@media (max-width: 860px) {
.video-dock {
display: none;
}
}
</style>

View File

@@ -0,0 +1,102 @@
<template>
<div class="video-surface">
<video
v-show="remoteStream"
ref="videoRef"
autoplay
playsinline
:muted="muted"
></video>
<div v-if="!remoteStream" class="video-surface-placeholder">
<strong>{{ session.withUserName }}</strong>
<span>{{ placeholderText }}</span>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useChatStore } from '../stores/chat';
const props = defineProps({
session: {
type: Object,
required: true
},
muted: {
type: Boolean,
default: false
}
});
const chatStore = useChatStore();
const videoRef = ref(null);
const remoteStream = computed(() => chatStore.getRemoteStream(props.session.callId));
const placeholderText = computed(() => {
switch (props.session.status) {
case 'ringing':
return 'Klingelt';
case 'connecting':
return 'Verbindet';
case 'active':
return 'Warte auf Videobild';
default:
return props.session.status || 'Verbinde';
}
});
watch(
remoteStream,
(stream) => {
if (videoRef.value) {
videoRef.value.srcObject = stream || null;
}
},
{ immediate: true }
);
onBeforeUnmount(() => {
if (videoRef.value) {
videoRef.value.srcObject = null;
}
});
</script>
<style scoped>
.video-surface {
width: 100%;
height: 100%;
position: relative;
background: #09110d;
}
.video-surface video {
width: 100%;
height: 100%;
object-fit: cover;
background: #09110d;
}
.video-surface-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #f7fff9;
gap: 6px;
text-align: center;
padding: 14px;
}
.video-surface-placeholder strong {
font-size: 17px;
}
.video-surface-placeholder span {
font-size: 13px;
opacity: 0.84;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -108,6 +108,36 @@
<span v-if="currentUserInfo">{{ currentUserInfo.age }} · {{ currentUserInfo.gender }}</span>
</div>
</div>
<div class="chat-header-actions">
<button
type="button"
class="video-toggle-button"
:class="{ 'is-active': chatStore.videoConsent.localConsent }"
@click="chatStore.setVideoConsent(!chatStore.videoConsent.localConsent)"
>
{{ chatStore.videoConsent.localConsent ? 'Video erlaubt' : 'Video erlauben' }}
</button>
<button
v-if="chatStore.videoConsent.videoVisible"
type="button"
class="video-call-button"
:disabled="!chatStore.canStartVideoCall"
@click="chatStore.inviteVideoCall()"
>
Videochat öffnen
</button>
</div>
</div>
<div v-if="chatStore.currentConversation" class="chat-video-status">
<span>
{{ chatStore.videoConsent.remoteConsent ? 'Partner hat Video freigegeben' : 'Partner hat Video noch nicht freigegeben' }}
</span>
<span v-if="chatStore.maxVideoConnectionsReached" class="chat-video-status-error">
Maximal drei Videoverbindungen gleichzeitig erlaubt
</span>
<span v-else-if="chatStore.currentConversationVideoSession">
{{ videoStatusLabel(chatStore.currentConversationVideoSession.status) }}
</span>
</div>
<HeaderAdBanner v-if="chatStore.currentConversation" />
<ChatWindow />
@@ -115,9 +145,11 @@
<ChatInput />
</div>
</div>
<VideoDock />
</div>
</section>
</div>
<FloatingVideoWindow />
<ImprintContainer />
</template>
@@ -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 '';
}
}
</script>
<style scoped>
@@ -358,4 +405,92 @@ onMounted(async () => {
position: sticky;
top: 0;
}
.horizontal-box-app {
align-items: stretch;
}
.chat-content {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.chat-header {
display: flex;
align-items: center;
gap: 12px;
}
.chat-header-main {
flex: 1;
min-width: 0;
}
.chat-header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.chat-header-actions button {
min-height: 38px;
border: 0;
border-radius: 9px;
padding: 0 14px;
font-weight: 800;
cursor: pointer;
}
.video-toggle-button {
background: #edf2ee;
color: #265437;
border: 1px solid #c8d6cd;
}
.video-toggle-button.is-active {
background: #dff0e5;
color: #1c6037;
}
.video-call-button {
background: #1d6a42;
color: #ffffff;
}
.video-call-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.chat-video-status {
margin: 10px 0 14px;
border: 1px solid #d8e0da;
border-radius: 10px;
padding: 10px 12px;
background: #f8fbf9;
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
color: #516257;
font-size: 13px;
}
.chat-video-status-error {
color: #9f2c2c;
font-weight: 700;
}
@media (max-width: 860px) {
.chat-header {
flex-wrap: wrap;
}
.chat-header-actions {
width: 100%;
justify-content: flex-start;
}
}
</style>

View File

@@ -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.

View File

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