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
|
## 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:
|
- Einbindung in die Kopfzeilen:
|
||||||
- [ChatView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/ChatView.vue)
|
- [ChatView.vue](/mnt/share/torsten/Programs/YpChat/client/src/views/ChatView.vue)
|
||||||
- [PartnersView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/PartnersView.vue)
|
- [PartnersView.vue](/mnt/share/torsten/Programs/YpChat/client/src/views/PartnersView.vue)
|
||||||
- [FeedbackView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/FeedbackView.vue)
|
- [FeedbackView.vue](/mnt/share/torsten/Programs/YpChat/client/src/views/FeedbackView.vue)
|
||||||
|
|
||||||
Aktiv wird der Banner nur mit:
|
Aktiv wird der Banner nur mit:
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ Wichtig:
|
|||||||
- die Datei muss öffentlich unter `https://ypchat.net/ads.txt` erreichbar sein
|
- die Datei muss öffentlich unter `https://ypchat.net/ads.txt` erreichbar sein
|
||||||
- Änderungen brauchen oft etwas Zeit, bis Google sie erkennt
|
- Ä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
|
## Was im Projekt erledigt werden muss
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
```bash
|
```bash
|
||||||
# Als root oder mit sudo
|
# Als root oder mit sudo
|
||||||
sudo mkdir -p /opt/ypchat
|
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
|
sudo chown -R www-data:www-data /opt/ypchat
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# YPChat Android
|
# YPChat Android
|
||||||
|
|
||||||
Native Android-App fuer den bestehenden SingleChat/YPChat-Server.
|
Native Android-App fuer den bestehenden YPChat-Server.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ dependencies {
|
|||||||
}
|
}
|
||||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
||||||
implementation("io.coil-kt.coil3:coil-network-okhttp: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")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".YpChatApp"
|
android:name=".YpChatApp"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import de.ypchat.android.data.api.RestApi
|
import de.ypchat.android.data.api.RestApi
|
||||||
import de.ypchat.android.data.api.SocketClient
|
import de.ypchat.android.data.api.SocketClient
|
||||||
import de.ypchat.android.data.repository.ChatRepository
|
import de.ypchat.android.data.repository.ChatRepository
|
||||||
|
import de.ypchat.android.media.AndroidVideoCallManager
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
@@ -28,5 +29,6 @@ class AppContainer(context: Context) {
|
|||||||
|
|
||||||
val restApi: RestApi = retrofit.create(RestApi::class.java)
|
val restApi: RestApi = retrofit.create(RestApi::class.java)
|
||||||
val socketClient = SocketClient(AppConfig.baseUrl, okHttpClient)
|
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.InboxItemDto
|
||||||
import de.ypchat.android.data.model.SocketEvent
|
import de.ypchat.android.data.model.SocketEvent
|
||||||
import de.ypchat.android.data.model.UserDto
|
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 okhttp3.OkHttpClient
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
@@ -113,6 +121,51 @@ class SocketClient(
|
|||||||
s.on("unreadChats") { args ->
|
s.on("unreadChats") { args ->
|
||||||
args.firstJson()?.let { emit(SocketEvent.UnreadChats(it.optInt("count", 0))) }
|
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 ->
|
s.on("userBlocked") { args ->
|
||||||
args.firstJson()?.let { emit(SocketEvent.UserBlocked(it.optString("userName"))) }
|
args.firstJson()?.let { emit(SocketEvent.UserBlocked(it.optString("userName"))) }
|
||||||
}
|
}
|
||||||
@@ -210,6 +263,55 @@ class SocketClient(
|
|||||||
fun requestOpenConversations() = socket?.emit("requestOpenConversations")
|
fun requestOpenConversations() = socket?.emit("requestOpenConversations")
|
||||||
fun blockUser(userName: String) = socket?.emit("blockUser", JSONObject().put("userName", userName))
|
fun blockUser(userName: String) = socket?.emit("blockUser", JSONObject().put("userName", userName))
|
||||||
fun unblockUser(userName: String) = socket?.emit("unblockUser", 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) {
|
private fun emit(event: SocketEvent) {
|
||||||
scope.launch { _events.emit(event) }
|
scope.launch { _events.emit(event) }
|
||||||
@@ -252,6 +354,79 @@ private fun JSONObject.toInboxItemDto(): InboxItemDto = InboxItemDto(
|
|||||||
unreadCount = optInt("unreadCount", 0)
|
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> {
|
private fun JSONArray?.toStringList(): List<String> {
|
||||||
if (this == null) return emptyList()
|
if (this == null) return emptyList()
|
||||||
return List(length()) { index -> opt(index)?.toString().orEmpty() }
|
return List(length()) { index -> opt(index)?.toString().orEmpty() }
|
||||||
|
|||||||
@@ -34,6 +34,71 @@ data class InboxItemDto(
|
|||||||
val unreadCount: Int = 0
|
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(
|
data class CountryOption(
|
||||||
val englishName: String,
|
val englishName: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ sealed interface SocketEvent {
|
|||||||
data class HistoryResults(val results: List<HistoryItemDto>) : SocketEvent
|
data class HistoryResults(val results: List<HistoryItemDto>) : SocketEvent
|
||||||
data class InboxResults(val results: List<InboxItemDto>) : SocketEvent
|
data class InboxResults(val results: List<InboxItemDto>) : SocketEvent
|
||||||
data class UnreadChats(val count: Int) : 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 UserBlocked(val userName: String) : SocketEvent
|
||||||
data class UserUnblocked(val userName: String) : SocketEvent
|
data class UserUnblocked(val userName: String) : SocketEvent
|
||||||
data class CommandResult(val lines: List<String>, val kind: 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.PartnerLinkDto
|
||||||
import de.ypchat.android.data.model.SocketEvent
|
import de.ypchat.android.data.model.SocketEvent
|
||||||
import de.ypchat.android.data.model.UserDto
|
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 okhttp3.MultipartBody
|
||||||
|
import org.webrtc.EglBase
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class ChatRepository(
|
class ChatRepository(
|
||||||
private val restApi: RestApi,
|
private val restApi: RestApi,
|
||||||
private val socketClient: SocketClient,
|
private val socketClient: SocketClient,
|
||||||
private val cookieJar: SessionCookieJar,
|
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 scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val _state = MutableStateFlow(ChatState())
|
private val _state = MutableStateFlow(ChatState())
|
||||||
val state: StateFlow<ChatState> = _state.asStateFlow()
|
val state: StateFlow<ChatState> = _state.asStateFlow()
|
||||||
|
val videoMediaState: StateFlow<VideoMediaState> = videoCallManager.state
|
||||||
|
val videoEglBaseContext: EglBase.Context = videoCallManager.eglBaseContext()
|
||||||
private var timeoutTickerStarted = false
|
private var timeoutTickerStarted = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -105,6 +114,7 @@ class ChatRepository(
|
|||||||
runCatching { restApi.logout() }
|
runCatching { restApi.logout() }
|
||||||
socketClient.disconnect()
|
socketClient.disconnect()
|
||||||
cookieJar.clear()
|
cookieJar.clear()
|
||||||
|
videoCallManager.releaseAll()
|
||||||
_state.value = ChatState(savedProfile = profileStore.read(), countries = _state.value.countries)
|
_state.value = ChatState(savedProfile = profileStore.read(), countries = _state.value.countries)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,13 +124,21 @@ class ChatRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun openConversation(userName: String) {
|
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)
|
socketClient.requestConversation(userName)
|
||||||
resetTimeout()
|
resetTimeout()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun closeConversation() {
|
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) {
|
fun sendMessage(text: String) {
|
||||||
@@ -287,6 +305,75 @@ class ChatRepository(
|
|||||||
resetTimeout()
|
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() {
|
private fun startTimeoutTicker() {
|
||||||
if (timeoutTickerStarted) return
|
if (timeoutTickerStarted) return
|
||||||
timeoutTickerStarted = true
|
timeoutTickerStarted = true
|
||||||
@@ -344,6 +431,7 @@ class ChatRepository(
|
|||||||
is SocketEvent.Conversation -> current.copy(
|
is SocketEvent.Conversation -> current.copy(
|
||||||
currentConversation = event.withUserName,
|
currentConversation = event.withUserName,
|
||||||
messages = event.messages,
|
messages = event.messages,
|
||||||
|
videoConsent = current.videoConsent.takeIf { it.withUserName == event.withUserName } ?: VideoConsentDto(withUserName = event.withUserName),
|
||||||
unreadChatsCount = maxOf(0, current.unreadChatsCount - 1),
|
unreadChatsCount = maxOf(0, current.unreadChatsCount - 1),
|
||||||
remainingSecondsToTimeout = 1800
|
remainingSecondsToTimeout = 1800
|
||||||
)
|
)
|
||||||
@@ -351,6 +439,56 @@ class ChatRepository(
|
|||||||
is SocketEvent.HistoryResults -> current.copy(historyResults = event.results)
|
is SocketEvent.HistoryResults -> current.copy(historyResults = event.results)
|
||||||
is SocketEvent.InboxResults -> current.copy(inboxResults = event.results)
|
is SocketEvent.InboxResults -> current.copy(inboxResults = event.results)
|
||||||
is SocketEvent.UnreadChats -> current.copy(unreadChatsCount = event.count)
|
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.UserBlocked -> current.copy(errorMessage = "${event.userName} blocked")
|
||||||
is SocketEvent.UserUnblocked -> current.copy(errorMessage = "${event.userName} unblocked")
|
is SocketEvent.UserUnblocked -> current.copy(errorMessage = "${event.userName} unblocked")
|
||||||
is SocketEvent.CommandResult -> current.copy(
|
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(
|
data class CommandTableState(
|
||||||
val title: String,
|
val title: String,
|
||||||
val columns: List<String>,
|
val columns: List<String>,
|
||||||
@@ -406,5 +578,51 @@ data class ChatState(
|
|||||||
val isUploadingImage: Boolean = false,
|
val isUploadingImage: Boolean = false,
|
||||||
val imageUploadMessage: String? = null,
|
val imageUploadMessage: String? = null,
|
||||||
val unreadChatsCount: Int = 0,
|
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 kotlinx.coroutines.launch
|
||||||
import de.ypchat.android.data.repository.ChatRepository
|
import de.ypchat.android.data.repository.ChatRepository
|
||||||
import de.ypchat.android.data.repository.ChatState
|
import de.ypchat.android.data.repository.ChatState
|
||||||
|
import de.ypchat.android.media.VideoMediaState
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.webrtc.EglBase
|
||||||
|
|
||||||
class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
|
class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
|
||||||
val state: StateFlow<ChatState> = repository.state
|
val state: StateFlow<ChatState> = repository.state
|
||||||
|
val videoMediaState: StateFlow<VideoMediaState> = repository.videoMediaState
|
||||||
|
val videoEglBaseContext: EglBase.Context = repository.videoEglBaseContext
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch { repository.restoreSession() }
|
viewModelScope.launch { repository.restoreSession() }
|
||||||
@@ -111,6 +115,18 @@ class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
|
|||||||
|
|
||||||
fun blockCurrentUser() = state.value.currentConversation?.let(repository::blockUser)
|
fun blockCurrentUser() = state.value.currentConversation?.let(repository::blockUser)
|
||||||
fun unblockCurrentUser() = state.value.currentConversation?.let(repository::unblockUser)
|
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 {
|
private companion object {
|
||||||
const val MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
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.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.absoluteOffset
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
@@ -54,6 +59,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
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.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import de.ypchat.android.R
|
import de.ypchat.android.R
|
||||||
import de.ypchat.android.data.model.ChatMessageDto
|
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.model.UserDto
|
||||||
import de.ypchat.android.data.repository.ChatState
|
import de.ypchat.android.data.repository.ChatState
|
||||||
import de.ypchat.android.data.repository.CommandTableState
|
import de.ypchat.android.data.repository.CommandTableState
|
||||||
|
import de.ypchat.android.data.repository.VideoSessionState
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
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) {
|
enum class AppTab(val labelRes: Int) {
|
||||||
Online(R.string.tab_online),
|
Online(R.string.tab_online),
|
||||||
@@ -773,6 +788,20 @@ private fun ChatScreen(state: ChatState, viewModel: ChatViewModel) {
|
|||||||
var draft by remember { mutableStateOf("") }
|
var draft by remember { mutableStateOf("") }
|
||||||
var showSmileys by remember { mutableStateOf(false) }
|
var showSmileys by remember { mutableStateOf(false) }
|
||||||
val context = LocalContext.current
|
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 ->
|
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
||||||
if (uri != null) viewModel.sendImage(context, uri)
|
if (uri != null) viewModel.sendImage(context, uri)
|
||||||
}
|
}
|
||||||
@@ -795,95 +824,623 @@ private fun ChatScreen(state: ChatState, viewModel: ChatViewModel) {
|
|||||||
viewModel.setImageUploadMessage("Camera permission denied")
|
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()) {
|
fun runVideoAction(action: () -> Unit) {
|
||||||
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
val hasCamera = context.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||||
TextButton(onClick = viewModel::closeConversation) { Text(stringResource(R.string.back)) }
|
val hasAudio = context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||||
Text(state.currentConversation.orEmpty(), fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
if (hasCamera && hasAudio) {
|
||||||
TextButton(onClick = viewModel::blockCurrentUser) { Text(stringResource(R.string.block)) }
|
action()
|
||||||
TextButton(onClick = viewModel::unblockCurrentUser) { Text(stringResource(R.string.unblock)) }
|
} 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()) }
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
}
|
val showRightDock = liveVideoSessions.isNotEmpty() && maxWidth >= 900.dp
|
||||||
if (state.isUploadingImage || !state.imageUploadMessage.isNullOrBlank()) {
|
|
||||||
UploadStatusBanner(state)
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
}
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||||
OutlinedTextField(
|
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
draft,
|
TextButton(onClick = viewModel::closeConversation) { Text(stringResource(R.string.back)) }
|
||||||
{ draft = it },
|
Text(state.currentConversation.orEmpty(), fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
||||||
modifier = Modifier.weight(1f),
|
TextButton(onClick = viewModel::blockCurrentUser) { Text(stringResource(R.string.block)) }
|
||||||
placeholder = { Text(stringResource(R.string.message_placeholder)) },
|
TextButton(onClick = viewModel::unblockCurrentUser) { Text(stringResource(R.string.unblock)) }
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
},
|
HorizontalDivider()
|
||||||
enabled = !state.isUploadingImage,
|
if (state.currentConversation != null) {
|
||||||
colors = ButtonDefaults.textButtonColors(
|
VideoConversationHeader(
|
||||||
containerColor = Primary100,
|
state = state,
|
||||||
contentColor = Primary700,
|
currentVideoSession = currentVideoSession,
|
||||||
disabledContainerColor = SurfaceSubtle,
|
canStartVideo = canStartVideo,
|
||||||
disabledContentColor = TextMuted
|
onToggleConsent = { viewModel.setVideoConsent(!state.videoConsent.localConsent) },
|
||||||
)
|
onInvite = { runVideoAction(viewModel::inviteVideoCall) }
|
||||||
) {
|
)
|
||||||
Text(stringResource(R.string.button_camera), fontWeight = FontWeight.Bold)
|
}
|
||||||
}
|
if (currentVideoSession != null) {
|
||||||
Spacer(Modifier.width(8.dp))
|
VideoSessionBanner(
|
||||||
Button(
|
session = currentVideoSession,
|
||||||
onClick = { viewModel.sendMessage(draft); draft = "" },
|
currentUserName = state.currentUser?.userName.orEmpty(),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
onAccept = { callId -> runVideoAction { viewModel.acceptVideoCall(callId) } },
|
||||||
enabled = draft.isNotBlank() && !state.isUploadingImage
|
onReject = viewModel::rejectVideoCall,
|
||||||
) {
|
onCancel = viewModel::cancelVideoCall,
|
||||||
Text(stringResource(R.string.button_send))
|
onBringToFront = viewModel::bringVideoToFront
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
if (showSmileys) {
|
LazyColumn(modifier = Modifier.weight(1f).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
LazyRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
items(state.messages) { message -> MessageBubble(message, state.currentUser?.userName.orEmpty()) }
|
||||||
items(SmileyItems) { smiley ->
|
}
|
||||||
Text(
|
if (!showRightDock && liveVideoSessions.isNotEmpty()) {
|
||||||
text = smileyEmoji(smiley.hexCode),
|
VideoDockRow(
|
||||||
modifier = Modifier
|
state = state,
|
||||||
.background(Primary100, RoundedCornerShape(999.dp))
|
sessions = liveVideoSessions,
|
||||||
.clickable {
|
viewModel = viewModel,
|
||||||
draft += smiley.token
|
videoMediaState = videoMediaState,
|
||||||
showSmileys = false
|
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? {
|
private fun saveCameraBitmap(context: Context, bitmap: Bitmap): Uri? {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val file = File(context.cacheDir, "singlechat-photo-${System.currentTimeMillis()}.jpg")
|
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)
|
stringResource(R.string.camera_permission_denied)
|
||||||
message == "Camera capture failed" ->
|
message == "Camera capture failed" ->
|
||||||
stringResource(R.string.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") ->
|
message.endsWith(" blocked") ->
|
||||||
stringResource(R.string.user_blocked, message.removeSuffix(" blocked"))
|
stringResource(R.string.user_blocked, message.removeSuffix(" blocked"))
|
||||||
message.endsWith(" unblocked") ->
|
message.endsWith(" unblocked") ->
|
||||||
|
|||||||
@@ -52,6 +52,33 @@
|
|||||||
<string name="button_camera">Photo</string>
|
<string name="button_camera">Photo</string>
|
||||||
<string name="button_send">Send</string>
|
<string name="button_send">Send</string>
|
||||||
<string name="button_smileys">Smileys</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_message">Image message</string>
|
||||||
<string name="image_upload_in_progress">Uploading image...</string>
|
<string name="image_upload_in_progress">Uploading image...</string>
|
||||||
<string name="image_upload_success">Image uploaded.</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="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_blocked">%1$s has been blocked</string>
|
||||||
<string name="user_unblocked">%1$s has been unblocked</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_title">Feedback</string>
|
||||||
<string name="feedback_comment">Comment</string>
|
<string name="feedback_comment">Comment</string>
|
||||||
<string name="feedback_send">Send feedback</string>
|
<string name="feedback_send">Send feedback</string>
|
||||||
|
|||||||
@@ -23,8 +23,48 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="messages-container">
|
<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
|
<div
|
||||||
v-for="(message, index) in chatStore.messages"
|
v-for="(message, index) in chatStore.messages"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -33,33 +73,33 @@
|
|||||||
>
|
>
|
||||||
<strong>{{ message.from }}:</strong>
|
<strong>{{ message.from }}:</strong>
|
||||||
<span v-if="message.isImage" class="image-message">
|
<span v-if="message.isImage" class="image-message">
|
||||||
<img
|
<img
|
||||||
:src="message.message"
|
:src="message.message"
|
||||||
:alt="'Bild von ' + message.from"
|
:alt="'Bild von ' + message.from"
|
||||||
class="chat-image"
|
class="chat-image"
|
||||||
@click="openImageModal(message.message)"
|
@click="openImageModal(message.message)"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span v-else v-html="replaceSmileys(message.message)"></span>
|
<span v-else v-html="replaceSmileys(message.message)"></span>
|
||||||
|
</div>
|
||||||
<!-- Bild-Modal -->
|
|
||||||
<div v-if="selectedImage" class="image-modal-overlay" @click="closeImageModal">
|
<div v-if="selectedImage" class="image-modal-overlay" @click="closeImageModal">
|
||||||
<div class="image-modal-content" @click.stop>
|
<div class="image-modal-content" @click.stop>
|
||||||
<button class="image-modal-close" @click="closeImageModal" title="Schließen">×</button>
|
<button class="image-modal-close" @click="closeImageModal" title="Schließen">×</button>
|
||||||
<img :src="selectedImage" alt="Vergrößertes Bild" class="image-modal-image" />
|
<img :src="selectedImage" alt="Vergrößertes Bild" class="image-modal-image" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useChatStore } from '../stores/chat';
|
import { useChatStore } from '../stores/chat';
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const selectedImage = ref(null);
|
const selectedImage = ref(null);
|
||||||
|
const currentVideoSession = computed(() => chatStore.currentConversationVideoSession);
|
||||||
|
|
||||||
function openImageModal(imageSrc) {
|
function openImageModal(imageSrc) {
|
||||||
selectedImage.value = imageSrc;
|
selectedImage.value = imageSrc;
|
||||||
@@ -69,7 +109,6 @@ function closeImageModal() {
|
|||||||
selectedImage.value = null;
|
selectedImage.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Smiley-Definitionen (wie im Original)
|
|
||||||
const smileys = {
|
const smileys = {
|
||||||
':)': { code: '1F642' },
|
':)': { code: '1F642' },
|
||||||
':D': { code: '1F600' },
|
':D': { code: '1F600' },
|
||||||
@@ -95,21 +134,18 @@ const smileys = {
|
|||||||
|
|
||||||
function replaceSmileys(text) {
|
function replaceSmileys(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
|
|
||||||
// HTML-Sonderzeichen escapen
|
|
||||||
let outputText = text
|
let outputText = text
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, '<')
|
.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);
|
const sortedCodes = Object.keys(smileys).sort((a, b) => b.length - a.length);
|
||||||
|
|
||||||
for (const code of sortedCodes) {
|
for (const code of sortedCodes) {
|
||||||
const regex = new RegExp(escapeRegex(code), 'g');
|
const regex = new RegExp(escapeRegex(code), 'g');
|
||||||
outputText = outputText.replace(regex, `&#x${smileys[code].code};`);
|
outputText = outputText.replace(regex, `&#x${smileys[code].code};`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return outputText;
|
return outputText;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +157,19 @@ function formatTime(timestamp) {
|
|||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -212,15 +261,63 @@ function formatTime(timestamp) {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
|
||||||
.empty-stats {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-container {
|
.messages-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.chat-image {
|
||||||
@@ -298,4 +395,15 @@ function formatTime(timestamp) {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.empty-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-call-banner {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</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>
|
<span v-if="currentUserInfo">{{ currentUserInfo.age }} · {{ currentUserInfo.gender }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<HeaderAdBanner v-if="chatStore.currentConversation" />
|
<HeaderAdBanner v-if="chatStore.currentConversation" />
|
||||||
<ChatWindow />
|
<ChatWindow />
|
||||||
@@ -115,9 +145,11 @@
|
|||||||
<ChatInput />
|
<ChatInput />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<VideoDock />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<FloatingVideoWindow />
|
||||||
<ImprintContainer />
|
<ImprintContainer />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -157,6 +189,8 @@ import InboxView from '../components/InboxView.vue';
|
|||||||
import HistoryView from '../components/HistoryView.vue';
|
import HistoryView from '../components/HistoryView.vue';
|
||||||
import ImprintContainer from '../components/ImprintContainer.vue';
|
import ImprintContainer from '../components/ImprintContainer.vue';
|
||||||
import HeaderAdBanner from '../components/HeaderAdBanner.vue';
|
import HeaderAdBanner from '../components/HeaderAdBanner.vue';
|
||||||
|
import VideoDock from '../components/VideoDock.vue';
|
||||||
|
import FloatingVideoWindow from '../components/FloatingVideoWindow.vue';
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -358,4 +405,92 @@ onMounted(async () => {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
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>
|
</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';
|
import axios from 'axios';
|
||||||
|
|
||||||
const TIMEOUT_SECONDS = 1800; // 30 Minuten
|
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 {
|
class Client {
|
||||||
constructor(sessionId) {
|
constructor(sessionId) {
|
||||||
@@ -149,6 +202,8 @@ function parseLoginRecord(line) {
|
|||||||
|
|
||||||
let clients = new Map();
|
let clients = new Map();
|
||||||
let conversations = new Map(); // Key: "user1:user2" (alphabetisch sortiert)
|
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)
|
// Map: Socket-ID -> Express-Session-ID (für Session-Wiederherstellung)
|
||||||
let socketToSessionMap = new Map();
|
let socketToSessionMap = new Map();
|
||||||
@@ -222,6 +277,306 @@ function getConversationKey(user1, user2) {
|
|||||||
return [user1, user2].sort().join(':');
|
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) {
|
function logClientLogin(client, __dirname) {
|
||||||
try {
|
try {
|
||||||
const logsDir = join(__dirname, '../logs');
|
const logsDir = join(__dirname, '../logs');
|
||||||
@@ -808,6 +1163,7 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
|
|
||||||
// Speichere Socket-ID mit Session-ID
|
// Speichere Socket-ID mit Session-ID
|
||||||
socket.data.sessionId = sessionId;
|
socket.data.sessionId = sessionId;
|
||||||
|
socketToSessionMap.set(socket.id, sessionId);
|
||||||
|
|
||||||
let client = clients.get(sessionId);
|
let client = clients.get(sessionId);
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -836,10 +1192,15 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
socket.emit('connected', connectedData);
|
socket.emit('connected', connectedData);
|
||||||
|
|
||||||
socket.on('disconnect', (reason) => {
|
socket.on('disconnect', (reason) => {
|
||||||
console.log(`[Disconnect] Socket getrennt für Session-ID: ${sessionId}, Grund: ${reason}`);
|
const currentSessionId = socket.data.sessionId || sessionId;
|
||||||
const client = clients.get(sessionId);
|
socketToSessionMap.delete(socket.id);
|
||||||
|
console.log(`[Disconnect] Socket getrennt für Session-ID: ${currentSessionId}, Grund: ${reason}`);
|
||||||
|
const client = clients.get(currentSessionId);
|
||||||
if (client) {
|
if (client) {
|
||||||
console.log(`[Disconnect] Client gefunden: ${client.userName || 'unbekannt'}, Socket war verbunden: ${client.socket ? client.socket.connected : 'null'}`);
|
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
|
// 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
|
// 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) {
|
if (expressSessionId) {
|
||||||
console.log('setSessionId - Express-Session-ID erhalten:', expressSessionId);
|
console.log('setSessionId - Express-Session-ID erhalten:', expressSessionId);
|
||||||
const currentSessionId = socket.data.sessionId;
|
const currentSessionId = socket.data.sessionId;
|
||||||
|
socketToSessionMap.set(socket.id, expressSessionId);
|
||||||
|
|
||||||
if (currentSessionId !== expressSessionId) {
|
if (currentSessionId !== expressSessionId) {
|
||||||
console.log('setSessionId - Aktualisiere Session-ID von', currentSessionId, 'zu', expressSessionId);
|
console.log('setSessionId - Aktualisiere Session-ID von', currentSessionId, 'zu', expressSessionId);
|
||||||
@@ -921,6 +1283,7 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
loggedIn: true,
|
loggedIn: true,
|
||||||
user: existingClient.toJSON()
|
user: existingClient.toJSON()
|
||||||
});
|
});
|
||||||
|
emitVideoCapacityToUser(existingClient.userName);
|
||||||
|
|
||||||
// Aktualisiere Userliste für alle Clients, damit der wiederhergestellte Client die Liste erhält
|
// Aktualisiere Userliste für alle Clients, damit der wiederhergestellte Client die Liste erhält
|
||||||
broadcastUserList();
|
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) {
|
async function handleLogin(socket, client, data) {
|
||||||
@@ -1154,6 +1652,7 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
sessionId: client.sessionId,
|
sessionId: client.sessionId,
|
||||||
user: client.toJSON()
|
user: client.toJSON()
|
||||||
});
|
});
|
||||||
|
emitVideoCapacityToUser(client.userName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessage(socket, client, data) {
|
function handleMessage(socket, client, data) {
|
||||||
@@ -1305,6 +1804,322 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
imageType: msg.imageType || null
|
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) {
|
function handleUserSearch(socket, client, data) {
|
||||||
@@ -1406,6 +2221,9 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
|
|
||||||
const { userName } = data;
|
const { userName } = data;
|
||||||
client.blockedUsers.add(userName);
|
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', {
|
socket.emit('userBlocked', {
|
||||||
userName
|
userName
|
||||||
@@ -1420,6 +2238,7 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
|
|
||||||
const { userName } = data;
|
const { userName } = data;
|
||||||
client.blockedUsers.delete(userName);
|
client.blockedUsers.delete(userName);
|
||||||
|
emitVideoConsentStateToParticipants(client.userName, userName);
|
||||||
|
|
||||||
socket.emit('userUnblocked', {
|
socket.emit('userUnblocked', {
|
||||||
userName
|
userName
|
||||||
@@ -1488,6 +2307,12 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
for (const [sid, client] of clients.entries()) {
|
for (const [sid, client] of clients.entries()) {
|
||||||
if (client.activitiesTimedOut()) {
|
if (client.activitiesTimedOut()) {
|
||||||
console.log(`Client ${client.userName} hat Timeout erreicht`);
|
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);
|
clients.delete(sid);
|
||||||
broadcastUserList();
|
broadcastUserList();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user