android, desktop: landscape calls on Android and better local camera ratio management (#5124)

* android, desktop: landscape calls on Android and better local camera ratio management

The main thing is that now when exiting from CallActivity while in call
audio devices are not reset to default. It allows to have landscape mode
enabled

* styles

* fix changing calls
This commit is contained in:
Stanislav Dmitrenko 2024-12-08 00:09:00 +07:00 committed by GitHub
parent 7d6c7c58d7
commit 307211a47f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 283 additions and 137 deletions

View file

@ -115,7 +115,6 @@
android:launchMode="singleInstance" android:launchMode="singleInstance"
android:supportsPictureInPicture="true" android:supportsPictureInPicture="true"
android:autoRemoveFromRecents="true" android:autoRemoveFromRecents="true"
android:screenOrientation="portrait"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"/> android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"/>
<provider <provider

View file

@ -360,6 +360,8 @@ class SimplexApp: Application(), LifecycleEventObserver {
return true return true
} }
override fun androidCreateActiveCallState(): Closeable = ActiveCallState()
override val androidApiLevel: Int get() = Build.VERSION.SDK_INT override val androidApiLevel: Int get() = Build.VERSION.SDK_INT
} }
} }

View file

@ -71,8 +71,12 @@ class PostSCallAudioDeviceManager: CallAudioDeviceManagerInterface {
} }
override fun stop() { override fun stop() {
am.unregisterAudioDeviceCallback(audioCallback) try {
am.removeOnCommunicationDeviceChangedListener(listener) am.unregisterAudioDeviceCallback(audioCallback)
am.removeOnCommunicationDeviceChangedListener(listener)
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
}
} }
override fun selectLastExternalDeviceOrDefault(speaker: Boolean, keepAnyExternal: Boolean) { override fun selectLastExternalDeviceOrDefault(speaker: Boolean, keepAnyExternal: Boolean) {

View file

@ -6,12 +6,12 @@ import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.* import android.content.*
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.* import android.media.*
import android.os.Build import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import android.os.PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK import android.os.PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK
import android.os.PowerManager.WakeLock
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.* import android.webkit.*
@ -23,7 +23,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -47,7 +46,6 @@ import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.res.MR import chat.simplex.res.MR
import com.google.accompanist.permissions.* import com.google.accompanist.permissions.*
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
@ -58,6 +56,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import java.io.Closeable
// Should be destroy()'ed and set as null when call is ended. Otherwise, it will be a leak // Should be destroy()'ed and set as null when call is ended. Otherwise, it will be a leak
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
@ -72,49 +71,62 @@ fun activeCallDestroyWebView() = withApi {
Log.d(TAG, "CallView: webview was destroyed") Log.d(TAG, "CallView: webview was destroyed")
} }
@SuppressLint("SourceLockedOrientationActivity") class ActiveCallState: Closeable {
@Composable val proximityLock: WakeLock? = screenOffWakeLock()
actual fun ActiveCallView() { var wasConnected = false
val call = remember { chatModel.activeCall }.value val callAudioDeviceManager = CallAudioDeviceManagerInterface.new()
val scope = rememberCoroutineScope() private var closed = false
val proximityLock = remember {
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
callAudioDeviceManager.start()
}
}
override fun close() {
if (closed) return
closed = true
CallSoundsPlayer.stop()
if (wasConnected) {
CallSoundsPlayer.vibrate()
}
callAudioDeviceManager.stop()
dropAudioManagerOverrides()
if (proximityLock?.isHeld == true) {
proximityLock.release()
}
}
private fun screenOffWakeLock(): WakeLock? {
val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager) val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) { return if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock") pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock")
} else { } else {
null null
} }
} }
val wasConnected = rememberSaveable { mutableStateOf(false) } }
@SuppressLint("SourceLockedOrientationActivity")
@Composable
actual fun ActiveCallView() {
val call = remember { chatModel.activeCall }.value
val callState = call?.androidCallState as ActiveCallState?
val scope = rememberCoroutineScope()
LaunchedEffect(call) { LaunchedEffect(call) {
if (call?.callState == CallState.Connected && !wasConnected.value) { if (call?.callState == CallState.Connected && callState != null && !callState.wasConnected) {
CallSoundsPlayer.vibrate(2) CallSoundsPlayer.vibrate(2)
wasConnected.value = true callState.wasConnected = true
} }
} }
val callAudioDeviceManager = remember { CallAudioDeviceManagerInterface.new() } LaunchedEffect(callState, chatModel.activeCallViewIsCollapsed.value) {
DisposableEffect(Unit) { callState ?: return@LaunchedEffect
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
callAudioDeviceManager.start()
}
onDispose {
CallSoundsPlayer.stop()
if (wasConnected.value) {
CallSoundsPlayer.vibrate()
}
callAudioDeviceManager.stop()
dropAudioManagerOverrides()
if (proximityLock?.isHeld == true) {
proximityLock.release()
}
}
}
LaunchedEffect(chatModel.activeCallViewIsCollapsed.value) {
if (chatModel.activeCallViewIsCollapsed.value) { if (chatModel.activeCallViewIsCollapsed.value) {
if (proximityLock?.isHeld == true) proximityLock.release() if (callState.proximityLock?.isHeld == true) callState.proximityLock.release()
} else { } else {
delay(1000) delay(1000)
if (proximityLock?.isHeld == false) proximityLock.acquire() if (callState.proximityLock?.isHeld == false) callState.proximityLock.acquire()
} }
} }
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
@ -122,6 +134,7 @@ actual fun ActiveCallView() {
Log.d(TAG, "received from WebRTCView: $apiMsg") Log.d(TAG, "received from WebRTCView: $apiMsg")
val call = chatModel.activeCall.value val call = chatModel.activeCall.value
if (call != null) { if (call != null) {
val callState = call.androidCallState as ActiveCallState
Log.d(TAG, "has active call $call") Log.d(TAG, "has active call $call")
val callRh = call.remoteHostId val callRh = call.remoteHostId
when (val r = apiMsg.resp) { when (val r = apiMsg.resp) {
@ -131,9 +144,9 @@ actual fun ActiveCallView() {
updateActiveCall(call) { it.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) } updateActiveCall(call) { it.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) }
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
// Starting is delayed to make Android <= 11 working good with Bluetooth // Starting is delayed to make Android <= 11 working good with Bluetooth
callAudioDeviceManager.start() callState.callAudioDeviceManager.start()
} else { } else {
callAudioDeviceManager.selectLastExternalDeviceOrDefault(call.hasVideo, true) callState.callAudioDeviceManager.selectLastExternalDeviceOrDefault(call.hasVideo, true)
} }
CallSoundsPlayer.startConnectingCallSound(scope) CallSoundsPlayer.startConnectingCallSound(scope)
activeCallWaitDeliveryReceipt(scope) activeCallWaitDeliveryReceipt(scope)
@ -143,9 +156,9 @@ actual fun ActiveCallView() {
updateActiveCall(call) { it.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) } updateActiveCall(call) { it.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) }
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
// Starting is delayed to make Android <= 11 working good with Bluetooth // Starting is delayed to make Android <= 11 working good with Bluetooth
callAudioDeviceManager.start() callState.callAudioDeviceManager.start()
} else { } else {
callAudioDeviceManager.selectLastExternalDeviceOrDefault(call.hasVideo, true) callState.callAudioDeviceManager.selectLastExternalDeviceOrDefault(call.hasVideo, true)
} }
} }
is WCallResponse.Answer -> withBGApi { is WCallResponse.Answer -> withBGApi {
@ -228,14 +241,14 @@ actual fun ActiveCallView() {
!chatModel.activeCallViewIsCollapsed.value -> true !chatModel.activeCallViewIsCollapsed.value -> true
else -> false else -> false
} }
if (call != null && showOverlay) { if (call != null && showOverlay && callState != null) {
ActiveCallOverlay(call, chatModel, callAudioDeviceManager) ActiveCallOverlay(call, chatModel, callState.callAudioDeviceManager)
} }
} }
KeyChangeEffect(call?.localMediaSources?.hasVideo) { KeyChangeEffect(callState, call?.localMediaSources?.hasVideo) {
if (call != null && call.hasVideo && callAudioDeviceManager.currentDevice.value?.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE) { if (call != null && call.hasVideo && callState != null && callState.callAudioDeviceManager.currentDevice.value?.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE) {
// enabling speaker on user action (peer action ignored) and not disabling it again // enabling speaker on user action (peer action ignored) and not disabling it again
callAudioDeviceManager.selectLastExternalDeviceOrDefault(call.hasVideo, true) callState.callAudioDeviceManager.selectLastExternalDeviceOrDefault(call.hasVideo, true)
} }
} }
val context = LocalContext.current val context = LocalContext.current
@ -243,16 +256,12 @@ actual fun ActiveCallView() {
val activity = context as? Activity ?: return@DisposableEffect onDispose {} val activity = context as? Activity ?: return@DisposableEffect onDispose {}
val prevVolumeControlStream = activity.volumeControlStream val prevVolumeControlStream = activity.volumeControlStream
activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL
// Lock orientation to portrait in order to have good experience with calls
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
chatModel.activeCallViewIsVisible.value = true chatModel.activeCallViewIsVisible.value = true
// After the first call, End command gets added to the list which prevents making another calls // After the first call, End command gets added to the list which prevents making another calls
chatModel.callCommand.removeAll { it is WCallCommand.End } chatModel.callCommand.removeAll { it is WCallCommand.End }
keepScreenOn(true) keepScreenOn(true)
onDispose { onDispose {
activity.volumeControlStream = prevVolumeControlStream activity.volumeControlStream = prevVolumeControlStream
// Unlock orientation
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
chatModel.activeCallViewIsVisible.value = false chatModel.activeCallViewIsVisible.value = false
chatModel.callCommand.clear() chatModel.callCommand.clear()
keepScreenOn(false) keepScreenOn(false)
@ -264,8 +273,8 @@ actual fun ActiveCallView() {
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, callAudioDeviceManager: CallAudioDeviceManagerInterface) { private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, callAudioDeviceManager: CallAudioDeviceManagerInterface) {
ActiveCallOverlayLayout( ActiveCallOverlayLayout(
call = call, call = call,
devices = remember { callAudioDeviceManager.devices }.value, devices = remember(callAudioDeviceManager) { callAudioDeviceManager.devices }.value,
currentDevice = remember { callAudioDeviceManager.currentDevice }, currentDevice = remember(callAudioDeviceManager) { callAudioDeviceManager.currentDevice },
dismiss = { withBGApi { chatModel.callManager.endCall(call) } }, dismiss = { withBGApi { chatModel.callManager.endCall(call) } },
toggleAudio = { chatModel.callCommand.add(WCallCommand.Media(CallMediaSource.Mic, enable = !call.localMediaSources.mic)) }, toggleAudio = { chatModel.callCommand.add(WCallCommand.Media(CallMediaSource.Mic, enable = !call.localMediaSources.mic)) },
selectDevice = { callAudioDeviceManager.selectDevice(it.id) }, selectDevice = { callAudioDeviceManager.selectDevice(it.id) },
@ -832,7 +841,8 @@ fun PreviewActiveCallOverlayVideo() {
connectionInfo = ConnectionInfo( connectionInfo = ConnectionInfo(
RTCIceCandidate(RTCIceCandidateType.Host, "tcp"), RTCIceCandidate(RTCIceCandidateType.Host, "tcp"),
RTCIceCandidate(RTCIceCandidateType.Host, "tcp") RTCIceCandidate(RTCIceCandidateType.Host, "tcp")
) ),
androidCallState = {}
), ),
devices = emptyList(), devices = emptyList(),
currentDevice = remember { mutableStateOf(null) }, currentDevice = remember { mutableStateOf(null) },
@ -841,7 +851,7 @@ fun PreviewActiveCallOverlayVideo() {
selectDevice = {}, selectDevice = {},
toggleVideo = {}, toggleVideo = {},
toggleSound = {}, toggleSound = {},
flipCamera = {} flipCamera = {},
) )
} }
} }
@ -862,7 +872,8 @@ fun PreviewActiveCallOverlayAudio() {
connectionInfo = ConnectionInfo( connectionInfo = ConnectionInfo(
RTCIceCandidate(RTCIceCandidateType.Host, "udp"), RTCIceCandidate(RTCIceCandidateType.Host, "udp"),
RTCIceCandidate(RTCIceCandidateType.Host, "udp") RTCIceCandidate(RTCIceCandidateType.Host, "udp")
) ),
androidCallState = {}
), ),
devices = emptyList(), devices = emptyList(),
currentDevice = remember { mutableStateOf(null) }, currentDevice = remember { mutableStateOf(null) },

View file

@ -10,6 +10,7 @@ import chat.simplex.common.model.ChatId
import chat.simplex.common.model.NotificationsMode import chat.simplex.common.model.NotificationsMode
import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.ui.theme.CurrentColors
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import java.io.Closeable
interface PlatformInterface { interface PlatformInterface {
suspend fun androidServiceStart() {} suspend fun androidServiceStart() {}
@ -26,6 +27,7 @@ interface PlatformInterface {
fun androidPictureInPictureAllowed(): Boolean = true fun androidPictureInPictureAllowed(): Boolean = true
fun androidCallEnded() {} fun androidCallEnded() {}
fun androidRestartNetworkObserver() {} fun androidRestartNetworkObserver() {}
fun androidCreateActiveCallState(): Closeable = Closeable { }
fun androidIsXiaomiDevice(): Boolean = false fun androidIsXiaomiDevice(): Boolean = false
val androidApiLevel: Int? get() = null val androidApiLevel: Int? get() = null
@Composable fun androidLockPortraitOrientation() {} @Composable fun androidLockPortraitOrientation() {}

View file

@ -43,6 +43,7 @@ class CallManager(val chatModel: ChatModel) {
private fun justAcceptIncomingCall(invitation: RcvCallInvitation, userProfile: Profile) { private fun justAcceptIncomingCall(invitation: RcvCallInvitation, userProfile: Profile) {
with (chatModel) { with (chatModel) {
activeCall.value?.androidCallState?.close()
activeCall.value = Call( activeCall.value = Call(
remoteHostId = invitation.remoteHostId, remoteHostId = invitation.remoteHostId,
userProfile = userProfile, userProfile = userProfile,
@ -51,6 +52,7 @@ class CallManager(val chatModel: ChatModel) {
callState = CallState.InvitationAccepted, callState = CallState.InvitationAccepted,
initialCallType = invitation.callType.media, initialCallType = invitation.callType.media,
sharedKey = invitation.sharedKey, sharedKey = invitation.sharedKey,
androidCallState = platform.androidCreateActiveCallState()
) )
showCallView.value = true showCallView.value = true
val useRelay = controller.appPrefs.webrtcPolicyRelay.get() val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
@ -78,6 +80,7 @@ class CallManager(val chatModel: ChatModel) {
// Don't destroy WebView if you plan to accept next call right after this one // Don't destroy WebView if you plan to accept next call right after this one
if (!switchingCall.value) { if (!switchingCall.value) {
showCallView.value = false showCallView.value = false
activeCall.value?.androidCallState?.close()
activeCall.value = null activeCall.value = null
activeCallViewIsCollapsed.value = false activeCallViewIsCollapsed.value = false
platform.androidCallEnded() platform.androidCallEnded()

View file

@ -7,6 +7,7 @@ import chat.simplex.res.MR
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.io.Closeable
import java.net.URI import java.net.URI
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
@ -27,7 +28,9 @@ data class Call(
// When a user has audio call, and then he wants to enable camera but didn't grant permissions for using camera yet, // When a user has audio call, and then he wants to enable camera but didn't grant permissions for using camera yet,
// we show permissions view without enabling camera before permissions are granted. After they are granted, enabling camera // we show permissions view without enabling camera before permissions are granted. After they are granted, enabling camera
val wantsToEnableCamera: Boolean = false val wantsToEnableCamera: Boolean = false,
val androidCallState: Closeable
) { ) {
val encrypted: Boolean get() = localEncrypted && sharedKey != null val encrypted: Boolean get() = localEncrypted && sharedKey != null
private val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false private val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false

View file

@ -29,6 +29,7 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.model.CIDirection.GroupRcv import chat.simplex.common.model.CIDirection.GroupRcv
import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.activeCall
import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
@ -573,7 +574,8 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType)
if (chatInfo is ChatInfo.Direct) { if (chatInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(remoteHostId, chatInfo.contact.contactId) val contactInfo = chatModel.controller.apiContactInfo(remoteHostId, chatInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = remoteHostId, contact = chatInfo.contact, callUUID = null, callState = CallState.WaitCapabilities, initialCallType = media, userProfile = profile) activeCall.value?.androidCallState?.close()
chatModel.activeCall.value = Call(remoteHostId = remoteHostId, contact = chatInfo.contact, callUUID = null, callState = CallState.WaitCapabilities, initialCallType = media, userProfile = profile, androidCallState = platform.androidCreateActiveCallState())
chatModel.showCallView.value = true chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media)) chatModel.callCommand.add(WCallCommand.Capabilities(media))
} }

View file

@ -12,26 +12,60 @@ body {
object-fit: cover; object-fit: cover;
} }
#remote-video-stream.collapsed { @media (orientation: portrait) {
position: absolute; #remote-video-stream.collapsed {
max-width: 30%; position: absolute;
max-height: 30%; width: 30%;
object-fit: cover; max-width: 30%;
margin: 16px; height: 39.9vw;
border-radius: 16px; object-fit: cover;
bottom: 80px; margin: 16px;
right: 0; border-radius: 16px;
bottom: 80px;
right: 0;
}
} }
#remote-video-stream.collapsed-pip { @media (orientation: landscape) {
position: absolute; #remote-video-stream.collapsed {
max-width: 50%; position: absolute;
max-height: 50%; width: 20%;
object-fit: cover; max-width: 20%;
margin: 8px; height: 15.03vw;
border-radius: 8px; object-fit: cover;
bottom: 0; margin: 16px;
right: 0; border-radius: 16px;
bottom: 80px;
right: 0;
}
}
@media (orientation: portrait) {
#remote-video-stream.collapsed-pip {
position: absolute;
width: 50%;
max-width: 50%;
height: 66.5vw;
object-fit: cover;
margin: 8px;
border-radius: 8px;
bottom: 0;
right: 0;
}
}
@media (orientation: landscape) {
#remote-video-stream.collapsed-pip {
position: absolute;
width: 50%;
max-width: 50%;
height: 37.59vw;
object-fit: cover;
margin: 8px;
border-radius: 8px;
bottom: 0;
right: 0;
}
} }
#remote-screen-video-stream.inline { #remote-screen-video-stream.inline {
@ -41,15 +75,32 @@ body {
object-fit: cover; object-fit: cover;
} }
#local-video-stream.inline { @media (orientation: portrait) {
position: absolute; #local-video-stream.inline {
width: 30%; position: absolute;
max-width: 30%; width: 30%;
object-fit: cover; max-width: 30%;
margin: 16px; height: 39.9vw;
border-radius: 16px; object-fit: cover;
top: 0; margin: 16px;
right: 0; border-radius: 16px;
top: 0;
right: 0;
}
}
@media (orientation: landscape) {
#local-video-stream.inline {
position: absolute;
width: 20%;
max-width: 20%;
height: 15.03vw;
object-fit: cover;
margin: 16px;
border-radius: 16px;
top: 0;
right: 0;
}
} }
#local-screen-video-stream.inline { #local-screen-video-stream.inline {

View file

@ -301,6 +301,7 @@ const processCommand = (function () {
localStream = await getLocalMediaStream(true, command.media == CallMediaType.Video && (await browserHasCamera()), VideoCamera.User); localStream = await getLocalMediaStream(true, command.media == CallMediaType.Video && (await browserHasCamera()), VideoCamera.User);
const videos = getVideoElements(); const videos = getVideoElements();
if (videos) { if (videos) {
setupLocalVideoRatio(videos.local);
videos.local.srcObject = localStream; videos.local.srcObject = localStream;
videos.local.play().catch((e) => console.log(e)); videos.local.play().catch((e) => console.log(e));
} }
@ -330,9 +331,12 @@ const processCommand = (function () {
console.log("starting incoming call - create webrtc session"); console.log("starting incoming call - create webrtc session");
if (activeCall) if (activeCall)
endCall(); endCall();
// It can be already defined on Android when switching calls (if the previous call was outgoing)
notConnectedCall = undefined;
inactiveCallMediaSources.mic = true; inactiveCallMediaSources.mic = true;
inactiveCallMediaSources.camera = command.media == CallMediaType.Video; inactiveCallMediaSources.camera = command.media == CallMediaType.Video;
inactiveCallMediaSourcesChanged(inactiveCallMediaSources); inactiveCallMediaSourcesChanged(inactiveCallMediaSources);
setupLocalVideoRatio(getVideoElements().local);
const { media, iceServers, relay } = command; const { media, iceServers, relay } = command;
const encryption = supportsInsertableStreams(useWorker); const encryption = supportsInsertableStreams(useWorker);
const aesKey = encryption ? command.aesKey : undefined; const aesKey = encryption ? command.aesKey : undefined;
@ -547,13 +551,13 @@ const processCommand = (function () {
} }
function endCall() { function endCall() {
var _a; var _a;
shutdownCameraAndMic();
try { try {
(_a = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection) === null || _a === void 0 ? void 0 : _a.close(); (_a = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection) === null || _a === void 0 ? void 0 : _a.close();
} }
catch (e) { catch (e) {
console.log(e); console.log(e);
} }
shutdownCameraAndMic();
activeCall = undefined; activeCall = undefined;
resetVideoElements(); resetVideoElements();
} }
@ -642,27 +646,21 @@ const processCommand = (function () {
} }
// Without doing it manually Firefox shows black screen but video can be played in Picture-in-Picture // Without doing it manually Firefox shows black screen but video can be played in Picture-in-Picture
videos.local.play().catch((e) => console.log(e)); videos.local.play().catch((e) => console.log(e));
setupLocalVideoRatio(videos.local);
} }
function setupLocalVideoRatio(local) { function setupLocalVideoRatio(local) {
const ratio = isDesktop ? 1.33 : 1 / 1.33;
const currentRect = local.getBoundingClientRect();
// better to get percents from here than to hardcode values from styles (the styles can be changed)
const screenWidth = currentRect.left + currentRect.width;
const percents = currentRect.width / screenWidth;
local.style.width = `${percents * 100}%`;
local.style.height = `${(percents / ratio) * 100}vw`;
local.addEventListener("loadedmetadata", function () { local.addEventListener("loadedmetadata", function () {
console.log("Local video videoWidth: " + local.videoWidth + "px, videoHeight: " + local.videoHeight + "px"); console.log("Local video videoWidth: " + local.videoWidth + "px, videoHeight: " + local.videoHeight + "px");
if (local.videoWidth == 0 || local.videoHeight == 0) if (local.videoWidth == 0 || local.videoHeight == 0)
return; return;
local.style.height = `${(percents / (local.videoWidth / local.videoHeight)) * 100}vw`; const ratio = local.videoWidth > local.videoHeight ? 0.2 : 0.3;
local.style.height = `${(ratio / (local.videoWidth / local.videoHeight)) * 100}vw`;
}); });
local.onresize = function () { local.onresize = function () {
console.log("Local video size changed to " + local.videoWidth + "x" + local.videoHeight); console.log("Local video size changed to " + local.videoWidth + "x" + local.videoHeight);
if (local.videoWidth == 0 || local.videoHeight == 0) if (local.videoWidth == 0 || local.videoHeight == 0)
return; return;
local.style.height = `${(percents / (local.videoWidth / local.videoHeight)) * 100}vw`; const ratio = local.videoWidth > local.videoHeight ? 0.2 : 0.3;
local.style.height = `${(ratio / (local.videoWidth / local.videoHeight)) * 100}vw`;
}; };
} }
function setupEncryptionForLocalStream(call) { function setupEncryptionForLocalStream(call) {
@ -1128,8 +1126,9 @@ const processCommand = (function () {
(!!useWorker && "RTCRtpScriptTransform" in window)); (!!useWorker && "RTCRtpScriptTransform" in window));
} }
function shutdownCameraAndMic() { function shutdownCameraAndMic() {
if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localStream) { if (activeCall) {
activeCall.localStream.getTracks().forEach((track) => track.stop()); activeCall.localStream.getTracks().forEach((track) => track.stop());
activeCall.localScreenStream.getTracks().forEach((track) => track.stop());
} }
} }
function resetVideoElements() { function resetVideoElements() {
@ -1295,6 +1294,9 @@ function changeLayout(layout) {
break; break;
} }
videos.localScreen.style.visibility = localSources.screenVideo ? "visible" : "hidden"; videos.localScreen.style.visibility = localSources.screenVideo ? "visible" : "hidden";
if (!isDesktop && !localSources.camera) {
resetLocalVideoElementHeight(videos.local);
}
} }
function getVideoElements() { function getVideoElements() {
const local = document.getElementById("local-video-stream"); const local = document.getElementById("local-video-stream");
@ -1312,6 +1314,11 @@ function getVideoElements() {
return; return;
return { local, localScreen, remote, remoteScreen }; return { local, localScreen, remote, remoteScreen };
} }
// Allow CSS to figure out the size of view by itself on Android because rotating to different orientation
// without dropping override will cause the view to have not normal proportion while no video is present
function resetLocalVideoElementHeight(local) {
local.style.height = "";
}
function desktopShowPermissionsAlert(mediaType) { function desktopShowPermissionsAlert(mediaType) {
if (!isDesktop) if (!isDesktop)
return; return;

View file

@ -15,8 +15,9 @@ body {
#remote-video-stream.collapsed { #remote-video-stream.collapsed {
position: absolute; position: absolute;
width: 20%;
max-width: 20%; max-width: 20%;
max-height: 20%; height: 15.03vw;
object-fit: cover; object-fit: cover;
margin: 16px; margin: 16px;
border-radius: 16px; border-radius: 16px;
@ -47,6 +48,7 @@ body {
position: absolute; position: absolute;
width: 20%; width: 20%;
max-width: 20%; max-width: 20%;
height: 15.03vw;
object-fit: cover; object-fit: cover;
margin: 16px; margin: 16px;
border-radius: 16px; border-radius: 16px;

View file

@ -12,26 +12,60 @@ body {
object-fit: cover; object-fit: cover;
} }
#remote-video-stream.collapsed { @media (orientation: portrait) {
position: absolute; #remote-video-stream.collapsed {
max-width: 30%; position: absolute;
max-height: 30%; width: 30%;
object-fit: cover; max-width: 30%;
margin: 16px; height: 39.9vw;
border-radius: 16px; object-fit: cover;
bottom: 80px; margin: 16px;
right: 0; border-radius: 16px;
bottom: 80px;
right: 0;
}
} }
#remote-video-stream.collapsed-pip { @media (orientation: landscape) {
position: absolute; #remote-video-stream.collapsed {
max-width: 50%; position: absolute;
max-height: 50%; width: 20%;
object-fit: cover; max-width: 20%;
margin: 8px; height: 15.03vw;
border-radius: 8px; object-fit: cover;
bottom: 0; margin: 16px;
right: 0; border-radius: 16px;
bottom: 80px;
right: 0;
}
}
@media (orientation: portrait) {
#remote-video-stream.collapsed-pip {
position: absolute;
width: 50%;
max-width: 50%;
height: 66.5vw;
object-fit: cover;
margin: 8px;
border-radius: 8px;
bottom: 0;
right: 0;
}
}
@media (orientation: landscape) {
#remote-video-stream.collapsed-pip {
position: absolute;
width: 50%;
max-width: 50%;
height: 37.59vw;
object-fit: cover;
margin: 8px;
border-radius: 8px;
bottom: 0;
right: 0;
}
} }
#remote-screen-video-stream.inline { #remote-screen-video-stream.inline {
@ -41,15 +75,32 @@ body {
object-fit: cover; object-fit: cover;
} }
#local-video-stream.inline { @media (orientation: portrait) {
position: absolute; #local-video-stream.inline {
width: 30%; position: absolute;
max-width: 30%; width: 30%;
object-fit: cover; max-width: 30%;
margin: 16px; height: 39.9vw;
border-radius: 16px; object-fit: cover;
top: 0; margin: 16px;
right: 0; border-radius: 16px;
top: 0;
right: 0;
}
}
@media (orientation: landscape) {
#local-video-stream.inline {
position: absolute;
width: 20%;
max-width: 20%;
height: 15.03vw;
object-fit: cover;
margin: 16px;
border-radius: 16px;
top: 0;
right: 0;
}
} }
#local-screen-video-stream.inline { #local-screen-video-stream.inline {

View file

@ -593,6 +593,7 @@ const processCommand = (function () {
) )
const videos = getVideoElements() const videos = getVideoElements()
if (videos) { if (videos) {
setupLocalVideoRatio(videos.local)
videos.local.srcObject = localStream videos.local.srcObject = localStream
videos.local.play().catch((e) => console.log(e)) videos.local.play().catch((e) => console.log(e))
} }
@ -621,9 +622,12 @@ const processCommand = (function () {
console.log("starting incoming call - create webrtc session") console.log("starting incoming call - create webrtc session")
if (activeCall) endCall() if (activeCall) endCall()
// It can be already defined on Android when switching calls (if the previous call was outgoing)
notConnectedCall = undefined
inactiveCallMediaSources.mic = true inactiveCallMediaSources.mic = true
inactiveCallMediaSources.camera = command.media == CallMediaType.Video inactiveCallMediaSources.camera = command.media == CallMediaType.Video
inactiveCallMediaSourcesChanged(inactiveCallMediaSources) inactiveCallMediaSourcesChanged(inactiveCallMediaSources)
setupLocalVideoRatio(getVideoElements()!.local)
const {media, iceServers, relay} = command const {media, iceServers, relay} = command
const encryption = supportsInsertableStreams(useWorker) const encryption = supportsInsertableStreams(useWorker)
@ -827,12 +831,12 @@ const processCommand = (function () {
} }
function endCall() { function endCall() {
shutdownCameraAndMic()
try { try {
activeCall?.connection?.close() activeCall?.connection?.close()
} catch (e) { } catch (e) {
console.log(e) console.log(e)
} }
shutdownCameraAndMic()
activeCall = undefined activeCall = undefined
resetVideoElements() resetVideoElements()
} }
@ -925,28 +929,21 @@ const processCommand = (function () {
} }
// Without doing it manually Firefox shows black screen but video can be played in Picture-in-Picture // Without doing it manually Firefox shows black screen but video can be played in Picture-in-Picture
videos.local.play().catch((e) => console.log(e)) videos.local.play().catch((e) => console.log(e))
setupLocalVideoRatio(videos.local)
} }
function setupLocalVideoRatio(local: HTMLVideoElement) { function setupLocalVideoRatio(local: HTMLVideoElement) {
const ratio = isDesktop ? 1.33 : 1 / 1.33
const currentRect = local.getBoundingClientRect()
// better to get percents from here than to hardcode values from styles (the styles can be changed)
const screenWidth = currentRect.left + currentRect.width
const percents = currentRect.width / screenWidth
local.style.width = `${percents * 100}%`
local.style.height = `${(percents / ratio) * 100}vw`
local.addEventListener("loadedmetadata", function () { local.addEventListener("loadedmetadata", function () {
console.log("Local video videoWidth: " + local.videoWidth + "px, videoHeight: " + local.videoHeight + "px") console.log("Local video videoWidth: " + local.videoWidth + "px, videoHeight: " + local.videoHeight + "px")
if (local.videoWidth == 0 || local.videoHeight == 0) return if (local.videoWidth == 0 || local.videoHeight == 0) return
local.style.height = `${(percents / (local.videoWidth / local.videoHeight)) * 100}vw` const ratio = local.videoWidth > local.videoHeight ? 0.2 : 0.3
local.style.height = `${(ratio / (local.videoWidth / local.videoHeight)) * 100}vw`
}) })
local.onresize = function () { local.onresize = function () {
console.log("Local video size changed to " + local.videoWidth + "x" + local.videoHeight) console.log("Local video size changed to " + local.videoWidth + "x" + local.videoHeight)
if (local.videoWidth == 0 || local.videoHeight == 0) return if (local.videoWidth == 0 || local.videoHeight == 0) return
local.style.height = `${(percents / (local.videoWidth / local.videoHeight)) * 100}vw` const ratio = local.videoWidth > local.videoHeight ? 0.2 : 0.3
local.style.height = `${(ratio / (local.videoWidth / local.videoHeight)) * 100}vw`
} }
} }
@ -1441,8 +1438,9 @@ const processCommand = (function () {
} }
function shutdownCameraAndMic() { function shutdownCameraAndMic() {
if (activeCall?.localStream) { if (activeCall) {
activeCall.localStream.getTracks().forEach((track) => track.stop()) activeCall.localStream.getTracks().forEach((track) => track.stop())
activeCall.localScreenStream.getTracks().forEach((track) => track.stop())
} }
} }
@ -1614,6 +1612,9 @@ function changeLayout(layout: LayoutType) {
break break
} }
videos.localScreen.style.visibility = localSources.screenVideo ? "visible" : "hidden" videos.localScreen.style.visibility = localSources.screenVideo ? "visible" : "hidden"
if (!isDesktop && !localSources.camera) {
resetLocalVideoElementHeight(videos.local)
}
} }
function getVideoElements(): VideoElements | undefined { function getVideoElements(): VideoElements | undefined {
@ -1637,6 +1638,12 @@ function getVideoElements(): VideoElements | undefined {
return {local, localScreen, remote, remoteScreen} return {local, localScreen, remote, remoteScreen}
} }
// Allow CSS to figure out the size of view by itself on Android because rotating to different orientation
// without dropping override will cause the view to have not normal proportion while no video is present
function resetLocalVideoElementHeight(local: HTMLVideoElement) {
local.style.height = ""
}
function desktopShowPermissionsAlert(mediaType: CallMediaType) { function desktopShowPermissionsAlert(mediaType: CallMediaType) {
if (!isDesktop) return if (!isDesktop) return

View file

@ -15,8 +15,9 @@ body {
#remote-video-stream.collapsed { #remote-video-stream.collapsed {
position: absolute; position: absolute;
width: 20%;
max-width: 20%; max-width: 20%;
max-height: 20%; height: 15.03vw;
object-fit: cover; object-fit: cover;
margin: 16px; margin: 16px;
border-radius: 16px; border-radius: 16px;
@ -47,6 +48,7 @@ body {
position: absolute; position: absolute;
width: 20%; width: 20%;
max-width: 20%; max-width: 20%;
height: 15.03vw;
object-fit: cover; object-fit: cover;
margin: 16px; margin: 16px;
border-radius: 16px; border-radius: 16px;