From ff7acf99320a872a7ec2f5e6a0e65be9e50a64f4 Mon Sep 17 00:00:00 2001
From: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
Date: Thu, 29 Feb 2024 10:30:23 -0500
Subject: [PATCH] localvideo: refactor preview component device control

Change-Id: Ibcd88c5a3c73a0e67f94d70bc420845aa7b8c822
---
 src/app/commoncomponents/LocalVideo.qml       |   2 +-
 src/app/currentcall.cpp                       |  45 +---
 src/app/currentconversation.cpp               |  60 ++---
 .../mainview/components/InCallLocalVideo.qml  | 211 +++++++++++++++++
 .../mainview/components/OngoingCallPage.qml   | 224 +-----------------
 .../components/VideoSettingsPage.qml          |   7 +-
 src/app/videodevices.cpp                      |  16 +-
 src/libclient/api/call.h                      |  42 ++++
 src/libclient/api/conversationmodel.h         |   2 +-
 src/libclient/conversationmodel.cpp           |   4 +-
 tests/qml/src/tst_OngoingCallPage.qml         |  11 +
 11 files changed, 318 insertions(+), 306 deletions(-)
 create mode 100644 src/app/mainview/components/InCallLocalVideo.qml

diff --git a/src/app/commoncomponents/LocalVideo.qml b/src/app/commoncomponents/LocalVideo.qml
index 88e28125a..c746f6d93 100644
--- a/src/app/commoncomponents/LocalVideo.qml
+++ b/src/app/commoncomponents/LocalVideo.qml
@@ -33,7 +33,7 @@ VideoView {
             stop();
             return;
         }
-        const forceRestart = rendererId === id;
+        const forceRestart = rendererId === id || force;
         if (!forceRestart) {
             // Stop previous device
             VideoDevices.stopDevice(rendererId);
diff --git a/src/app/currentcall.cpp b/src/app/currentcall.cpp
index 7ef2af43b..4238abc30 100644
--- a/src/app/currentcall.cpp
+++ b/src/app/currentcall.cpp
@@ -223,43 +223,14 @@ CurrentCall::updateCallInfo()
     set_isGrid(callInfo.layout == call::Layout::GRID);
     set_isAudioOnly(callInfo.isAudioOnly);
 
-    bool isAudioMuted {};
-    bool isVideoMuted {};
-    bool isSharing {};
-    QString sharingSource {};
-    bool isCapturing {};
-    QString previewId {};
-    using namespace libjami::Media;
-    if (callInfo.status != lrc::api::call::Status::ENDED) {
-        for (const auto& media : callInfo.mediaList) {
-            if (media[MediaAttributeKey::MEDIA_TYPE] == Details::MEDIA_TYPE_VIDEO) {
-                if (media[MediaAttributeKey::SOURCE].startsWith(VideoProtocolPrefix::DISPLAY)
-                    || media[MediaAttributeKey::SOURCE].startsWith(VideoProtocolPrefix::FILE)) {
-                    isSharing = true;
-                    sharingSource = media[MediaAttributeKey::SOURCE];
-                }
-                if (media[MediaAttributeKey::ENABLED] == TRUE_STR
-                    && media[MediaAttributeKey::MUTED] == FALSE_STR && previewId.isEmpty()) {
-                    previewId = media[libjami::Media::MediaAttributeKey::SOURCE];
-                }
-                if (media[libjami::Media::MediaAttributeKey::SOURCE].startsWith(
-                        libjami::Media::VideoProtocolPrefix::CAMERA)) {
-                    isVideoMuted |= media[MediaAttributeKey::MUTED] == TRUE_STR;
-                    isCapturing = media[MediaAttributeKey::MUTED] == FALSE_STR;
-                }
-            } else if (media[MediaAttributeKey::MEDIA_TYPE] == Details::MEDIA_TYPE_AUDIO) {
-                if (media[MediaAttributeKey::LABEL] == "audio_0") {
-                    isAudioMuted |= media[libjami::Media::MediaAttributeKey::MUTED] == TRUE_STR;
-                }
-            }
-        }
-    }
-    set_previewId(previewId);
-    set_isAudioMuted(isAudioMuted);
-    set_isVideoMuted(isVideoMuted);
-    set_isSharing(isSharing);
-    set_sharingSource(sharingSource);
-    set_isCapturing(isCapturing);
+    auto callInfoEx = callInfo.getCallInfoEx();
+    set_previewId(callInfoEx["preview_id"].toString());
+    set_isAudioMuted(callInfoEx["is_audio_muted"].toBool());
+    set_isVideoMuted(callInfoEx["is_video_muted"].toBool());
+    set_isSharing(callInfoEx["is_sharing"].toBool());
+    set_sharingSource(isSharing_ ? callInfoEx["preview_id"].toString() : QString());
+    set_isCapturing(callInfoEx["is_capturing"].toBool());
+
     set_isHandRaised(callModel->isHandRaised(id_));
     set_isModerator(callModel->isModerator(id_));
 
diff --git a/src/app/currentconversation.cpp b/src/app/currentconversation.cpp
index bc90057e2..44d7ddeaf 100644
--- a/src/app/currentconversation.cpp
+++ b/src/app/currentconversation.cpp
@@ -18,6 +18,8 @@
 
 #include "currentconversation.h"
 
+#include "global.h"
+
 #include <api/conversationmodel.h>
 #include <api/contact.h>
 
@@ -264,51 +266,39 @@ void
 CurrentConversation::connectModel()
 {
     membersModel_->setMembers({}, {}, {});
-    auto convModel = lrcInstance_->getCurrentConversationModel();
-    if (!convModel)
+
+    auto currentConversationModel = lrcInstance_->getCurrentConversationModel();
+    auto currentCallModel = lrcInstance_->getCurrentCallModel();
+    if (!currentConversationModel || !currentCallModel) {
+        C_DBG << "CurrentConversation: can't connect to unavailable models";
         return;
+    }
 
     auto connectObjectSignal = [this](auto obj, auto signal, auto slot) {
         connect(obj, signal, this, slot, Qt::UniqueConnection);
     };
 
-    connectObjectSignal(convModel,
+    connectObjectSignal(currentConversationModel,
                         &ConversationModel::conversationUpdated,
                         &CurrentConversation::onConversationUpdated);
-    connectObjectSignal(convModel,
+    connectObjectSignal(currentConversationModel,
                         &ConversationModel::profileUpdated,
                         &CurrentConversation::updateProfile);
-
-    connect(lrcInstance_->getCurrentConversationModel(),
-            &ConversationModel::profileUpdated,
-            this,
-            &CurrentConversation::updateProfile,
-            Qt::UniqueConnection);
-    connect(lrcInstance_->getCurrentConversationModel(),
-            &ConversationModel::onConversationErrorsUpdated,
-            this,
-            &CurrentConversation::updateErrors,
-            Qt::UniqueConnection);
-    connect(lrcInstance_->getCurrentConversationModel(),
-            &ConversationModel::activeCallsChanged,
-            this,
-            &CurrentConversation::updateActiveCalls,
-            Qt::UniqueConnection);
-    connect(lrcInstance_->getCurrentConversationModel(),
-            &ConversationModel::conversationPreferencesUpdated,
-            this,
-            &CurrentConversation::updateConversationPreferences,
-            Qt::UniqueConnection);
-    connect(lrcInstance_->getCurrentConversationModel(),
-            &ConversationModel::needsHost,
-            this,
-            &CurrentConversation::onNeedsHost,
-            Qt::UniqueConnection);
-    connect(lrcInstance_->getCurrentCallModel(),
-            &CallModel::callStatusChanged,
-            this,
-            &CurrentConversation::onCallStatusChanged,
-            Qt::UniqueConnection);
+    connectObjectSignal(currentConversationModel,
+                        &ConversationModel::conversationErrorsUpdated,
+                        &CurrentConversation::updateErrors);
+    connectObjectSignal(currentConversationModel,
+                        &ConversationModel::activeCallsChanged,
+                        &CurrentConversation::updateActiveCalls);
+    connectObjectSignal(currentConversationModel,
+                        &ConversationModel::conversationPreferencesUpdated,
+                        &CurrentConversation::updateConversationPreferences);
+    connectObjectSignal(currentConversationModel,
+                        &ConversationModel::needsHost,
+                        &CurrentConversation::onNeedsHost);
+    connectObjectSignal(currentCallModel,
+                        &CallModel::callStatusChanged,
+                        &CurrentConversation::onCallStatusChanged);
 }
 
 void
diff --git a/src/app/mainview/components/InCallLocalVideo.qml b/src/app/mainview/components/InCallLocalVideo.qml
new file mode 100644
index 000000000..1b244b691
--- /dev/null
+++ b/src/app/mainview/components/InCallLocalVideo.qml
@@ -0,0 +1,211 @@
+/*
+ * Copyright (C) 2024 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import QtQuick
+import QtQuick.Controls
+import Qt5Compat.GraphicalEffects
+
+import net.jami.Enums 1.1
+import net.jami.Constants 1.1
+import net.jami.Adapters 1.1
+
+import "../../commoncomponents"
+
+// This component uses anchors and they are set within this component.
+LocalVideo {
+    id: localPreview
+
+    required property var container
+    required property real opacityModifier
+
+    readonly property int previewMargin: 15
+    readonly property int previewMarginYTop: previewMargin + 42
+    readonly property int previewMarginYBottom: previewMargin + 84
+
+    anchors.bottomMargin: previewMarginYBottom
+    anchors.leftMargin: sideMargin
+    anchors.rightMargin: sideMargin
+    anchors.topMargin: previewMarginYTop
+
+    visibilityCondition: (CurrentCall.isSharing || !CurrentCall.isVideoMuted) &&
+                         !CurrentCall.isConference
+    height: width * invAspectRatio
+    width: Math.max(container.width / 5, JamiTheme.minimumPreviewWidth)
+    flip: CurrentCall.flipSelf && !CurrentCall.isSharing
+    blurRadius: hidden ? 25 : 0
+
+    opacity: hidden ? opacityModifier : 1
+
+    // Allow hiding the preview (available when anchored)
+    readonly property bool hovered: hoverHandler.hovered
+    readonly property bool anchored: state !== "unanchored"
+    property bool hidden: false
+    readonly property real hiddenHandleSize: 32
+    // Compute the margin as a function of the preview width in order to
+    // apply a negative margin and expose a constant width handle.
+    // If not hidden, return the previewMargin.
+    property real sideMargin: !hidden ? previewMargin : -(width - hiddenHandleSize)
+    // Animate the hiddenSize with a Behavior.
+    Behavior on sideMargin { NumberAnimation { duration: 250; easing.type: Easing.OutExpo }}
+    readonly property bool onLeft: state.indexOf("left") !== -1
+    PushButton {
+        id: hidePreviewButton
+        objectName: "hidePreviewButton"
+
+        width: localPreview.hiddenHandleSize
+        state: localPreview.onLeft ?
+                   (localPreview.hidden ? "right" : "left") :
+                   (localPreview.hidden ? "left" : "right")
+        states: [
+            State {
+                name: "left"
+                AnchorChanges {
+                    target: hidePreviewButton
+                    anchors.left: parent.left
+                }
+            },
+            State {
+                name: "right"
+                AnchorChanges {
+                    target: hidePreviewButton
+                    anchors.right: parent.right
+                }
+            }
+        ]
+        anchors.top: parent.top
+        anchors.bottom: parent.bottom
+        opacity: (localPreview.anchored && localPreview.hovered) || localPreview.hidden
+        Behavior on opacity { NumberAnimation { duration: 250; easing.type: Easing.OutExpo }}
+        visible: opacity > 0
+        background: Rectangle {
+            readonly property color normalColor: JamiTheme.mediumGrey
+            color: JamiTheme.mediumGrey
+            opacity: hidePreviewButton.hovered ? 0.7 : 0.5
+            Behavior on opacity { NumberAnimation { duration: 250; easing.type: Easing.OutExpo }}
+        }
+        normalImageSource: hidePreviewButton.state === "left" ?
+                               JamiResources.chevron_left_black_24dp_svg :
+                               JamiResources.chevron_right_black_24dp_svg
+        imageColor: JamiTheme.darkGreyColor
+        onClicked: localPreview.hidden = !localPreview.hidden
+        toolTipText: localPreview.hidden ?
+                         JamiStrings.showLocalVideo :
+                         JamiStrings.hideLocalVideo
+    }
+
+    state: "anchor_top_right"
+    states: [
+        State {
+            name: "unanchored"
+            AnchorChanges {
+                target: localPreview
+                anchors.top: undefined
+                anchors.right: undefined
+                anchors.bottom: undefined
+                anchors.left: undefined
+            }
+        },
+        State {
+            name: "anchor_top_left"
+            AnchorChanges {
+                target: localPreview
+                anchors.top: localPreview.container.top
+                anchors.left: localPreview.container.left
+            }
+        },
+        State {
+            name: "anchor_top_right"
+            AnchorChanges {
+                target: localPreview
+                anchors.top: localPreview.container.top
+                anchors.right: localPreview.container.right
+            }
+        },
+        State {
+            name: "anchor_bottom_right"
+            AnchorChanges {
+                target: localPreview
+                anchors.bottom: localPreview.container.bottom
+                anchors.right: localPreview.container.right
+            }
+        },
+        State {
+            name: "anchor_bottom_left"
+            AnchorChanges {
+                target: localPreview
+                anchors.bottom: localPreview.container.bottom
+                anchors.left: localPreview.container.left
+            }
+        }
+    ]
+
+    transitions: Transition {
+        AnchorAnimation {
+            duration: 250
+            easing.type: Easing.OutBack
+            easing.overshoot: 1.5
+        }
+    }
+
+    HoverHandler {
+        id: hoverHandler
+    }
+
+    DragHandler {
+        id: dragHandler
+        readonly property var container: localPreview.container
+        target: parent
+        dragThreshold: 4
+        enabled: !localPreview.hidden
+        xAxis.maximum: container.width - parent.width - previewMargin
+        xAxis.minimum: previewMargin
+        yAxis.maximum: container.height - parent.height - previewMarginYBottom
+        yAxis.minimum: previewMarginYTop
+        onActiveChanged: {
+            if (active) {
+                localPreview.state = "unanchored";
+            } else {
+                const center = Qt.point(target.x + target.width / 2,
+                                        target.y + target.height / 2);
+                const containerCenter = Qt.point(container.x + container.width / 2,
+                                                 container.y + container.height / 2);
+                if (center.x >= containerCenter.x) {
+                    if (center.y >= containerCenter.y) {
+                        localPreview.state = "anchor_bottom_right";
+                    } else {
+                        localPreview.state = "anchor_top_right";
+                    }
+                } else {
+                    if (center.y >= containerCenter.y) {
+                        localPreview.state = "anchor_bottom_left";
+                    } else {
+                        localPreview.state = "anchor_top_left";
+                    }
+                }
+            }
+        }
+    }
+
+    layer.enabled: true
+    layer.effect: OpacityMask {
+        maskSource: Rectangle {
+            width: localPreview.width
+            height: localPreview.height
+            radius: JamiTheme.primaryRadius
+        }
+    }
+}
diff --git a/src/app/mainview/components/OngoingCallPage.qml b/src/app/mainview/components/OngoingCallPage.qml
index 429ca7d92..8c5f401fb 100644
--- a/src/app/mainview/components/OngoingCallPage.qml
+++ b/src/app/mainview/components/OngoingCallPage.qml
@@ -30,11 +30,6 @@ import "../../commoncomponents"
 Rectangle {
     id: root
 
-    // Constraints for the preview component.
-    property int previewMargin: 15
-    property int previewMarginYTop: previewMargin + 42
-    property int previewMarginYBottom: previewMargin + 84
-
     property alias chatViewContainer: chatViewContainer
     property string callPreviewId
 
@@ -166,222 +161,15 @@ Rectangle {
                 }
             }
 
-            LocalVideo {
+            // Note: this component should not be used within a layout, as
+            // it implements anchor management itself.
+            InCallLocalVideo {
                 id: localPreview
                 objectName: "localPreview"
 
-                readonly property var container: parent
-                readonly property string callPreviewId: root.callPreviewId
-
-                visibilityCondition: (CurrentCall.isSharing || !CurrentCall.isVideoMuted) &&
-                                     !CurrentCall.isConference
-                height: width * invAspectRatio
-
-                // Keep the area of the preview a proportion of the screen size plus a
-                // modifier to allow the user to scale it.
-                readonly property real containerArea: container.width * container.height
-                property real scalingFactor: 1
-                width: Math.sqrt(containerArea / 16) * scalingFactor
-                flip: CurrentCall.flipSelf && !CurrentCall.isSharing
-                blurRadius: hidden ? 25 : 0
-                onCallPreviewIdChanged: startWithId(callPreviewId)
-                onVisibleChanged: if (!visible) stop()
-
-                anchors.topMargin: previewMarginYTop
-                anchors.leftMargin: sideMargin
-                anchors.rightMargin: sideMargin
-                anchors.bottomMargin: previewMarginYBottom
-
-                opacity: hidden ? callOverlay.mainOverlayOpacity : 1
-
-                // Allow hiding the preview (available when anchored)
-                readonly property bool hovered: hoverHandler.hovered
-                readonly property bool anchored: state !== "unanchored"
-                property bool hidden: false
-                readonly property real hiddenHandleSize: 32
-                // Compute the margin as a function of the preview width in order to
-                // apply a negative margin and expose a constant width handle.
-                // If not hidden, return the previewMargin.
-                property real sideMargin: !hidden ? previewMargin : -(width - hiddenHandleSize)
-                // Animate the hiddenSize with a Behavior.
-                Behavior on sideMargin { NumberAnimation { duration: 250; easing.type: Easing.OutExpo }}
-                readonly property bool onLeft: state.indexOf("left") !== -1
-                PushButton {
-                    id: hidePreviewButton
-                    objectName: "hidePreviewButton"
-
-                    width: localPreview.hiddenHandleSize
-                    state: {
-                        if (!localPreview.anchored) {
-                            return "none";
-                        }
-                        return localPreview.onLeft ?
-                                    (localPreview.hidden ? "right" : "left") :
-                                    (localPreview.hidden ? "left" : "right")
-                    }
-                    states: [
-                        State {
-                            name: "none"
-                            // Override visible to false when the localPreview isn't anchored.
-                            PropertyChanges {
-                                target: hidePreviewButton
-                                visible: false
-                            }
-                        },
-                        State {
-                            name: "left"
-                            AnchorChanges {
-                                target: hidePreviewButton
-                                anchors.left: parent.left
-                            }
-                        },
-                        State {
-                            name: "right"
-                            AnchorChanges {
-                                target: hidePreviewButton
-                                anchors.right: parent.right
-                            }
-                        }
-                    ]
-                    anchors.top: parent.top
-                    anchors.bottom: parent.bottom
-                    opacity: (localPreview.anchored && localPreview.hovered) || localPreview.hidden
-                    Behavior on opacity { NumberAnimation { duration: 250; easing.type: Easing.OutExpo }}
-                    visible: opacity > 0
-                    background: Rectangle {
-                        readonly property color normalColor: JamiTheme.mediumGrey
-                        color: JamiTheme.mediumGrey
-                        opacity: hidePreviewButton.hovered ? 0.7 : 0.5
-                        Behavior on opacity { NumberAnimation { duration: 250; easing.type: Easing.OutExpo }}
-                    }
-                    normalImageSource: hidePreviewButton.state === "left" ?
-                                           JamiResources.chevron_left_black_24dp_svg :
-                                           JamiResources.chevron_right_black_24dp_svg
-                    imageColor: JamiTheme.darkGreyColor
-                    onClicked: localPreview.hidden = !localPreview.hidden
-                    toolTipText: localPreview.hidden ?
-                                     JamiStrings.showLocalVideo :
-                                     JamiStrings.hideLocalVideo
-                }
-
-                state: "anchor_top_right"
-                states: [
-                    State {
-                        name: "unanchored"
-                        AnchorChanges {
-                            target: localPreview
-                            anchors.top: undefined
-                            anchors.right: undefined
-                            anchors.bottom: undefined
-                            anchors.left: undefined
-                        }
-                    },
-                    State {
-                        name: "anchor_top_left"
-                        AnchorChanges {
-                            target: localPreview
-                            anchors.top: localPreview.container.top
-                            anchors.left: localPreview.container.left
-                        }
-                    },
-                    State {
-                        name: "anchor_top_right"
-                        AnchorChanges {
-                            target: localPreview
-                            anchors.top: localPreview.container.top
-                            anchors.right: localPreview.container.right
-                        }
-                    },
-                    State {
-                        name: "anchor_bottom_right"
-                        AnchorChanges {
-                            target: localPreview
-                            anchors.bottom: localPreview.container.bottom
-                            anchors.right: localPreview.container.right
-                        }
-                    },
-                    State {
-                        name: "anchor_bottom_left"
-                        AnchorChanges {
-                            target: localPreview
-                            anchors.bottom: localPreview.container.bottom
-                            anchors.left: localPreview.container.left
-                        }
-                    }
-                ]
-
-                transitions: Transition {
-                    AnchorAnimation {
-                        duration: 250
-                        easing.type: Easing.OutBack
-                        easing.overshoot: 1.5
-                    }
-                }
-
-                HoverHandler {
-                    id: hoverHandler
-                }
-
-                WheelHandler {
-                    onWheel: function(event) {
-                        const delta = event.angleDelta.y / 120 * 0.1;
-                        parent.opacity = JamiQmlUtils.clamp(parent.opacity + delta, 0.25, 1);
-                    }
-                    acceptedModifiers: Qt.CTRL
-                }
-
-                WheelHandler {
-                    onWheel: function(event) {
-                        const delta = event.angleDelta.y / 120 * 0.1;
-                        localPreview.scalingFactor = JamiQmlUtils.clamp(localPreview.scalingFactor + delta, 0.5, 4);
-                    }
-                    acceptedModifiers: Qt.NoModifier
-                    enabled: !localPreview.hidden
-                }
-
-                DragHandler {
-                    id: dragHandler
-                    readonly property var container: localPreview.container
-                    target: parent
-                    dragThreshold: 4
-                    enabled: !localPreview.hidden
-                    xAxis.maximum: container.width - parent.width - previewMargin
-                    xAxis.minimum: previewMargin
-                    yAxis.maximum: container.height - parent.height - previewMarginYBottom
-                    yAxis.minimum: previewMarginYTop
-                    onActiveChanged: {
-                        if (active) {
-                            localPreview.state = "unanchored";
-                        } else {
-                            const center = Qt.point(target.x + target.width / 2,
-                                                    target.y + target.height / 2);
-                            const containerCenter = Qt.point(container.x + container.width / 2,
-                                                             container.y + container.height / 2);
-                            if (center.x >= containerCenter.x) {
-                                if (center.y >= containerCenter.y) {
-                                    localPreview.state = "anchor_bottom_right";
-                                } else {
-                                    localPreview.state = "anchor_top_right";
-                                }
-                            } else {
-                                if (center.y >= containerCenter.y) {
-                                    localPreview.state = "anchor_bottom_left";
-                                } else {
-                                    localPreview.state = "anchor_top_left";
-                                }
-                            }
-                        }
-                    }
-                }
-
-                layer.enabled: true
-                layer.effect: OpacityMask {
-                    maskSource: Rectangle {
-                        width: localPreview.width
-                        height: localPreview.height
-                        radius: JamiTheme.primaryRadius
-                    }
-                }
+                container: parent
+                rendererId: CurrentCall.previewId
+                opacityModifier: callOverlay.mainOverlayOpacity
             }
 
             CallOverlay {
diff --git a/src/app/settingsview/components/VideoSettingsPage.qml b/src/app/settingsview/components/VideoSettingsPage.qml
index 7ce709e10..712b07ab4 100644
--- a/src/app/settingsview/components/VideoSettingsPage.qml
+++ b/src/app/settingsview/components/VideoSettingsPage.qml
@@ -80,13 +80,10 @@ SettingsPageBase {
         Component.onCompleted: {
             flipControl.checked = UtilsAdapter.getAppValue(Settings.FlipSelf);
             hardwareAccelControl.checked = AvAdapter.getHardwareAcceleration();
-            if (previewWidget.visible)
-                startPreviewing(true);
+            startPreviewing(true);
         }
 
-        Component.onDestruction: {
-            previewWidget.startWithId("");
-        }
+        Component.onDestruction: previewWidget.stop()
 
         // video Preview
         Rectangle {
diff --git a/src/app/videodevices.cpp b/src/app/videodevices.cpp
index 86eb9eed3..013d502f7 100644
--- a/src/app/videodevices.cpp
+++ b/src/app/videodevices.cpp
@@ -256,13 +256,15 @@ VideoDevices::startDevice(const QString& id, bool force)
 void
 VideoDevices::stopDevice(const QString& id)
 {
-    if (!id.isEmpty()) {
-        qInfo() << "Stopping device" << id;
-        if (lrcInstance_->avModel().stopPreview(id)) {
-            deviceOpen_ = false;
-        } else {
-            qWarning() << "Failed to stop device" << id;
-        }
+    if (id.isEmpty()) {
+        return;
+    }
+
+    qInfo() << "Stopping device" << id;
+    if (lrcInstance_->avModel().stopPreview(id)) {
+        deviceOpen_ = false;
+    } else {
+        qWarning() << "Failed to stop device" << id;
     }
 }
 
diff --git a/src/libclient/api/call.h b/src/libclient/api/call.h
index 7328df4f6..d386e72a7 100644
--- a/src/libclient/api/call.h
+++ b/src/libclient/api/call.h
@@ -149,6 +149,48 @@ struct Info
                 return true;
         return false;
     }
+
+    // Extract some common meta data for this call including:
+    // - the video preview ID
+    // - audio/video muted status
+    // - if the call is sharing (indicating that the preview is a screen share)
+    QVariantMap getCallInfoEx() const
+    {
+        bool isAudioMuted = false;
+        bool isVideoMuted = false;
+        QString previewId;
+        QVariantMap callInfo;
+        using namespace libjami::Media;
+        if (status == lrc::api::call::Status::ENDED) {
+            return {};
+        }
+        for (const auto& media : mediaList) {
+            if (media[MediaAttributeKey::MEDIA_TYPE] == Details::MEDIA_TYPE_VIDEO) {
+                if (media[MediaAttributeKey::SOURCE].startsWith(VideoProtocolPrefix::DISPLAY)
+                    || media[MediaAttributeKey::SOURCE].startsWith(VideoProtocolPrefix::FILE)) {
+                    callInfo["is_sharing"] = true;
+                    callInfo["preview_id"] = media[MediaAttributeKey::SOURCE];
+                }
+                if (media[MediaAttributeKey::ENABLED] == TRUE_STR
+                    && media[MediaAttributeKey::MUTED] == FALSE_STR && previewId.isEmpty()) {
+                    previewId = media[libjami::Media::MediaAttributeKey::SOURCE];
+                }
+                if (media[libjami::Media::MediaAttributeKey::SOURCE].startsWith(
+                        libjami::Media::VideoProtocolPrefix::CAMERA)) {
+                    isVideoMuted |= media[MediaAttributeKey::MUTED] == TRUE_STR;
+                    callInfo["is_capturing"] = media[MediaAttributeKey::MUTED] == FALSE_STR;
+                }
+            } else if (media[MediaAttributeKey::MEDIA_TYPE] == Details::MEDIA_TYPE_AUDIO) {
+                if (media[MediaAttributeKey::LABEL] == "audio_0") {
+                    isAudioMuted |= media[libjami::Media::MediaAttributeKey::MUTED] == TRUE_STR;
+                }
+            }
+        }
+        callInfo["preview_id"] = previewId;
+        callInfo["is_audio_muted"] = isAudioMuted;
+        callInfo["is_video_muted"] = isVideoMuted;
+        return callInfo;
+    }
 };
 
 static inline bool
diff --git a/src/libclient/api/conversationmodel.h b/src/libclient/api/conversationmodel.h
index 414ba3fbf..707ac3a75 100644
--- a/src/libclient/api/conversationmodel.h
+++ b/src/libclient/api/conversationmodel.h
@@ -464,7 +464,7 @@ Q_SIGNALS:
      * Emitted when a conversation detects an error
      * @param uid
      */
-    void onConversationErrorsUpdated(const QString& uid) const;
+    void conversationErrorsUpdated(const QString& uid) const;
     /**
      * Emitted when conversation's preferences has been updated
      * @param uid
diff --git a/src/libclient/conversationmodel.cpp b/src/libclient/conversationmodel.cpp
index 525c07d49..63d644dae 100644
--- a/src/libclient/conversationmodel.cpp
+++ b/src/libclient/conversationmodel.cpp
@@ -1089,7 +1089,7 @@ ConversationModel::popFrontError(const QString& conversationId)
 
     auto& conversation = conversationOpt->get();
     conversation.errors.pop_front();
-    Q_EMIT onConversationErrorsUpdated(conversationId);
+    Q_EMIT conversationErrorsUpdated(conversationId);
 }
 
 void
@@ -2706,7 +2706,7 @@ ConversationModelPimpl::slotOnConversationError(const QString& accountId,
     try {
         auto& conversation = getConversationForUid(conversationId).get();
         conversation.errors.push_back({code, what});
-        Q_EMIT linked.onConversationErrorsUpdated(conversationId);
+        Q_EMIT linked.conversationErrorsUpdated(conversationId);
     } catch (...) {
     }
 }
diff --git a/tests/qml/src/tst_OngoingCallPage.qml b/tests/qml/src/tst_OngoingCallPage.qml
index 473d3af87..31b58d1f9 100644
--- a/tests/qml/src/tst_OngoingCallPage.qml
+++ b/tests/qml/src/tst_OngoingCallPage.qml
@@ -180,6 +180,17 @@ TestWrapper {
                     compare(localPreview.hidden, false);
                 });
             }
+
+            function test_localPreviewRemainsVisibleWhenOngoingCallPageIsToggled() {
+                localPreviewTestWrapper(function(localPreview) {
+                    // The local preview should remain visible when the OngoingCallPage is toggled.
+                    compare(localPreview.visible, true);
+                    uut.visible = false;
+                    compare(localPreview.visible, false);
+                    uut.visible = true;
+                    compare(localPreview.visible, true);
+                });
+            }
         }
     }
 }
-- 
GitLab