diff --git a/images/icons/moderator.svg b/images/icons/moderator.svg new file mode 100644 index 0000000000000000000000000000000000000000..60bc71b7e0bd58b909b7f769c319e7b341609b38 --- /dev/null +++ b/images/icons/moderator.svg @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <title>Moderateur</title> + <g id="Icones_Outline" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="Moderateur" fill-rule="nonzero" stroke="#000000" stroke-width="1.3"> + <path d="M12.0856077,6.17414866 L15.2188905,10.399333 L21.4126996,6.23175137 L21.4126996,18.3907977 L2.73757526,18.3907977 L2.73757526,6.23175137 L8.92952352,10.3980809 L12.0856077,6.17414866 Z" id="Shape"></path> + </g> + </g> +</svg> \ No newline at end of file diff --git a/qml.qrc b/qml.qrc index 7f2d12277203836d5f422d0cd0368bdb1da4b8b0..b06ef1cb9cd67a7b42af9923b99b4638bb818b7e 100644 --- a/qml.qrc +++ b/qml.qrc @@ -102,7 +102,6 @@ <file>src/commoncomponents/GeneralMenuItem.qml</file> <file>src/mainview/components/ConversationSmartListContextMenu.qml</file> <file>src/mainview/components/CallViewContextMenu.qml</file> - <file>src/mainview/components/ParticipantContextMenu.qml</file> <file>src/commoncomponents/GeneralMenuSeparator.qml</file> <file>src/mainview/components/UserProfile.qml</file> <file>src/mainview/js/videodevicecontextmenuitemcreation.js</file> @@ -137,5 +136,6 @@ <file>src/commoncomponents/ResponsiveImage.qml</file> <file>src/commoncomponents/PresenceIndicator.qml</file> <file>src/commoncomponents/AvatarImage.qml</file> + <file>src/mainview/components/ParticipantOverlayMenu.qml</file> </qresource> </RCC> diff --git a/resources.qrc b/resources.qrc index efc77e06afa7ce8ae8c256f66c310563303498c9..6d7dbdab1720c8a24a509d3c22ae0c9118b96188 100644 --- a/resources.qrc +++ b/resources.qrc @@ -135,5 +135,6 @@ <file>images/icons/settings_backup_restore-24px.svg</file> <file>images/logo-jami-standard-coul.svg</file> <file>images/logo-jami-standard-coul-white.svg</file> + <file>images/icons/moderator.svg</file> </qresource> </RCC> diff --git a/src/calladapter.cpp b/src/calladapter.cpp index 2797a82d2923ea9a8baa68bc3dbb560c66546a9b..d3e5f363edaeb004545bb7b7c3e9d08990c9bfa1 100644 --- a/src/calladapter.cpp +++ b/src/calladapter.cpp @@ -399,6 +399,7 @@ CallAdapter::connectCallModel(const QString& accountId) const auto convInfo = LRCInstance::getConversationFromCallId(callId); if (!convInfo.uid.isEmpty()) { emit callStatusChanged(static_cast<int>(call.status), accountId, convInfo.uid); + updateCallOverlay(convInfo); } switch (call.status) { @@ -531,14 +532,13 @@ CallAdapter::hangupCall(const QString& uri) } } } - callModel->hangUp(convInfo.callId); } } } void -CallAdapter::maximizeParticipant(const QString& uri, bool isActive) +CallAdapter::maximizeParticipant(const QString& uri) { auto* callModel = LRCInstance::getAccountInfo(accountId_).callModel.get(); auto* convModel = LRCInstance::getCurrentConversationModel(); @@ -547,48 +547,53 @@ CallAdapter::maximizeParticipant(const QString& uri, bool isActive) if (confId.isEmpty()) confId = conversation.callId; try { - const auto call = callModel->getCall(confId); - switch (call.layout) { - case lrc::api::call::Layout::GRID: - callModel->setActiveParticipant(confId, uri); - callModel->setConferenceLayout(confId, lrc::api::call::Layout::ONE_WITH_SMALL); - break; - case lrc::api::call::Layout::ONE_WITH_SMALL: - callModel->setActiveParticipant(confId, uri); - callModel->setConferenceLayout(confId, - isActive ? lrc::api::call::Layout::ONE - : lrc::api::call::Layout::ONE_WITH_SMALL); - break; - case lrc::api::call::Layout::ONE: - callModel->setActiveParticipant(confId, uri); - callModel->setConferenceLayout(confId, lrc::api::call::Layout::GRID); - break; - }; + auto call = callModel->getCall(confId); + if (call.participantsInfos.size() > 0) { + for (const auto& participant : call.participantsInfos) { + if (participant["uri"] == uri) { + if (participant["active"] == "false") { + callModel->setActiveParticipant(confId, uri); + callModel->setConferenceLayout(confId, lrc::api::call::Layout::ONE_WITH_SMALL); + } else if (participant["y"].toInt() != 0) { + callModel->setActiveParticipant(confId, uri); + callModel->setConferenceLayout(confId, lrc::api::call::Layout::ONE); + } else { + callModel->setConferenceLayout(confId, lrc::api::call::Layout::GRID); + } + return; + } + } + } } catch (...) { } } void -CallAdapter::minimizeParticipant() +CallAdapter::minimizeParticipant(const QString& uri) { auto* callModel = LRCInstance::getAccountInfo(accountId_).callModel.get(); auto* convModel = LRCInstance::getCurrentConversationModel(); const auto conversation = convModel->getConversationForUID(LRCInstance::getCurrentConvUid()); auto confId = conversation.confId; + if (confId.isEmpty()) confId = conversation.callId; try { auto call = callModel->getCall(confId); - switch (call.layout) { - case lrc::api::call::Layout::GRID: - break; - case lrc::api::call::Layout::ONE_WITH_SMALL: - callModel->setConferenceLayout(confId, lrc::api::call::Layout::GRID); - break; - case lrc::api::call::Layout::ONE: - callModel->setConferenceLayout(confId, lrc::api::call::Layout::ONE_WITH_SMALL); - break; - }; + if (call.participantsInfos.size() > 0) { + for (const auto& participant : call.participantsInfos) { + if (participant["uri"] == uri) { + if (participant["active"] == "true") { + if (participant["y"].toInt() == 0) { + callModel->setConferenceLayout(confId, lrc::api::call::Layout::ONE_WITH_SMALL); + } else { + callModel->setConferenceLayout(confId, lrc::api::call::Layout::GRID); + } + } + return; + } + } + } } catch (...) { } } @@ -626,7 +631,10 @@ CallAdapter::isCurrentHost() const if (!convInfo.uid.isEmpty()) { auto* callModel = LRCInstance::getAccountInfo(accountId_).callModel.get(); try { - auto call = callModel->getCall(convInfo.callId); + auto confId = convInfo.confId; + if (confId.isEmpty()) + confId = convInfo.callId; + auto call = callModel->getCall(confId); if (call.participantsInfos.size() == 0) { return true; } else { @@ -647,13 +655,12 @@ CallAdapter::participantIsHost(const QString& uri) const auto& accInfo = LRCInstance::getAccountInfo(accountId_); auto* callModel = accInfo.callModel.get(); try { - auto call = callModel->getCall(convInfo.callId); - if (call.participantsInfos.size() == 0) { - return (uri.isEmpty() || uri == accInfo.profileInfo.uri); + if (isCurrentHost()) { + return uri == accInfo.profileInfo.uri; } else { - return !convInfo.confId.isEmpty() - && callModel->hasCall(convInfo.confId) - && (uri.isEmpty() || uri == accInfo.profileInfo.uri); + auto call = callModel->getCall(convInfo.callId); + auto peer = call.peerUri.remove("ring:"); + return (uri == peer); } } catch (...) { } @@ -778,22 +785,6 @@ CallAdapter::isCurrentMuted() const return true; } -int -CallAdapter::getCurrentLayoutType() const -{ - auto* convModel = LRCInstance::getCurrentConversationModel(); - const auto convInfo = convModel->getConversationForUID(convUid_); - if (!convInfo.uid.isEmpty()) { - auto* callModel = LRCInstance::getAccountInfo(accountId_).callModel.get(); - try { - auto call = callModel->getCall(convInfo.confId); - return static_cast<int>(call.layout); - } catch (...) { - } - } - return -1; -} - void CallAdapter::holdThisCallToggle() { diff --git a/src/calladapter.h b/src/calladapter.h index af6a3a25526f92eeb0edda6e35b0370040483174..f33e6679b8924f026a81260ed38a9b4a53f77bad 100644 --- a/src/calladapter.h +++ b/src/calladapter.h @@ -53,15 +53,14 @@ public: * For Call Overlay */ Q_INVOKABLE void hangupCall(const QString& uri); - Q_INVOKABLE void maximizeParticipant(const QString& uri, bool isActive); - Q_INVOKABLE void minimizeParticipant(); + Q_INVOKABLE void maximizeParticipant(const QString& uri); + Q_INVOKABLE void minimizeParticipant(const QString& uri); Q_INVOKABLE void hangUpThisCall(); - Q_INVOKABLE void setModerator(const QString& uri, const bool state); Q_INVOKABLE bool isCurrentHost() const; - Q_INVOKABLE bool participantIsHost(const QString& uri = {}) const; + Q_INVOKABLE bool participantIsHost(const QString& uri) const; + Q_INVOKABLE void setModerator(const QString& uri, const bool state); Q_INVOKABLE bool isModerator(const QString& uri = {}) const; Q_INVOKABLE bool isCurrentModerator() const; - Q_INVOKABLE int getCurrentLayoutType() const; Q_INVOKABLE void holdThisCallToggle(); Q_INVOKABLE void muteThisCallToggle(); Q_INVOKABLE void recordThisCallToggle(); diff --git a/src/commoncomponents/ResponsiveImage.qml b/src/commoncomponents/ResponsiveImage.qml index 08d1eb7289a9dbe95b625c728bba1659053b2476..6fe7da23b9ffc448b6154d0b551a05cfd2aedac1 100644 --- a/src/commoncomponents/ResponsiveImage.qml +++ b/src/commoncomponents/ResponsiveImage.qml @@ -62,8 +62,8 @@ Image { function setSourceSize() { if (isSvg) { - sourceSize.width = Math.max(24, width) - sourceSize.height = Math.max(24, height) + sourceSize.width = width + sourceSize.height = height } else sourceSize = undefined } diff --git a/src/constant/JamiStrings.qml b/src/constant/JamiStrings.qml index 97c17cb06b192348c3575942267286c90e1482fd..517fa28b94654de0b4b8cd3766dd1dbc145dfd77 100644 --- a/src/constant/JamiStrings.qml +++ b/src/constant/JamiStrings.qml @@ -397,4 +397,14 @@ Item { // Generic dialog options property string optionOk: qsTr("Ok") property string optionCancel: qsTr("Cancel") + + // Conference moderation + property string setModerator: qsTr("Set moderator") + property string unsetModerator: qsTr("Unset moderator") + property string muteParticipant: qsTr("Mute") + property string unmuteParticipant: qsTr("Unmute") + property string maximizeParticipant: qsTr("Maximize") + property string minimizeParticipant: qsTr("Minimize") + property string hangupParticipant: qsTr("Hangup") } + diff --git a/src/constant/JamiTheme.qml b/src/constant/JamiTheme.qml index 70ce90aa013906d05442d34275c5756b76a4567d..ae2061e93b5bc37e880c0f94c36ba3840bac8ef0 100644 --- a/src/constant/JamiTheme.qml +++ b/src/constant/JamiTheme.qml @@ -30,6 +30,8 @@ Item { // General property color blackColor: "#000000" property color whiteColor: "#ffffff" + property color darkGreyColor: "#272727" + property color darkGreyColorOpacity: "#4D272727" // 77% property color transparentColor: "transparent" property color primaryForegroundColor: darkTheme? whiteColor : blackColor property color primaryBackgroundColor: darkTheme? bgDarkMode_ : whiteColor @@ -91,6 +93,10 @@ Item { property color sipInputButtonHoverColor: "#4477aa" property color sipInputButtonPressColor: "#5588bb" + property string buttonConference: "#110000" + property string buttonConferenceHovered: "#66cfff" + property string buttonConferencePressed: "#66cfff" + // Wizard / account manager property color accountCreationOtherStepColor: "grey" property color accountCreationCurrentStepColor: "#28b1ed" @@ -151,6 +157,7 @@ Item { property int textFontSize: 9 property int settingsFontSize: 9 property int buttonFontSize: 9 + property int participantFontSize: 10 property int menuFontSize: 12 property int headerFontSize: 13 property int titleFontSize: 16 diff --git a/src/mainview/components/CallOverlay.qml b/src/mainview/components/CallOverlay.qml index d2c8bae009a25880a2b4c6e819db46e3c364e428..621d0f28331a1e32bb287ea61117c999a2cd1087 100644 --- a/src/mainview/components/CallOverlay.qml +++ b/src/mainview/components/CallOverlay.qml @@ -47,7 +47,8 @@ Rectangle { recordingRect.visible = isRecording } - function updateButtonStatus(isPaused, isAudioOnly, isAudioMuted, isVideoMuted, isRecording, isSIP, isConferenceCall) { + function updateButtonStatus(isPaused, isAudioOnly, isAudioMuted, isVideoMuted, + isRecording, isSIP, isConferenceCall) { callViewContextMenu.isSIP = isSIP callViewContextMenu.isPaused = isPaused callViewContextMenu.isAudioOnly = isAudioOnly @@ -75,46 +76,116 @@ Rectangle { MediaHandlerPickerCreation.closeMediaHandlerPicker() } + // returns true if participant is not fully maximized + function showMaximize(pX, pY, pW, pH) { + // Hack: -1 offset added to avoid problems with odd sizes + return (pX - distantRenderer.getXOffset() !== 0 + || pY - distantRenderer.getYOffset() !== 0 + || pW < (distantRenderer.width - distantRenderer.getXOffset() * 2 - 1) + || pH < (distantRenderer.height - distantRenderer.getYOffset() * 2 - 1)) + } + + // returns true if participant takes renderer's width + function showMinimize(pX, pW) { + return (pX - distantRenderer.getXOffset() === 0 + && pW >= distantRenderer.width - distantRenderer.getXOffset() * 2 - 1) + } + + function handleParticipantsInfo(infos) { + // TODO: in the future the conference layout should be entirely managed by the client videoCallOverlay.updateMenu() - var isModerator = CallAdapter.isCurrentModerator() - var isHost = CallAdapter.isCurrentHost() + var showMax = false + var showMin = false + + var deletedUris = [] + var currentUris = [] for (var p in participantOverlays) { - if (participantOverlays[p]) - participantOverlays[p].destroy() + if (participantOverlays[p]) { + var participant = infos.find(e => e.uri === participantOverlays[p].uri); + if (participant) { + // Update participant's information + var newX = distantRenderer.getXOffset() + + participant.x * distantRenderer.getScaledWidth() + var newY = distantRenderer.getYOffset() + + participant.y * distantRenderer.getScaledHeight() + var newWidth = participant.w * distantRenderer.getScaledWidth() + var newHeight = participant.h * distantRenderer.getScaledHeight() + var newVisible = participant.w !== 0 && participant.h !== 0 + + if (participantOverlays[p].x !== newX) + participantOverlays[p].x = newX + if (participantOverlays[p].y !== newY) + participantOverlays[p].y = newY + if (participantOverlays[p].width !== newWidth) + participantOverlays[p].width = newWidth + if (participantOverlays[p].height !== newHeight) + participantOverlays[p].height = newHeight + if (participantOverlays[p].visible !== newVisible) + participantOverlays[p].visible = newVisible + + showMax = showMaximize(participantOverlays[p].x, + participantOverlays[p].y, + participantOverlays[p].width, + participantOverlays[p].height) + showMin = showMinimize(participantOverlays[p].x, + participantOverlays[p].width) + + participantOverlays[p].setMenu(participant.uri, participant.bestName, + participant.isLocal, showMax, showMin) + if (participant.videoMuted) + participantOverlays[p].setAvatar(participant.avatar) + else + participantOverlays[p].setAvatar("") + currentUris.push(participantOverlays[p].uri) + } else { + // Participant is no longer in conference + deletedUris.push(participantOverlays[p].uri) + participantOverlays[p].destroy() + } + } } - participantOverlays = [] - if (infos.length == 0) { + participantOverlays = participantOverlays.filter(part => !deletedUris.includes(part.uri)) + + if (infos.length === 0) { // Return to normal call previewRenderer.visible = true + for (var part in participantOverlays) { + if (participantOverlays[part]) { + participantOverlays[part].destroy() + } + } + participantOverlays = [] } else { previewRenderer.visible = false for (var infoVariant in infos) { - var hover = participantComponent.createObject(callOverlayRectMouseArea, { - x: distantRenderer.getXOffset() + infos[infoVariant].x * distantRenderer.getScaledWidth(), - y: distantRenderer.getYOffset() + infos[infoVariant].y * distantRenderer.getScaledHeight(), - width: infos[infoVariant].w * distantRenderer.getScaledWidth(), - height: infos[infoVariant].h * distantRenderer.getScaledHeight(), - visible: infos[infoVariant].w != 0 && infos[infoVariant].h != 0 - }) - if (!hover) { - console.log("Error when creating the hover") - return - } + // Only create overlay for new participants + if (!currentUris.includes(infos[infoVariant].uri)) { + var hover = participantComponent.createObject(callOverlayRectMouseArea, { + x: distantRenderer.getXOffset() + infos[infoVariant].x * distantRenderer.getScaledWidth(), + y: distantRenderer.getYOffset() + infos[infoVariant].y * distantRenderer.getScaledHeight(), + width: infos[infoVariant].w * distantRenderer.getScaledWidth(), + height: infos[infoVariant].h * distantRenderer.getScaledHeight(), + visible: infos[infoVariant].w !== 0 && infos[infoVariant].h !== 0 + }) + if (!hover) { + console.log("Error when creating the hover") + return + } + + showMax = showMaximize(hover.x, hover.y, hover.width, hover.height) + showMin = showMinimize(hover.x, hover.width) - hover.setParticipantName(infos[infoVariant].bestName) - hover.active = infos[infoVariant].active; - hover.isLocal = infos[infoVariant].isLocal; - hover.setMenuVisible(isModerator) - hover.setEndCallVisible(isHost) - hover.uri = infos[infoVariant].uri - if (infos[infoVariant].videoMuted) - hover.setAvatar(infos[infoVariant].avatar) - else - hover.setAvatar("") - hover.injectedContextMenu = participantContextMenu - participantOverlays.push(hover) + hover.setMenu(infos[infoVariant].uri, infos[infoVariant].bestName, + infos[infoVariant].isLocal, showMax, showMin) + if (infos[infoVariant].videoMuted) + hover.setAvatar(infos[infoVariant].avatar) + else + hover.setAvatar("") + participantOverlays.push(hover) + } } } + } // x, y position does not need to be translated @@ -477,8 +548,4 @@ Rectangle { MediaHandlerPickerCreation.openMediaHandlerPicker() } } - - ParticipantContextMenu { - id: participantContextMenu - } } diff --git a/src/mainview/components/CallOverlayButtonGroup.qml b/src/mainview/components/CallOverlayButtonGroup.qml index f8bfc0e8559fb61579ba9b8208c87869ca37065b..84295ba03c7682530398d3be06ab3e4b368844f9 100644 --- a/src/mainview/components/CallOverlayButtonGroup.qml +++ b/src/mainview/components/CallOverlayButtonGroup.qml @@ -33,22 +33,23 @@ Rectangle { // ButtonCounts here is to make sure that flow layout margin is calculated correctly, // since no other methods can make buttons at the layout center. property int buttonPreferredSize: 48 - property var isHost: true + property var isModerator: true property var isSip: false signal chatButtonClicked signal addToConferenceButtonClicked function updateMenu() { - root.isHost = CallAdapter.isCurrentHost() - addToConferenceButton.visible = !root.isSip && root.isHost + root.isModerator = CallAdapter.isCurrentModerator() + addToConferenceButton.visible = !root.isSip && root.isModerator } - function setButtonStatus(isPaused, isAudioOnly, isAudioMuted, isVideoMuted, isRecording, isSIP, isConferenceCall) { - root.isHost = CallAdapter.isCurrentModerator() + function setButtonStatus(isPaused, isAudioOnly, isAudioMuted, isVideoMuted, + isRecording, isSIP, isConferenceCall) { + root.isModerator = CallAdapter.isCurrentModerator() root.isSip = isSIP noVideoButton.visible = !isAudioOnly - addToConferenceButton.visible = !isSIP && isHost + addToConferenceButton.visible = !root.isSIP && root.isModerator noMicButton.checked = isAudioMuted noVideoButton.checked = isVideoMuted @@ -150,7 +151,7 @@ Rectangle { Layout.preferredWidth: buttonPreferredSize Layout.preferredHeight: buttonPreferredSize - visible: !isHost + visible: !isModerator pressedColor: JamiTheme.invertedPressedButtonColor hoveredColor: JamiTheme.invertedHoveredButtonColor diff --git a/src/mainview/components/ParticipantContextMenu.qml b/src/mainview/components/ParticipantContextMenu.qml deleted file mode 100644 index 5c9f4985ac3dcc0e0cbcbac55b04b22cda9094b4..0000000000000000000000000000000000000000 --- a/src/mainview/components/ParticipantContextMenu.qml +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright (C) 2020 by Savoir-faire Linux - * Author: Sébastien Blin <sebastien.blin@savoirfairelinux.com> - * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> - * - * 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 2.14 -import QtQuick.Controls 2.14 -import QtGraphicalEffects 1.14 -import net.jami.Models 1.0 -import net.jami.Constants 1.0 - -import "../../commoncomponents" -import "../../commoncomponents/js/contextmenugenerator.js" as ContextMenuGenerator - -Item { - id: root - - property var uri: "" - property var maximized: true - property var active: true - property var showHangup: false - property var showMaximize: false - property var showMinimize: false - property var showSetModerator: false - property var showUnsetModerator: false - property var showMute: false - property var showUnmute: false - - function openMenu(){ - ContextMenuGenerator.initMenu() - if (showHangup) - ContextMenuGenerator.addMenuItem(JamiStrings.hangup, - "qrc:/images/icons/ic_call_end_white_24px.svg", - function (){ - CallAdapter.hangupCall(uri) - }) - - if (showMaximize) - ContextMenuGenerator.addMenuItem(qsTr("Maximize participant"), - "qrc:/images/icons/open_in_full-24px.svg", - function (){ - CallAdapter.maximizeParticipant(uri, active) - }) - if (showMinimize) - ContextMenuGenerator.addMenuItem(qsTr("Minimize participant"), - "qrc:/images/icons/close_fullscreen-24px.svg", - function (){ - CallAdapter.minimizeParticipant() - }) - - if (showSetModerator) - ContextMenuGenerator.addMenuItem(qsTr("Set moderator"), - "qrc:/images/icons/person_add-24px.svg", - function (){ - CallAdapter.setModerator(uri, true) - }) - - if (showUnsetModerator) - ContextMenuGenerator.addMenuItem(qsTr("Unset moderator"), - "qrc:/images/icons/round-close-24px.svg", - function (){ - CallAdapter.setModerator(uri, false) - }) - - if (showMute) - ContextMenuGenerator.addMenuItem(qsTr("Mute participant"), - "qrc:/images/icons/mic_off-24px.svg", - function (){ - CallAdapter.muteParticipant(uri, true) - }) - - if (showUnmute) - ContextMenuGenerator.addMenuItem(qsTr("Unmute participant"), - "qrc:/images/icons/mic-24px.svg", - function (){ - CallAdapter.muteParticipant(uri, false) - }) - - - root.height = ContextMenuGenerator.getMenu().height - root.width = ContextMenuGenerator.getMenu().width - ContextMenuGenerator.getMenu().open() - } - - Component.onCompleted: { - ContextMenuGenerator.createBaseContextMenuObjects(root) - } -} - diff --git a/src/mainview/components/ParticipantOverlay.qml b/src/mainview/components/ParticipantOverlay.qml index 38e19e753d8abefad534418cf49fc701a1d3f07d..a28f630d197c34182af44c6a10cbaf39c8b18ff4 100644 --- a/src/mainview/components/ParticipantOverlay.qml +++ b/src/mainview/components/ParticipantOverlay.qml @@ -1,6 +1,7 @@ /* * Copyright (C) 2020 by Savoir-faire Linux * Author: Sébastien Blin <sebastien.blin@savoirfairelinux.com> + * Author: Albert Babà <albert.babi@savoirfairelinux.com> * * 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 @@ -19,6 +20,7 @@ import QtQuick 2.14 import QtQuick.Controls 2.14 import QtQuick.Layouts 1.14 +import QtQuick.Shapes 1.14 import QtQuick.Controls.Universal 2.14 import QtGraphicalEffects 1.14 import net.jami.Models 1.0 @@ -29,190 +31,215 @@ import "../../commoncomponents" Rectangle { id: root - property int buttonPreferredSize: 12 - property var uri: "" - property var active: true - property var isLocal: true - property var showEndCall: true - property var injectedContextMenu: null + // svg path for the background participant shape (width is offset dependant) + property int offset: indicatorsRowLayout.width + property int shapeHeight: 16 + property string pathShape: "M 0.0,%8 + C 0.0,%8 %1,%8 %1,%8 %2,%8 %3,%9 %4,10.0 %5,5.0 %5,0.0 %6,0.0 %7,0.0 %4,0.0 + 0.0,0.0 0.0,0.0 0.0,%8 0.0,%8 Z".arg(offset).arg(4.0+offset).arg(7+offset) + .arg(9+offset).arg(11+offset).arg(15+offset).arg(18+offset).arg(shapeHeight) + .arg(shapeHeight-2) - function setParticipantName(name) { - participantName.text = name - } + // TODO: properties should be + property string uri: overlayMenu.uri + property bool participantIsModerator: false + property bool participantIsMuted: false // TODO: try to use AvatarImage as well function setAvatar(avatar) { if (avatar === "") { - opacity = 0 contactImage.source = "" } else { - opacity = 1 contactImage.source = "data:image/png;base64," + avatar } } - function setMenuVisible(isVisible) { - optionsButton.visible = isVisible - } + function setMenu(newUri, bestName, isLocal, showMax, showMin) { + + overlayMenu.uri = newUri + overlayMenu.bestName = bestName - function setEndCallVisible(isVisible) { - showEndCall = isVisible + var isHost = CallAdapter.isCurrentHost() + var isModerator = CallAdapter.isCurrentModerator() + var participantIsHost = CallAdapter.participantIsHost(overlayMenu.uri) + participantIsModerator = CallAdapter.isModerator(overlayMenu.uri) + overlayMenu.showSetModerator = isHost && !isLocal && !participantIsModerator + overlayMenu.showUnsetModerator = isHost && !isLocal && participantIsModerator + + participantIsMuted = CallAdapter.isMuted(overlayMenu.uri) + overlayMenu.showMute = isModerator && !participantIsMuted + overlayMenu.showUnmute = isModerator && participantIsMuted && isLocal + overlayMenu.showMaximize = isModerator && showMax + overlayMenu.showMinimize = isModerator && showMin + overlayMenu.showHangup = isModerator && !isLocal && !participantIsHost } - border.width: 1 - opacity: 0 color: "transparent" z: 1 - MouseArea { - id: mouseAreaHover - anchors.fill: parent - hoverEnabled: true - propagateComposedEvents: true - acceptedButtons: Qt.LeftButton - - Image { - id: contactImage - - anchors.centerIn: parent - - height: Math.min(parent.width / 2, parent.height / 2) - width: Math.min(parent.width / 2, parent.height / 2) - - fillMode: Image.PreserveAspectFit - source: "" - asynchronous: true - - layer.enabled: true - layer.effect: OpacityMask { - maskSource: Rectangle{ - width: contactImage.width - height: contactImage.height - radius: { - var size = ((contactImage.width <= contactImage.height)? contactImage.width:contactImage.height) - return size /2 - } - } + // Participant header with moderator / mute indicators + Rectangle { + id: participantIndicators + width: indicatorsRowLayout.width + height: shapeHeight + visible: participantIsModerator || participantIsMuted + color: "transparent" + + Shape { + id: myShape + ShapePath { + id: backgroundShape + strokeColor: "transparent" + fillColor: JamiTheme.darkGreyColorOpacity + capStyle: ShapePath.RoundCap + PathSvg { path: pathShape } } } RowLayout { - id: bottomLabel - - height: 24 - width: parent.width - anchors.bottom: parent.bottom - - Rectangle { - color: "black" - opacity: 0.8 - height: parent.height - width: parent.width - Layout.fillWidth: true - Layout.preferredHeight: parent.height - - Text { - id: participantName - anchors.fill: parent - leftPadding: 8.0 - - TextMetrics { - id: participantMetrics - elide: Text.ElideRight - elideWidth: bottomLabel.width - 8 - } - - text: participantMetrics.elidedText - - color: "white" - font.pointSize: JamiTheme.textFontSize - horizontalAlignment: Text.AlignLeft - verticalAlignment: Text.AlignVCenter + id: indicatorsRowLayout + height: parent.height + anchors.verticalCenter: parent.verticalCenter + + ResponsiveImage { + id: isModeratorIndicator + + visible: participantIsModerator + + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: 6 + containerHeight: 12 + containerWidth: 12 + + source: "qrc:/images/icons/moderator.svg" + layer { + enabled: true + effect: ColorOverlay { color: JamiTheme.whiteColor } + mipmap: false + smooth: true } + } - Button { - id: optionsButton + ResponsiveImage { + id: isMutedIndicator + + visible: participantIsMuted + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: 6 + containerHeight: 12 + containerWidth: 12 + + source: "qrc:/images/icons/mic_off-24px.svg" + layer { + enabled: true + effect: ColorOverlay { color: JamiTheme.whiteColor } + mipmap: false + smooth: true + } + } + } + } - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter + // Participant background, mousearea, hover and buttons for moderation + Rectangle { + id: participantRect - background: Rectangle { - color: "transparent" + anchors.fill: parent + opacity: 0 + color: JamiTheme.darkGreyColorOpacity + z: 1 + + MouseArea { + id: mouseAreaHover + + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: false + acceptedButtons: Qt.LeftButton + + Image { + id: contactImage + + anchors.centerIn: parent + height: Math.min(parent.width / 2, parent.height / 2) + width: Math.min(parent.width / 2, parent.height / 2) + + fillMode: Image.PreserveAspectFit + source: "" + asynchronous: true + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle{ + width: contactImage.width + height: contactImage.height + radius: { + var size = ((contactImage.width <= contactImage.height)? + contactImage.width : contactImage.height) + return size / 2 + } } + } + layer.mipmap: false + layer.smooth: true + } + ParticipantOverlayMenu { + id: overlayMenu + visible: participantRect.opacity !== 0 + anchors.centerIn: parent + hasMinimumSize: root.width > minimumWidth && root.height > minimumHeight - icon.color: "white" - icon.height: buttonPreferredSize - icon.width: buttonPreferredSize - icon.source: "qrc:/images/icons/more_vert-24px.svg" - - onClicked: { - if (!injectedContextMenu) { - console.log("Participant's overlay don't have any injected context menu") - return - } - var mousePos = mapToItem(videoCallPageRect, parent.x, parent.y) - var layout = CallAdapter.getCurrentLayoutType() - var showMaximized = layout !== 2 - var showMinimized = !(layout === 0 || (layout === 1 && !active)) - var isModerator = CallAdapter.isModerator(uri) - var isHost = CallAdapter.isCurrentHost() - var participantIsHost = CallAdapter.participantIsHost(uri) - var isMuted = CallAdapter.isMuted(uri) - injectedContextMenu.showHangup = !root.isLocal && showEndCall - injectedContextMenu.showMaximize = showMaximized - injectedContextMenu.showMinimize = showMinimized - injectedContextMenu.uri = uri - injectedContextMenu.active = active - injectedContextMenu.x = mousePos.x - injectedContextMenu.y = mousePos.y - injectedContextMenu.height - injectedContextMenu.showSetModerator = (isHost && !participantIsHost && !isModerator) - injectedContextMenu.showUnsetModerator = (isHost && !participantIsHost && isModerator) - injectedContextMenu.showMute = !isMuted - injectedContextMenu.showUnmute = isMuted && root.isLocal - injectedContextMenu.openMenu() + onMouseAreaExited: { + if (contactImage.status === Image.Null) { + root.z = 1 + participantRect.state = "exited" } } } - } - onClicked: { - CallAdapter.maximizeParticipant(uri, active) - } + onClicked: { + CallAdapter.maximizeParticipant(uri) + } - onEntered: { - if (contactImage.status === Image.Null) - root.state = "entered" - } + onEntered: { + if (contactImage.status === Image.Null) { + root.z = 2 + participantRect.state = "entered" + } + } - onExited: { - if (contactImage.status === Image.Null) - root.state = "exited" + onExited: { + if (contactImage.status === Image.Null) { + root.z = 1 + participantRect.state = "exited" + } + } } - } - states: [ - State { - name: "entered" - PropertyChanges { - target: root - opacity: 1 - } - }, - State { - name: "exited" - PropertyChanges { - target: root - opacity: 0 + states: [ + State { + name: "entered" + PropertyChanges { + target: participantRect + opacity: 1 + } + }, + State { + name: "exited" + PropertyChanges { + target: participantRect + opacity: 0 + } } - } - ] + ] - transitions: Transition { - PropertyAnimation { - target: root - property: "opacity" - duration: 500 + transitions: Transition { + PropertyAnimation { + target: participantRect + property: "opacity" + duration: 500 + } } } } diff --git a/src/mainview/components/ParticipantOverlayMenu.qml b/src/mainview/components/ParticipantOverlayMenu.qml new file mode 100644 index 0000000000000000000000000000000000000000..3383abb384e0f22c19f113f9d9155e53cd5a61ba --- /dev/null +++ b/src/mainview/components/ParticipantOverlayMenu.qml @@ -0,0 +1,271 @@ +/* + * Copyright (C) 2020 by Savoir-faire Linux + * Author: Albert Babà <albert.babi@savoirfairelinux.com> + * + * 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 2.14 +import QtQuick.Controls 2.14 +import QtGraphicalEffects 1.14 +import QtQuick.Layouts 1.14 +import net.jami.Models 1.0 +import net.jami.Constants 1.0 + +import "../../commoncomponents" + +// Overlay menu for conference moderation +Rectangle { + id: root + + property bool hasMinimumSize: true + property int buttonPreferredSize: 30 + property int minimumWidth: Math.max(114, visibleButtons * 37 + 21 * 2) + property int minimumHeight: 114 + property int visibleButtons: toggleModerator.visible + + toggleMute.visible + + maximizeParticipant.visible + + minimizeParticipant.visible + + hangupParticipant.visible + + property string uri: "" + property string bestName: "" + property bool showSetModerator: false + property bool showUnsetModerator: false + property bool showMute: false + property bool showUnmute: false + property bool showMaximize: false + property bool showMinimize: false + property bool showHangup: false + + signal mouseAreaExited + + // values taken from sketch + width: hasMinimumSize? parent.width : minimumWidth + height: hasMinimumSize? parent.height: minimumHeight + + color: hasMinimumSize? "transparent" : JamiTheme.darkGreyColorOpacity + radius: 10 + + MouseArea { + id: mouseAreaHover + + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: true + acceptedButtons: Qt.LeftButton + + onExited: mouseAreaExited() + + ColumnLayout { + id: layout + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + Text { + id: participantName + + TextMetrics { + id: participantMetrics + text: bestName + elide: Text.ElideRight + elideWidth: root.width - JamiTheme.preferredMarginSize * 2 + } + + text: participantMetrics.elidedText + color: JamiTheme.whiteColor + font.pointSize: JamiTheme.participantFontSize + Layout.alignment: Qt.AlignCenter + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + RowLayout { + id: rowLayoutButtons + + Layout.alignment: Qt.AlignHCenter | Qt.AlignBottom + Layout.fillWidth: true + spacing: 7 + + PushButton { + id: toggleModerator + + visible: (showSetModerator || showUnsetModerator) + Layout.preferredWidth: buttonPreferredSize + Layout.preferredHeight: buttonPreferredSize + preferredSize: 16 + normalColor: JamiTheme.buttonConference + hoveredColor: JamiTheme.buttonConferenceHovered + pressedColor: JamiTheme.buttonConferencePressed + + source: "qrc:/images/icons/moderator.svg" + imageColor: hovered? JamiTheme.darkGreyColor + : JamiTheme.whiteColor + + onClicked: CallAdapter.setModerator(uri, showSetModerator) + onHoveredChanged: toggleModeratorToolTip.visible = hovered + + Text { + id: toggleModeratorToolTip + + visible: false + width: parent.width + text: showSetModerator? JamiStrings.setModerator + : JamiStrings.unsetModerator + horizontalAlignment: Text.AlignHCenter + anchors.top: parent.bottom + anchors.topMargin: 6 + color: JamiTheme.whiteColor + font.pointSize: JamiTheme.tinyFontSize + } + } + + PushButton { + id: toggleMute + + visible: showMute || showUnmute + Layout.preferredWidth: buttonPreferredSize + Layout.preferredHeight: buttonPreferredSize + preferredSize: 16 + + normalColor: JamiTheme.buttonConference + hoveredColor: JamiTheme.buttonConferenceHovered + pressedColor: JamiTheme.buttonConferencePressed + + source: showMute? "qrc:/images/icons/mic-24px.svg" + : "qrc:/images/icons/mic_off-24px.svg" + imageColor: hovered? JamiTheme.darkGreyColor + : JamiTheme.whiteColor + + onClicked: CallAdapter.muteParticipant(uri, showMute) + onHoveredChanged: toggleParticipantToolTip.visible = hovered + + Text { + id: toggleParticipantToolTip + + visible: false + width: parent.width + text: showMute? JamiStrings.muteParticipant + : JamiStrings.unmuteParticipant + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignTop + + anchors.top: parent.bottom + anchors.topMargin: 6 + color: JamiTheme.whiteColor + font.pointSize: JamiTheme.tinyFontSize + } + } + + PushButton { + id: maximizeParticipant + + visible: showMaximize + Layout.preferredWidth: buttonPreferredSize + Layout.preferredHeight: buttonPreferredSize + preferredSize: 16 + + normalColor: JamiTheme.buttonConference + hoveredColor: JamiTheme.buttonConferenceHovered + pressedColor: JamiTheme.buttonConferencePressed + + source: "qrc:/images/icons/open_in_full-24px.svg" + imageColor: hovered? JamiTheme.darkGreyColor + : JamiTheme.whiteColor + + onClicked: CallAdapter.maximizeParticipant(uri) + onHoveredChanged: maximizeParticipantToolTip.visible = hovered + + Text { + id: maximizeParticipantToolTip + + visible: false + width: parent.width + text: JamiStrings.maximizeParticipant + horizontalAlignment: Text.AlignHCenter + anchors.top: parent.bottom + anchors.topMargin: 6 + color: JamiTheme.whiteColor + font.pointSize: JamiTheme.tinyFontSize + } + } + + PushButton { + id: minimizeParticipant + + visible: showMinimize + Layout.preferredWidth: buttonPreferredSize + Layout.preferredHeight: buttonPreferredSize + preferredSize: 16 + + normalColor: JamiTheme.buttonConference + hoveredColor: JamiTheme.buttonConferenceHovered + pressedColor: JamiTheme.buttonConferencePressed + + source: "qrc:/images/icons/close_fullscreen-24px.svg" + imageColor: hovered? JamiTheme.darkGreyColor + : JamiTheme.whiteColor + onClicked: CallAdapter.minimizeParticipant(uri) + onHoveredChanged: minimizeParticipantToolTip.visible = hovered + + Text { + id: minimizeParticipantToolTip + + visible: false + width: parent.width + text: JamiStrings.minimizeParticipant + horizontalAlignment: Text.AlignHCenter + anchors.top: parent.bottom + anchors.topMargin: 6 + color: JamiTheme.whiteColor + font.pointSize: JamiTheme.tinyFontSize + } + } + + PushButton { + id: hangupParticipant + + visible: showHangup + Layout.preferredWidth: buttonPreferredSize + Layout.preferredHeight: buttonPreferredSize + preferredSize: 16 + + normalColor: JamiTheme.buttonConference + hoveredColor: JamiTheme.buttonConferenceHovered + pressedColor: JamiTheme.buttonConferencePressed + + source: "qrc:/images/icons/ic_block_24px.svg" + imageColor: hovered? JamiTheme.darkGreyColor + : JamiTheme.whiteColor + onClicked: CallAdapter.hangupCall(uri) + onHoveredChanged: hangupParticipantToolTip.visible = hovered + + Text { + id: hangupParticipantToolTip + + visible: false + width: parent.width + text: JamiStrings.hangupParticipant + horizontalAlignment: Text.AlignHCenter + anchors.top: parent.bottom + anchors.topMargin: 6 + color: JamiTheme.whiteColor + font.pointSize: JamiTheme.tinyFontSize + } + } + } + } + } +} diff --git a/src/mainview/components/VideoCallPage.qml b/src/mainview/components/VideoCallPage.qml index 4befacc2fb058f14269cb16a8f5fc88fc53257c4..45ad4d5bc9039b179762eb88c6272d3bf2a23572 100644 --- a/src/mainview/components/VideoCallPage.qml +++ b/src/mainview/components/VideoCallPage.qml @@ -191,6 +191,7 @@ Rectangle { isRecording, isSIP, isConferenceCall) videoCallPageRect.bestName = bestName + videoCallOverlay.handleParticipantsInfo(CallAdapter.getConferencesInfos()) } function onShowOnHoldLabel(isPaused) {