diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt
index b87eac12c..a39e88751 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt
@@ -126,6 +126,12 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(false)
}
+ binding.floatingRendererContainer.setOnClickListener {
+ val swapVideoViewIntent =
+ WebRtcCallService.swapVideoViews(this, viewModel.videoViewSwapped)
+ startService(swapVideoViewIntent)
+ }
+
binding.microphoneButton.setOnClickListener {
val audioEnabledIntent =
WebRtcCallService.microphoneIntent(this, !viewModel.microphoneEnabled)
@@ -330,28 +336,57 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
launch {
viewModel.localVideoEnabledState.collect { isEnabled ->
+ binding.localFloatingRenderer.removeAllViews()
binding.localRenderer.removeAllViews()
if (isEnabled) {
viewModel.localRenderer?.let { surfaceView ->
- surfaceView.setZOrderOnTop(true)
binding.localRenderer.addView(surfaceView)
}
+ viewModel.localFloatingRenderer?.let { surfaceView ->
+ surfaceView.setZOrderOnTop(true)
+ binding.localFloatingRenderer.addView(surfaceView)
+
+ }
}
- binding.localRenderer.isVisible = isEnabled
+ binding.localFloatingRenderer.isVisible = isEnabled && !viewModel.videoViewSwapped
+ binding.localRenderer.isVisible = isEnabled && viewModel.videoViewSwapped
binding.enableCameraButton.isSelected = isEnabled
+ binding.floatingRendererContainer.isVisible = binding.localFloatingRenderer.isVisible
+ binding.videocamOffIcon.isVisible = !binding.localFloatingRenderer.isVisible
+ binding.remoteRecipient.isVisible = !(binding.remoteRenderer.isVisible || binding.localRenderer.isVisible)
+ binding.swapViewIcon.bringToFront()
}
}
launch {
viewModel.remoteVideoEnabledState.collect { isEnabled ->
binding.remoteRenderer.removeAllViews()
+ binding.remoteFloatingRenderer.removeAllViews()
if (isEnabled) {
viewModel.remoteRenderer?.let { surfaceView ->
binding.remoteRenderer.addView(surfaceView)
}
+ viewModel.remoteFloatingRenderer?.let { surfaceView ->
+ surfaceView.setZOrderOnTop(true)
+ binding.remoteFloatingRenderer.addView(surfaceView)
+ }
}
- binding.remoteRenderer.isVisible = isEnabled
- binding.remoteRecipient.isVisible = !isEnabled
+ binding.remoteRenderer.isVisible = isEnabled && !viewModel.videoViewSwapped
+ binding.remoteFloatingRenderer.isVisible = isEnabled && viewModel.videoViewSwapped
+ binding.videocamOffIcon.isVisible = !binding.remoteFloatingRenderer.isVisible
+ binding.floatingRendererContainer.isVisible = binding.remoteFloatingRenderer.isVisible
+ binding.remoteRecipient.isVisible = !(binding.remoteRenderer.isVisible || binding.localRenderer.isVisible)
+ binding.swapViewIcon.bringToFront()
+ }
+ }
+
+ launch {
+ viewModel.videoViewSwappedState.collect{ isSwapped ->
+ binding.remoteRenderer.isVisible = !isSwapped && viewModel.remoteVideoEnabled
+ binding.remoteFloatingRenderer.isVisible = isSwapped && viewModel.remoteVideoEnabled
+ binding.localFloatingRenderer.isVisible = !isSwapped && viewModel.videoEnabled
+ binding.localRenderer.isVisible = isSwapped && viewModel.videoEnabled
+ binding.floatingRendererContainer.isVisible = binding.localFloatingRenderer.isVisible || binding.remoteFloatingRenderer.isVisible
}
}
}
@@ -366,6 +401,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
override fun onStop() {
super.onStop()
uiJob?.cancel()
+ binding.remoteFloatingRenderer.removeAllViews()
binding.remoteRenderer.removeAllViews()
binding.localRenderer.removeAllViews()
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt
index 3ae3d30f0..34fe18120 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt
@@ -62,6 +62,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
const val ACTION_LOCAL_HANGUP = "LOCAL_HANGUP"
const val ACTION_SET_MUTE_AUDIO = "SET_MUTE_AUDIO"
const val ACTION_SET_MUTE_VIDEO = "SET_MUTE_VIDEO"
+ const val ACTION_SWAP_VIDEO_VIEW = "SWAP_VIDEO_VIEW"
const val ACTION_FLIP_CAMERA = "FLIP_CAMERA"
const val ACTION_UPDATE_AUDIO = "UPDATE_AUDIO"
const val ACTION_WIRED_HEADSET_CHANGE = "WIRED_HEADSET_CHANGE"
@@ -81,6 +82,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
const val EXTRA_RECIPIENT_ADDRESS = "RECIPIENT_ID"
const val EXTRA_ENABLED = "ENABLED"
const val EXTRA_AUDIO_COMMAND = "AUDIO_COMMAND"
+ const val EXTRA_SWAPPED = "is_video_swapped"
const val EXTRA_MUTE = "mute_value"
const val EXTRA_AVAILABLE = "enabled_value"
const val EXTRA_REMOTE_DESCRIPTION = "remote_description"
@@ -108,6 +110,11 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
fun acceptCallIntent(context: Context) = Intent(context, WebRtcCallService::class.java)
.setAction(ACTION_ANSWER_CALL)
+ fun swapVideoViews(context: Context, swapped: Boolean) =
+ Intent(context, WebRtcCallService::class.java)
+ .setAction(ACTION_SWAP_VIDEO_VIEW)
+ .putExtra(EXTRA_SWAPPED, swapped)
+
fun microphoneIntent(context: Context, enabled: Boolean) =
Intent(context, WebRtcCallService::class.java)
.setAction(ACTION_SET_MUTE_AUDIO)
@@ -294,6 +301,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
action == ACTION_DENY_CALL -> handleDenyCall(intent)
action == ACTION_LOCAL_HANGUP -> handleLocalHangup(intent)
action == ACTION_REMOTE_HANGUP -> handleRemoteHangup(intent)
+ action == ACTION_SWAP_VIDEO_VIEW ->handleSwapVideoView(intent)
action == ACTION_SET_MUTE_AUDIO -> handleSetMuteAudio(intent)
action == ACTION_SET_MUTE_VIDEO -> handleSetMuteVideo(intent)
action == ACTION_FLIP_CAMERA -> handleSetCameraFlip(intent)
@@ -598,6 +606,11 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
onHangup()
}
+ private fun handleSwapVideoView(intent: Intent) {
+ val swapped = intent.getBooleanExtra(EXTRA_SWAPPED, false)
+ callManager.handleSwapVideoView(swapped)
+ }
+
private fun handleSetMuteAudio(intent: Intent) {
val muted = intent.getBooleanExtra(EXTRA_MUTE, false)
callManager.handleSetMuteAudio(muted)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt
index 894de9de6..53afa6f64 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt
@@ -3,7 +3,10 @@ package org.thoughtcrime.securesms.webrtc
import android.content.Context
import android.content.pm.PackageManager
import android.telephony.TelephonyManager
+import android.view.SurfaceView
+import android.view.View
import androidx.core.content.ContextCompat
+import androidx.core.view.isVisible
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.serialization.json.Json
@@ -29,6 +32,7 @@ import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioDeviceUpdat
import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioEnabled
import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.RecipientUpdate
import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.VideoEnabled
+import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.VideoSwapped
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat
import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
@@ -61,6 +65,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer {
sealed class StateEvent {
+ data class VideoSwapped(val isSwapped: Boolean): StateEvent()
data class AudioEnabled(val isEnabled: Boolean): StateEvent()
data class VideoEnabled(val isEnabled: Boolean): StateEvent()
data class CallStateUpdate(val state: CallState): StateEvent()
@@ -103,6 +108,8 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
val videoEvents = _videoEvents.asSharedFlow()
private val _remoteVideoEvents = MutableStateFlow(VideoEnabled(false))
val remoteVideoEvents = _remoteVideoEvents.asSharedFlow()
+ private val _videoViewSwappedEvents = MutableStateFlow(VideoSwapped(false))
+ val videoViewSwappedEvents = _videoViewSwappedEvents.asSharedFlow()
private val stateProcessor = StateProcessor(CallState.Idle)
@@ -146,8 +153,10 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
private val outgoingIceDebouncer = Debouncer(200L)
var localRenderer: SurfaceViewRenderer? = null
+ var localFloatingRenderer: SurfaceViewRenderer? = null
var remoteRotationSink: RemoteRotationVideoProxySink? = null
var remoteRenderer: SurfaceViewRenderer? = null
+ var remoteFloatingRenderer: SurfaceViewRenderer? = null
private var peerConnectionFactory: PeerConnectionFactory? = null
fun clearPendingIceUpdates() {
@@ -214,15 +223,26 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
// setScalingType(SCALE_ASPECT_FIT)
}
+ localFloatingRenderer = SurfaceViewRenderer(context).apply {
+// setScalingType(SCALE_ASPECT_FIT)
+ }
remoteRenderer = SurfaceViewRenderer(context).apply {
// setScalingType(SCALE_ASPECT_FIT)
}
+
+ remoteFloatingRenderer = SurfaceViewRenderer(context).apply {
+// setScalingType(SCALE_ASPECT_FIT)
+ }
+
remoteRotationSink = RemoteRotationVideoProxySink()
localRenderer?.init(base.eglBaseContext, null)
localRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT)
+ localFloatingRenderer?.init(base.eglBaseContext, null)
+ localFloatingRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT)
remoteRenderer?.init(base.eglBaseContext, null)
+ remoteFloatingRenderer?.init(base.eglBaseContext, null)
remoteRotationSink!!.setSink(remoteRenderer!!)
val encoderFactory = DefaultVideoEncoderFactory(base.eglBaseContext, true, true)
@@ -377,12 +397,16 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
peerConnection?.dispose()
peerConnection = null
+ localFloatingRenderer?.release()
localRenderer?.release()
remoteRotationSink?.release()
+ remoteFloatingRenderer?.release()
remoteRenderer?.release()
eglBase?.release()
+ localFloatingRenderer = null
localRenderer = null
+ remoteFloatingRenderer = null
remoteRenderer = null
eglBase = null
@@ -395,6 +419,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
_audioEvents.value = AudioEnabled(false)
_videoEvents.value = VideoEnabled(false)
_remoteVideoEvents.value = VideoEnabled(false)
+ _videoViewSwappedEvents.value = VideoSwapped(false)
pendingOutgoingIceUpdates.clear()
pendingIncomingIceUpdates.clear()
}
@@ -460,7 +485,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
val recipient = recipient ?: return Promise.ofFail(NullPointerException("recipient is null"))
val offer = pendingOffer ?: return Promise.ofFail(NullPointerException("pendingOffer is null"))
val factory = peerConnectionFactory ?: return Promise.ofFail(NullPointerException("peerConnectionFactory is null"))
- val local = localRenderer ?: return Promise.ofFail(NullPointerException("localRenderer is null"))
+ val local = localFloatingRenderer ?: return Promise.ofFail(NullPointerException("localRenderer is null"))
val base = eglBase ?: return Promise.ofFail(NullPointerException("eglBase is null"))
val connection = PeerConnectionWrapper(
context,
@@ -505,7 +530,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
?: return Promise.ofFail(NullPointerException("recipient is null"))
val factory = peerConnectionFactory
?: return Promise.ofFail(NullPointerException("peerConnectionFactory is null"))
- val local = localRenderer
+ val local = localFloatingRenderer
?: return Promise.ofFail(NullPointerException("localRenderer is null"))
val base = eglBase ?: return Promise.ofFail(NullPointerException("eglBase is null"))
@@ -595,6 +620,17 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
}
}
+ fun handleSwapVideoView(swapped: Boolean) {
+ _videoViewSwappedEvents.value = VideoSwapped(!swapped)
+ if (!swapped) {
+ peerConnection?.rotationVideoSink?.setSink(localRenderer)
+ remoteRotationSink?.setSink(remoteFloatingRenderer!!)
+ } else {
+ peerConnection?.rotationVideoSink?.setSink(localFloatingRenderer)
+ remoteRotationSink?.setSink(remoteRenderer!!)
+ }
+ }
+
fun handleSetMuteAudio(muted: Boolean) {
_audioEvents.value = AudioEnabled(!muted)
peerConnection?.setAudioEnabled(!muted)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt
index 4f27e5d1a..c6cfb3788 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt
@@ -32,14 +32,30 @@ class CallViewModel @Inject constructor(private val callManager: CallManager): V
val localRenderer: SurfaceViewRenderer?
get() = callManager.localRenderer
+ val localFloatingRenderer: SurfaceViewRenderer?
+ get() = callManager.localFloatingRenderer
+
val remoteRenderer: SurfaceViewRenderer?
get() = callManager.remoteRenderer
+ val remoteFloatingRenderer: SurfaceViewRenderer?
+ get() = callManager.remoteFloatingRenderer
+
private var _videoEnabled: Boolean = false
val videoEnabled: Boolean
get() = _videoEnabled
+ private var _remoteVideoEnabled: Boolean = false
+
+ val remoteVideoEnabled: Boolean
+ get() = _remoteVideoEnabled
+
+ private var _videoViewSwapped: Boolean = false
+
+ val videoViewSwapped: Boolean
+ get() = _videoViewSwapped
+
private var _microphoneEnabled: Boolean = true
val microphoneEnabled: Boolean
@@ -65,7 +81,14 @@ class CallViewModel @Inject constructor(private val callManager: CallManager): V
.onEach { _videoEnabled = it }
val remoteVideoEnabledState
- get() = callManager.remoteVideoEvents.map { it.isEnabled }
+ get() = callManager.remoteVideoEvents
+ .map { it.isEnabled }
+ .onEach { _remoteVideoEnabled = it }
+
+ val videoViewSwappedState
+ get() = callManager.videoViewSwappedEvents
+ .map { it.isSwapped }
+ .onEach { _videoViewSwapped = it }
var deviceRotation: Int = 0
set(value) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt
index 26d8fc223..03f69f46e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt
@@ -41,7 +41,7 @@ class PeerConnectionWrapper(private val context: Context,
private val mediaStream: MediaStream
private val videoSource: VideoSource?
private val videoTrack: VideoTrack?
- private val rotationVideoSink = RotationVideoSink()
+ public val rotationVideoSink = RotationVideoSink()
val readyForIce
get() = peerConnection?.localDescription != null && peerConnection?.remoteDescription != null
diff --git a/app/src/main/res/drawable/ic_baseline_screen_rotation_alt_24.xml b/app/src/main/res/drawable/ic_baseline_screen_rotation_alt_24.xml
new file mode 100644
index 000000000..553db9c08
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_screen_rotation_alt_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_webrtc.xml b/app/src/main/res/layout/activity_webrtc.xml
index e497456e6..ebef19ce5 100644
--- a/app/src/main/res/layout/activity_webrtc.xml
+++ b/app/src/main/res/layout/activity_webrtc.xml
@@ -22,6 +22,12 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"/>
+
+
+ android:layout_width="0dp"
+ android:visibility="invisible"
+ android:background="@color/black">
+
+
+