This commit is contained in:
10
ADSENSE.md
10
ADSENSE.md
@@ -6,11 +6,11 @@ Im Header kann ein Google-AdSense-Banner eingeblendet werden. Die Einbindung ist
|
||||
|
||||
## Bereits im Code vorbereitet
|
||||
|
||||
- Header-Komponente: [HeaderAdBanner.vue](/mnt/share/torsten/Programs/SingleChat/client/src/components/HeaderAdBanner.vue)
|
||||
- Header-Komponente: [HeaderAdBanner.vue](/mnt/share/torsten/Programs/YpChat/client/src/components/HeaderAdBanner.vue)
|
||||
- Einbindung in die Kopfzeilen:
|
||||
- [ChatView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/ChatView.vue)
|
||||
- [PartnersView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/PartnersView.vue)
|
||||
- [FeedbackView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/FeedbackView.vue)
|
||||
- [ChatView.vue](/mnt/share/torsten/Programs/YpChat/client/src/views/ChatView.vue)
|
||||
- [PartnersView.vue](/mnt/share/torsten/Programs/YpChat/client/src/views/PartnersView.vue)
|
||||
- [FeedbackView.vue](/mnt/share/torsten/Programs/YpChat/client/src/views/FeedbackView.vue)
|
||||
|
||||
Aktiv wird der Banner nur mit:
|
||||
|
||||
@@ -56,7 +56,7 @@ Wichtig:
|
||||
- die Datei muss öffentlich unter `https://ypchat.net/ads.txt` erreichbar sein
|
||||
- Änderungen brauchen oft etwas Zeit, bis Google sie erkennt
|
||||
|
||||
Im Projekt liegt aktuell eine Datei unter [docroot/ads.txt](/mnt/share/torsten/Programs/SingleChat/docroot/ads.txt). Diese muss auf deine echte Publisher-ID geprüft und ggf. angepasst werden.
|
||||
Im Projekt liegt aktuell eine Datei unter [docroot/ads.txt](/mnt/share/torsten/Programs/YpChat/docroot/ads.txt). Diese muss auf deine echte Publisher-ID geprüft und ggf. angepasst werden.
|
||||
|
||||
## Was im Projekt erledigt werden muss
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# YPChat Android
|
||||
|
||||
Native Android-App fuer den bestehenden SingleChat/YPChat-Server.
|
||||
Native Android-App fuer den bestehenden YPChat-Server.
|
||||
|
||||
## Stack
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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") ->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -23,8 +23,48 @@
|
||||
</span>
|
||||
</div>
|
||||
</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"
|
||||
@@ -33,33 +73,33 @@
|
||||
>
|
||||
<strong>{{ message.from }}:</strong>
|
||||
<span v-if="message.isImage" class="image-message">
|
||||
<img
|
||||
:src="message.message"
|
||||
:alt="'Bild von ' + message.from"
|
||||
class="chat-image"
|
||||
<img
|
||||
:src="message.message"
|
||||
:alt="'Bild von ' + message.from"
|
||||
class="chat-image"
|
||||
@click="openImageModal(message.message)"
|
||||
/>
|
||||
</span>
|
||||
<span v-else v-html="replaceSmileys(message.message)"></span>
|
||||
|
||||
<!-- Bild-Modal -->
|
||||
</div>
|
||||
|
||||
<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' },
|
||||
@@ -95,21 +134,18 @@ const smileys = {
|
||||
|
||||
function replaceSmileys(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// HTML-Sonderzeichen escapen
|
||||
|
||||
let outputText = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// 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};`);
|
||||
}
|
||||
|
||||
|
||||
return outputText;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
240
client/src/components/FloatingVideoWindow.vue
Normal file
240
client/src/components/FloatingVideoWindow.vue
Normal 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>
|
||||
204
client/src/components/VideoDock.vue
Normal file
204
client/src/components/VideoDock.vue
Normal 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>
|
||||
102
client/src/components/VideoSessionSurface.vue
Normal file
102
client/src/components/VideoSessionSurface.vue
Normal 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
@@ -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>
|
||||
|
||||
436
docs/videochat-umsetzungsplan.md
Normal file
436
docs/videochat-umsetzungsplan.md
Normal 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.
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user