1
0
Fork 0
mirror of https://git.jami.net/savoirfairelinux/jami-client-qt.git synced 2025-07-14 20:45:23 +02:00

localvideo: refactor preview component device control

Change-Id: Ibcd88c5a3c73a0e67f94d70bc420845aa7b8c822
This commit is contained in:
Andreas Traczyk 2024-02-29 10:30:23 -05:00 committed by François-Simon Fauteux-Chapleau
parent afde816b23
commit ff7acf9932
11 changed files with 318 additions and 306 deletions

View file

@ -33,7 +33,7 @@ VideoView {
stop();
return;
}
const forceRestart = rendererId === id;
const forceRestart = rendererId === id || force;
if (!forceRestart) {
// Stop previous device
VideoDevices.stopDevice(rendererId);

View file

@ -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_));

View file

@ -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

View file

@ -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
}
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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;
}
}

View file

@ -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

View file

@ -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

View file

@ -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 (...) {
}
}

View file

@ -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);
});
}
}
}
}