summary refs log tree commit diff
diff options
context:
space:
mode:
authorDeepBlueV7.X <nicolas.werner@hotmail.de>2021-01-07 15:56:46 +0100
committerGitHub <noreply@github.com>2021-01-07 15:56:46 +0100
commit49abcb24b5b243bed96a8961a2614233cda23afb (patch)
tree151078e9f4dbe15141e09a1d31ad59eab78843ce
parentFix notification reply build on other platforms (diff)
parentFix device discovery under GStreamer 1.16 (diff)
downloadnheko-49abcb24b5b243bed96a8961a2614233cda23afb.tar.xz
Merge pull request #356 from trilene/call-dialogs-qml
Port call dialogs to Qml
-rw-r--r--CMakeLists.txt4
-rw-r--r--resources/qml/MessageInput.qml33
-rw-r--r--resources/qml/TimelineView.qml9
-rw-r--r--resources/qml/voip/ActiveCallBar.qml (renamed from resources/qml/ActiveCallBar.qml)65
-rw-r--r--resources/qml/voip/CallDevices.qml79
-rw-r--r--resources/qml/voip/CallInviteBar.qml126
-rw-r--r--resources/qml/voip/DeviceError.qml31
-rw-r--r--resources/qml/voip/PlaceCall.qml146
-rw-r--r--resources/qml/voip/VideoCall.qml (renamed from resources/qml/VideoCall.qml)0
-rw-r--r--resources/res.qrc8
-rw-r--r--src/CallManager.cpp142
-rw-r--r--src/CallManager.h48
-rw-r--r--src/ChatPage.cpp1
-rw-r--r--src/UserSettingsPage.cpp4
-rw-r--r--src/WebRTCSession.cpp27
-rw-r--r--src/WebRTCSession.h2
-rw-r--r--src/dialogs/AcceptCall.cpp152
-rw-r--r--src/dialogs/AcceptCall.h39
-rw-r--r--src/dialogs/PlaceCall.cpp131
-rw-r--r--src/dialogs/PlaceCall.h44
-rw-r--r--src/timeline/InputBar.cpp44
-rw-r--r--src/timeline/InputBar.h1
-rw-r--r--src/timeline/TimelineModel.cpp7
-rw-r--r--src/timeline/TimelineViewManager.cpp47
-rw-r--r--src/timeline/TimelineViewManager.h23
25 files changed, 624 insertions, 589 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 2d0d6dd6..2365ac09 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -245,7 +245,6 @@ configure_file(cmake/nheko.h config/nheko.h)
 #
 set(SRC_FILES
 	# Dialogs
-	src/dialogs/AcceptCall.cpp
 	src/dialogs/CreateRoom.cpp
 	src/dialogs/FallbackAuth.cpp
 	src/dialogs/ImageOverlay.cpp
@@ -254,7 +253,6 @@ set(SRC_FILES
 	src/dialogs/LeaveRoom.cpp
 	src/dialogs/Logout.cpp
 	src/dialogs/MemberList.cpp
-	src/dialogs/PlaceCall.cpp
 	src/dialogs/PreviewUploadOverlay.cpp
 	src/dialogs/ReCaptcha.cpp
 	src/dialogs/ReadReceipts.cpp
@@ -464,7 +462,6 @@ feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAG
 
 qt5_wrap_cpp(MOC_HEADERS
 	# Dialogs
-	src/dialogs/AcceptCall.h
 	src/dialogs/CreateRoom.h
 	src/dialogs/FallbackAuth.h
 	src/dialogs/ImageOverlay.h
@@ -473,7 +470,6 @@ qt5_wrap_cpp(MOC_HEADERS
 	src/dialogs/LeaveRoom.h
 	src/dialogs/Logout.h
 	src/dialogs/MemberList.h
-	src/dialogs/PlaceCall.h
 	src/dialogs/PreviewUploadOverlay.h
 	src/dialogs/RawMessage.h
 	src/dialogs/ReCaptcha.h
diff --git a/resources/qml/MessageInput.qml b/resources/qml/MessageInput.qml
index e8ebd5fc..00edb7e5 100644
--- a/resources/qml/MessageInput.qml
+++ b/resources/qml/MessageInput.qml
@@ -1,3 +1,4 @@
+import "./voip"
 import QtQuick 2.9
 import QtQuick.Controls 2.3
 import QtQuick.Layouts 1.2
@@ -10,6 +11,13 @@ Rectangle {
     Layout.preferredHeight: textInput.height
     Layout.minimumHeight: 40
 
+    Component {
+        id: placeCallDialog
+
+        PlaceCall {
+        }
+    }
+
     RowLayout {
         id: inputBar
 
@@ -17,18 +25,33 @@ Rectangle {
         spacing: 16
 
         ImageButton {
-            visible: TimelineManager.callsSupported
+            visible: CallManager.callsSupported
+            opacity: CallManager.haveCallInvite ? 0.3 : 1.0
             Layout.alignment: Qt.AlignBottom
             hoverEnabled: true
             width: 22
             height: 22
-            image: TimelineManager.isOnCall ? ":/icons/icons/ui/end-call.png" : ":/icons/icons/ui/place-call.png"
+            image: CallManager.isOnCall ? ":/icons/icons/ui/end-call.png" : ":/icons/icons/ui/place-call.png"
             ToolTip.visible: hovered
-            ToolTip.text: TimelineManager.isOnCall ? qsTr("Hang up") : qsTr("Place a call")
+            ToolTip.text: CallManager.isOnCall ? qsTr("Hang up") : qsTr("Place a call")
             Layout.topMargin: 8
             Layout.bottomMargin: 8
             Layout.leftMargin: 16
-            onClicked: TimelineManager.timeline.input.callButton()
+            onClicked: {
+                if (TimelineManager.timeline) {
+                    if (CallManager.haveCallInvite) {
+                        return;
+                    }
+                    else if (CallManager.isOnCall) {
+                        CallManager.hangUp();
+                    }
+                    else {
+                        CallManager.refreshDevices();
+                        var dialog = placeCallDialog.createObject(timelineRoot);
+                        dialog.open();
+                    }
+                }
+            }
         }
 
         ImageButton {
@@ -39,7 +62,7 @@ Rectangle {
             image: ":/icons/icons/ui/paper-clip-outline.png"
             Layout.topMargin: 8
             Layout.bottomMargin: 8
-            Layout.leftMargin: TimelineManager.callsSupported ? 0 : 16
+            Layout.leftMargin: CallManager.callsSupported ? 0 : 16
             onClicked: TimelineManager.timeline.input.openFileSelection()
             ToolTip.visible: hovered
             ToolTip.text: qsTr("Send a file")
diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml
index 6e9cd665..3e134b35 100644
--- a/resources/qml/TimelineView.qml
+++ b/resources/qml/TimelineView.qml
@@ -1,6 +1,7 @@
 import "./delegates"
 import "./device-verification"
 import "./emoji"
+import "./voip"
 import QtGraphicalEffects 1.0
 import QtQuick 2.9
 import QtQuick.Controls 2.3
@@ -210,7 +211,7 @@ Page {
                         }
 
                         Loader {
-                            source: TimelineManager.onVideoCall ? "VideoCall.qml" : ""
+                            source: CallManager.isOnCall && CallManager.isVideo ? "voip/VideoCall.qml" : ""
                             onLoaded: TimelineManager.setVideoCallItem()
                         }
 
@@ -223,6 +224,12 @@ Page {
 
             }
 
+            CallInviteBar {
+                id: callInviteBar
+                Layout.fillWidth: true
+                z: 3
+            }
+
             ActiveCallBar {
                 Layout.fillWidth: true
                 z: 3
diff --git a/resources/qml/ActiveCallBar.qml b/resources/qml/voip/ActiveCallBar.qml
index 3059e213..0e932e13 100644
--- a/resources/qml/ActiveCallBar.qml
+++ b/resources/qml/voip/ActiveCallBar.qml
@@ -1,19 +1,19 @@
+import "../"
 import QtQuick 2.9
 import QtQuick.Controls 2.3
 import QtQuick.Layouts 1.2
 import im.nheko 1.0
 
 Rectangle {
-    id: activeCallBar
 
-    visible: TimelineManager.callState != WebRTCState.DISCONNECTED
-    color: "#2ECC71"
+    visible: CallManager.isOnCall
+    color: callInviteBar.color
     implicitHeight: visible ? rowLayout.height + 8 : 0
 
     MouseArea {
         anchors.fill: parent
         onClicked: {
-            if (TimelineManager.onVideoCall)
+            if (CallManager.isVideo)
                 stackLayout.currentIndex = stackLayout.currentIndex ? 0 : 1;
 
         }
@@ -30,63 +30,66 @@ Rectangle {
         Avatar {
             width: avatarSize
             height: avatarSize
-            url: TimelineManager.callPartyAvatarUrl.replace("mxc://", "image://MxcImage/")
-            displayName: TimelineManager.callPartyName
+            url: CallManager.callPartyAvatarUrl.replace("mxc://", "image://MxcImage/")
+            displayName: CallManager.callParty
         }
 
         Label {
+            Layout.leftMargin: 8
             font.pointSize: fontMetrics.font.pointSize * 1.1
-            text: "  " + TimelineManager.callPartyName + " "
+            text: CallManager.callParty
+            color: "#000000"
         }
 
         Image {
+            Layout.leftMargin: 4
             Layout.preferredWidth: 24
             Layout.preferredHeight: 24
-            source: TimelineManager.onVideoCall ? "qrc:/icons/icons/ui/video-call.png" : "qrc:/icons/icons/ui/place-call.png"
+            source: CallManager.isVideo ? "qrc:/icons/icons/ui/video-call.png" : "qrc:/icons/icons/ui/place-call.png"
         }
 
         Label {
             id: callStateLabel
 
             font.pointSize: fontMetrics.font.pointSize * 1.1
+            color: "#000000"
         }
 
         Item {
-            state: TimelineManager.callState
             states: [
                 State {
                     name: "OFFERSENT"
-                    when: state == WebRTCState.OFFERSENT
+                    when: CallManager.callState == WebRTCState.OFFERSENT
 
                     PropertyChanges {
                         target: callStateLabel
-                        text: "Calling..."
+                        text: qsTr("Calling...")
                     }
 
                 },
                 State {
                     name: "CONNECTING"
-                    when: state == WebRTCState.CONNECTING
+                    when: CallManager.callState == WebRTCState.CONNECTING
 
                     PropertyChanges {
                         target: callStateLabel
-                        text: "Connecting..."
+                        text: qsTr("Connecting...")
                     }
 
                 },
                 State {
                     name: "ANSWERSENT"
-                    when: state == WebRTCState.ANSWERSENT
+                    when: CallManager.callState == WebRTCState.ANSWERSENT
 
                     PropertyChanges {
                         target: callStateLabel
-                        text: "Connecting..."
+                        text: qsTr("Connecting...")
                     }
 
                 },
                 State {
                     name: "CONNECTED"
-                    when: state == WebRTCState.CONNECTED
+                    when: CallManager.callState == WebRTCState.CONNECTED
 
                     PropertyChanges {
                         target: callStateLabel
@@ -100,13 +103,13 @@ Rectangle {
 
                     PropertyChanges {
                         target: stackLayout
-                        currentIndex: TimelineManager.onVideoCall ? 1 : 0
+                        currentIndex: CallManager.isVideo ? 1 : 0
                     }
 
                 },
                 State {
                     name: "DISCONNECTED"
-                    when: state == WebRTCState.DISCONNECTED
+                    when: CallManager.callState == WebRTCState.DISCONNECTED
 
                     PropertyChanges {
                         target: callStateLabel
@@ -132,7 +135,7 @@ Rectangle {
             }
 
             interval: 1000
-            running: TimelineManager.callState == WebRTCState.CONNECTED
+            running: CallManager.callState == WebRTCState.CONNECTED
             repeat: true
             onTriggered: {
                 var d = new Date();
@@ -149,36 +152,28 @@ Rectangle {
         }
 
         ImageButton {
-            visible: TimelineManager.onVideoCall
+            visible: CallManager.haveLocalVideo
             width: 24
             height: 24
             buttonTextColor: "#000000"
             image: ":/icons/icons/ui/toggle-camera-view.png"
             hoverEnabled: true
             ToolTip.visible: hovered
-            ToolTip.text: "Toggle camera view"
-            onClicked: TimelineManager.toggleCameraView()
-        }
-
-        Item {
-            implicitWidth: 8
+            ToolTip.text: qsTr("Toggle camera view")
+            onClicked: CallManager.toggleCameraView()
         }
 
         ImageButton {
+            Layout.leftMargin: 8
+            Layout.rightMargin: 16
             width: 24
             height: 24
             buttonTextColor: "#000000"
-            image: TimelineManager.isMicMuted ? ":/icons/icons/ui/microphone-unmute.png" : ":/icons/icons/ui/microphone-mute.png"
+            image: CallManager.isMicMuted ? ":/icons/icons/ui/microphone-unmute.png" : ":/icons/icons/ui/microphone-mute.png"
             hoverEnabled: true
             ToolTip.visible: hovered
-            ToolTip.text: TimelineManager.isMicMuted ? qsTr("Unmute Mic") : qsTr("Mute Mic")
-            onClicked: TimelineManager.toggleMicMute()
-        }
-
-        Item {
-            implicitWidth: 16
+            ToolTip.text: CallManager.isMicMuted ? qsTr("Unmute Mic") : qsTr("Mute Mic")
+            onClicked: CallManager.toggleMicMute()
         }
-
     }
-
 }
diff --git a/resources/qml/voip/CallDevices.qml b/resources/qml/voip/CallDevices.qml
new file mode 100644
index 00000000..f0847b14
--- /dev/null
+++ b/resources/qml/voip/CallDevices.qml
@@ -0,0 +1,79 @@
+import QtQuick 2.9
+import QtQuick.Controls 2.3
+import QtQuick.Layouts 1.2
+import im.nheko 1.0
+
+Popup {
+
+    modal: true
+    anchors.centerIn: parent
+    background: Rectangle {
+        color: colors.window
+        border.color: colors.windowText
+    }
+
+    palette: colors
+
+    ColumnLayout {
+
+        spacing: 16
+
+        ColumnLayout {
+            spacing: 8
+
+            Layout.topMargin: 8
+            Layout.leftMargin: 8
+            Layout.rightMargin: 8
+
+            RowLayout {
+
+                Image {
+                    Layout.preferredWidth: 22
+                    Layout.preferredHeight: 22
+                    source: "image://colorimage/:/icons/icons/ui/microphone-unmute.png?" + colors.windowText
+                }
+
+                ComboBox {
+                    id: micCombo
+                    Layout.fillWidth: true
+                    model: CallManager.mics
+                }
+            }
+
+            RowLayout {
+
+                visible: CallManager.isVideo && CallManager.cameras.length > 0
+
+                Image {
+                    Layout.preferredWidth: 22
+                    Layout.preferredHeight: 22
+                    source: "image://colorimage/:/icons/icons/ui/video-call.png?" + colors.windowText
+                }
+
+                ComboBox {
+                    id: cameraCombo
+                    Layout.fillWidth: true
+                    model: CallManager.cameras
+                }
+            }
+        }
+
+        DialogButtonBox {
+
+            Layout.leftMargin: 128
+            standardButtons: DialogButtonBox.Ok | DialogButtonBox.Cancel
+
+            onAccepted:  {
+                Settings.microphone = micCombo.currentText
+                if (cameraCombo.visible) {
+                    Settings.camera = cameraCombo.currentText
+                }
+                close();
+            }
+
+            onRejected: {
+                close();
+            }
+        }
+    }
+}
diff --git a/resources/qml/voip/CallInviteBar.qml b/resources/qml/voip/CallInviteBar.qml
new file mode 100644
index 00000000..5021949a
--- /dev/null
+++ b/resources/qml/voip/CallInviteBar.qml
@@ -0,0 +1,126 @@
+import "../"
+import QtQuick 2.9
+import QtQuick.Controls 2.3
+import QtQuick.Layouts 1.2
+import im.nheko 1.0
+
+Rectangle {
+
+    visible: CallManager.haveCallInvite
+    color: "#2ECC71"
+    implicitHeight: visible ? rowLayout.height + 8 : 0
+
+    Component {
+        id: devicesDialog
+        CallDevices {
+        }
+    }
+
+    Component {
+        id: deviceError
+        DeviceError {
+        }
+    }
+
+    RowLayout {
+        id: rowLayout
+
+        anchors.left: parent.left
+        anchors.right: parent.right
+        anchors.verticalCenter: parent.verticalCenter
+        anchors.leftMargin: 8
+
+        Avatar {
+            width: avatarSize
+            height: avatarSize
+            url: CallManager.callPartyAvatarUrl.replace("mxc://", "image://MxcImage/")
+            displayName: CallManager.callParty
+        }
+
+        Label {
+            Layout.leftMargin: 8
+            font.pointSize: fontMetrics.font.pointSize * 1.1
+            text: CallManager.callParty
+            color: "#000000"
+        }
+
+        Image {
+            Layout.leftMargin: 4
+            Layout.preferredWidth: 24
+            Layout.preferredHeight: 24
+            source: CallManager.isVideo ? "qrc:/icons/icons/ui/video-call.png" : "qrc:/icons/icons/ui/place-call.png"
+        }
+
+        Label {
+            font.pointSize: fontMetrics.font.pointSize * 1.1
+            text: CallManager.isVideo ? qsTr("Video Call") : qsTr("Voice Call")
+            color: "#000000"
+        }
+
+        Item {
+            Layout.fillWidth: true
+        }
+
+        ImageButton {
+            Layout.rightMargin: 16
+            width: 20
+            height: 20
+            buttonTextColor: "#000000"
+            image: ":/icons/icons/ui/settings.png"
+            hoverEnabled: true
+            ToolTip.visible: hovered
+            ToolTip.text: qsTr("Devices")
+            onClicked: {
+                  CallManager.refreshDevices();
+                  var dialog = devicesDialog.createObject(timelineRoot);
+                  dialog.open();
+            }
+        }
+
+        Button {
+            Layout.rightMargin: 4
+            icon.source: CallManager.isVideo ? "qrc:/icons/icons/ui/video-call.png" : "qrc:/icons/icons/ui/place-call.png"
+            text: qsTr(" Accept ")
+            palette: colors
+
+            onClicked: {
+                if (CallManager.mics.length == 0) {
+                    var dialog = deviceError.createObject(timelineRoot, {
+                        "errorString": qsTr("No microphone found."),
+                        "image": ":/icons/icons/ui/place-call.png"
+                    });
+                    dialog.open();
+                    return;
+                }
+                else if (!CallManager.mics.includes(Settings.microphone)) {
+                    var dialog = deviceError.createObject(timelineRoot, {
+                        "errorString": qsTr("Unknown microphone: ") + Settings.microphone,
+                        "image": ":/icons/icons/ui/place-call.png"
+                    });
+                    dialog.open();
+                    return;
+                }
+                if (CallManager.isVideo && CallManager.cameras.length > 0 && !CallManager.cameras.includes(Settings.camera)) {
+                    var dialog = deviceError.createObject(timelineRoot, {
+                        "errorString": qsTr("Unknown camera: ") + Settings.camera,
+                        "image": ":/icons/icons/ui/video-call.png"
+                    });
+                    dialog.open();
+                    return;
+                }
+                CallManager.acceptInvite();
+            }
+        }
+
+        Button {
+            Layout.rightMargin: 16
+            icon.source: "qrc:/icons/icons/ui/end-call.png"
+            text: qsTr(" Decline ")
+            palette: colors
+
+            onClicked: {
+                CallManager.hangUp();
+            }
+        }
+    }
+}
diff --git a/resources/qml/voip/DeviceError.qml b/resources/qml/voip/DeviceError.qml
new file mode 100644
index 00000000..a6411b95
--- /dev/null
+++ b/resources/qml/voip/DeviceError.qml
@@ -0,0 +1,31 @@
+import QtQuick 2.9
+import QtQuick.Controls 2.3
+import QtQuick.Layouts 1.2
+import im.nheko 1.0
+
+Popup {
+
+    property string errorString
+    property var image
+
+    modal: true
+    anchors.centerIn: parent
+    background: Rectangle {
+        color: colors.window
+        border.color: colors.windowText
+    }
+
+    RowLayout {
+
+        Image {
+            Layout.preferredWidth: 16
+            Layout.preferredHeight: 16
+            source: "image://colorimage/" + image + "?" + colors.windowText
+        }
+
+        Label {
+            text: errorString
+            color: colors.windowText
+        }
+    }
+}
diff --git a/resources/qml/voip/PlaceCall.qml b/resources/qml/voip/PlaceCall.qml
new file mode 100644
index 00000000..95383d95
--- /dev/null
+++ b/resources/qml/voip/PlaceCall.qml
@@ -0,0 +1,146 @@
+import "../"
+import QtQuick 2.9
+import QtQuick.Controls 2.3
+import QtQuick.Layouts 1.2
+import im.nheko 1.0
+
+Popup {
+
+    modal: true
+    anchors.centerIn: parent
+    palette: colors
+    background: Rectangle {
+        color: colors.window
+        border.color: colors.windowText
+    }
+
+    Component {
+        id: deviceError
+        DeviceError {
+        }
+    }
+
+    ColumnLayout {
+        id: columnLayout
+
+        spacing: 16
+
+        RowLayout {
+
+            Layout.topMargin: 8
+            Layout.leftMargin: 8
+
+            Label {
+                text: qsTr("Place a call to ") + TimelineManager.timeline.roomName + "?"
+                color: colors.windowText
+            }
+
+            Item {
+                Layout.fillWidth: true
+            }
+        }
+
+        RowLayout {
+            id: buttonLayout
+
+            Layout.leftMargin: 8
+            Layout.rightMargin: 8
+
+            function validateMic() {
+                if (CallManager.mics.length == 0) {
+                    var dialog = deviceError.createObject(timelineRoot, {
+                        "errorString": qsTr("No microphone found."),
+                        "image": ":/icons/icons/ui/place-call.png"
+                    });
+                    dialog.open();
+                    return false;
+                }
+                return true;
+            }
+
+            Avatar {
+                Layout.rightMargin: cameraCombo.visible ? 16 : 64
+                width: avatarSize
+                height: avatarSize
+                url: TimelineManager.timeline.roomAvatarUrl.replace("mxc://", "image://MxcImage/")
+                displayName: TimelineManager.timeline.roomName
+            }
+
+            Button {
+                text: qsTr(" Voice ")
+                icon.source: "qrc:/icons/icons/ui/place-call.png"
+                onClicked: {
+                    if (buttonLayout.validateMic()) {
+                        Settings.microphone = micCombo.currentText
+                        CallManager.sendInvite(TimelineManager.timeline.roomId(), false);
+                        close();
+                    }
+                }
+            }
+
+            Button {
+                visible: CallManager.cameras.length > 0
+                text: qsTr(" Video ")
+                icon.source: "qrc:/icons/icons/ui/video-call.png"
+                onClicked: {
+                    if (buttonLayout.validateMic()) {
+                        Settings.microphone = micCombo.currentText
+                        Settings.camera = cameraCombo.currentText
+                        CallManager.sendInvite(TimelineManager.timeline.roomId(), true);
+                        close();
+                    }
+                }
+            }
+
+            Button {
+                text: qsTr("Cancel")
+                onClicked: {
+                    close();
+                }
+            }
+        }
+
+        ColumnLayout {
+            spacing: 8
+
+            RowLayout {
+
+                Layout.leftMargin: 8
+                Layout.rightMargin: 8
+                Layout.bottomMargin: cameraCombo.visible ? 0 : 8
+
+                Image {
+                    Layout.preferredWidth: 22
+                    Layout.preferredHeight: 22
+                    source: "image://colorimage/:/icons/icons/ui/microphone-unmute.png?" + colors.windowText
+                }
+
+                ComboBox {
+                    id: micCombo
+                    Layout.fillWidth: true
+                    model: CallManager.mics
+                }
+            }
+
+            RowLayout {
+
+                visible: CallManager.cameras.length > 0
+                Layout.leftMargin: 8
+                Layout.rightMargin: 8
+                Layout.bottomMargin: 8
+
+                Image {
+                    Layout.preferredWidth: 22
+                    Layout.preferredHeight: 22
+                    source: "image://colorimage/:/icons/icons/ui/video-call.png?" + colors.windowText
+                }
+
+                ComboBox {
+                    id: cameraCombo
+                    Layout.fillWidth: true
+                    model: CallManager.cameras
+                }
+            }
+        }
+    }
+}
diff --git a/resources/qml/VideoCall.qml b/resources/qml/voip/VideoCall.qml
index 14408b6e..14408b6e 100644
--- a/resources/qml/VideoCall.qml
+++ b/resources/qml/voip/VideoCall.qml
diff --git a/resources/res.qrc b/resources/res.qrc
index a01907ec..bceeb298 100644
--- a/resources/res.qrc
+++ b/resources/res.qrc
@@ -123,7 +123,6 @@
         <file>qtquickcontrols2.conf</file>
 
         <file>qml/TimelineView.qml</file>
-        <file>qml/ActiveCallBar.qml</file>
         <file>qml/Avatar.qml</file>
         <file>qml/Completer.qml</file>
         <file>qml/EncryptionIndicator.qml</file>
@@ -139,7 +138,6 @@
         <file>qml/TimelineRow.qml</file>
         <file>qml/TopBar.qml</file>
         <file>qml/TypingIndicator.qml</file>
-        <file>qml/VideoCall.qml</file>
         <file>qml/emoji/EmojiButton.qml</file>
         <file>qml/emoji/EmojiPicker.qml</file>
         <file>qml/UserProfile.qml</file>
@@ -159,6 +157,12 @@
         <file>qml/device-verification/NewVerificationRequest.qml</file>
         <file>qml/device-verification/Failed.qml</file>
         <file>qml/device-verification/Success.qml</file>
+        <file>qml/voip/ActiveCallBar.qml</file>
+        <file>qml/voip/CallDevices.qml</file>
+        <file>qml/voip/CallInviteBar.qml</file>
+        <file>qml/voip/DeviceError.qml</file>
+        <file>qml/voip/PlaceCall.qml</file>
+        <file>qml/voip/VideoCall.qml</file>
     </qresource>
     <qresource prefix="/media">
         <file>media/ring.ogg</file>
diff --git a/src/CallManager.cpp b/src/CallManager.cpp
index 89cfeaf9..f725d49f 100644
--- a/src/CallManager.cpp
+++ b/src/CallManager.cpp
@@ -10,11 +10,9 @@
 #include "CallManager.h"
 #include "ChatPage.h"
 #include "Logging.h"
-#include "MainWindow.h"
 #include "MatrixClient.h"
+#include "UserSettingsPage.h"
 #include "Utils.h"
-#include "WebRTCSession.h"
-#include "dialogs/AcceptCall.h"
 
 #include "mtx/responses/turn_server.hpp"
 
@@ -112,6 +110,23 @@ CallManager::CallManager(QObject *parent)
                 default:
                         break;
                 }
+                emit newCallState();
+        });
+
+        connect(&session_, &WebRTCSession::devicesChanged, this, [this]() {
+                if (ChatPage::instance()->userSettings()->microphone().isEmpty()) {
+                        auto mics = session_.getDeviceNames(false, std::string());
+                        if (!mics.empty())
+                                ChatPage::instance()->userSettings()->setMicrophone(
+                                  QString::fromStdString(mics.front()));
+                }
+                if (ChatPage::instance()->userSettings()->camera().isEmpty()) {
+                        auto cameras = session_.getDeviceNames(true, std::string());
+                        if (!cameras.empty())
+                                ChatPage::instance()->userSettings()->setCamera(
+                                  QString::fromStdString(cameras.front()));
+                }
+                emit devicesChanged();
         });
 
         connect(&player_,
@@ -144,7 +159,7 @@ CallManager::CallManager(QObject *parent)
 void
 CallManager::sendInvite(const QString &roomid, bool isVideo)
 {
-        if (onActiveCall())
+        if (isOnCall())
                 return;
 
         auto roomInfo = cache::singleRoomInfo(roomid.toStdString());
@@ -160,7 +175,8 @@ CallManager::sendInvite(const QString &roomid, bool isVideo)
                 return;
         }
 
-        roomid_ = roomid;
+        isVideo_ = isVideo;
+        roomid_  = roomid;
         session_.setTurnServers(turnURIs_);
         generateCallID();
         nhlog::ui()->debug(
@@ -168,16 +184,14 @@ CallManager::sendInvite(const QString &roomid, bool isVideo)
         std::vector<RoomMember> members(cache::getMembers(roomid.toStdString()));
         const RoomMember &callee =
           members.front().user_id == utils::localUser() ? members.back() : members.front();
-        callPartyName_      = callee.display_name.isEmpty() ? callee.user_id : callee.display_name;
+        callParty_          = callee.display_name.isEmpty() ? callee.user_id : callee.display_name;
         callPartyAvatarUrl_ = QString::fromStdString(roomInfo.avatar_url);
-        emit newCallParty();
+        emit newInviteState();
         playRingtone(QUrl("qrc:/media/media/ringback.ogg"), true);
         if (!session_.createOffer(isVideo)) {
                 emit ChatPage::instance()->showNotification("Problem setting up call.");
                 endCall();
         }
-        if (isVideo)
-                emit newVideoCallState();
 }
 
 namespace {
@@ -206,12 +220,6 @@ CallManager::hangUp(CallHangUp::Reason reason)
         }
 }
 
-bool
-CallManager::onActiveCall() const
-{
-        return session_.state() != webrtc::State::DISCONNECTED;
-}
-
 void
 CallManager::syncEvent(const mtx::events::collections::TimelineEvents &event)
 {
@@ -257,7 +265,7 @@ CallManager::handleEvent(const RoomEvent<CallInvite> &callInviteEvent)
                 return;
 
         auto roomInfo = cache::singleRoomInfo(callInviteEvent.room_id);
-        if (onActiveCall() || roomInfo.member_count != 2) {
+        if (isOnCall() || roomInfo.member_count != 2) {
                 emit newMessage(QString::fromStdString(callInviteEvent.room_id),
                                 CallHangUp{callInviteEvent.content.call_id,
                                            0,
@@ -277,48 +285,41 @@ CallManager::handleEvent(const RoomEvent<CallInvite> &callInviteEvent)
         std::vector<RoomMember> members(cache::getMembers(callInviteEvent.room_id));
         const RoomMember &caller =
           members.front().user_id == utils::localUser() ? members.back() : members.front();
-        callPartyName_      = caller.display_name.isEmpty() ? caller.user_id : caller.display_name;
+        callParty_          = caller.display_name.isEmpty() ? caller.user_id : caller.display_name;
         callPartyAvatarUrl_ = QString::fromStdString(roomInfo.avatar_url);
-        emit newCallParty();
-        auto dialog = new dialogs::AcceptCall(caller.user_id,
-                                              caller.display_name,
-                                              QString::fromStdString(roomInfo.name),
-                                              QString::fromStdString(roomInfo.avatar_url),
-                                              isVideo,
-                                              MainWindow::instance());
-        connect(dialog, &dialogs::AcceptCall::accept, this, [this, callInviteEvent, isVideo]() {
-                MainWindow::instance()->hideOverlay();
-                answerInvite(callInviteEvent.content, isVideo);
-        });
-        connect(dialog, &dialogs::AcceptCall::reject, this, [this]() {
-                MainWindow::instance()->hideOverlay();
-                hangUp();
-        });
-        MainWindow::instance()->showSolidOverlayModal(dialog);
+
+        haveCallInvite_ = true;
+        isVideo_        = isVideo;
+        inviteSDP_      = callInviteEvent.content.sdp;
+        session_.refreshDevices();
+        emit newInviteState();
 }
 
 void
-CallManager::answerInvite(const CallInvite &invite, bool isVideo)
+CallManager::acceptInvite()
 {
+        if (!haveCallInvite_)
+                return;
+
         stopRingtone();
         std::string errorMessage;
         if (!session_.havePlugins(false, &errorMessage) ||
-            (isVideo && !session_.havePlugins(true, &errorMessage))) {
+            (isVideo_ && !session_.havePlugins(true, &errorMessage))) {
                 emit ChatPage::instance()->showNotification(QString::fromStdString(errorMessage));
                 hangUp();
                 return;
         }
 
         session_.setTurnServers(turnURIs_);
-        if (!session_.acceptOffer(invite.sdp)) {
+        if (!session_.acceptOffer(inviteSDP_)) {
                 emit ChatPage::instance()->showNotification("Problem setting up call.");
                 hangUp();
                 return;
         }
         session_.acceptICECandidates(remoteICECandidates_);
         remoteICECandidates_.clear();
-        if (isVideo)
-                emit newVideoCallState();
+        haveCallInvite_ = false;
+        emit newInviteState();
 }
 
 void
@@ -332,7 +333,7 @@ CallManager::handleEvent(const RoomEvent<CallCandidates> &callCandidatesEvent)
                            callCandidatesEvent.sender);
 
         if (callid_ == callCandidatesEvent.content.call_id) {
-                if (onActiveCall())
+                if (isOnCall())
                         session_.acceptICECandidates(callCandidatesEvent.content.candidates);
                 else {
                         // CallInvite has been received and we're awaiting localUser to accept or
@@ -350,15 +351,19 @@ CallManager::handleEvent(const RoomEvent<CallAnswer> &callAnswerEvent)
                            callAnswerEvent.content.call_id,
                            callAnswerEvent.sender);
 
-        if (!onActiveCall() && callAnswerEvent.sender == utils::localUser().toStdString() &&
+        if (callAnswerEvent.sender == utils::localUser().toStdString() &&
             callid_ == callAnswerEvent.content.call_id) {
-                emit ChatPage::instance()->showNotification("Call answered on another device.");
-                stopRingtone();
-                MainWindow::instance()->hideOverlay();
+                if (!isOnCall()) {
+                        emit ChatPage::instance()->showNotification(
+                          "Call answered on another device.");
+                        stopRingtone();
+                        haveCallInvite_ = false;
+                        emit newInviteState();
+                }
                 return;
         }
 
-        if (onActiveCall() && callid_ == callAnswerEvent.content.call_id) {
+        if (isOnCall() && callid_ == callAnswerEvent.content.call_id) {
                 stopRingtone();
                 if (!session_.acceptAnswer(callAnswerEvent.content.sdp)) {
                         emit ChatPage::instance()->showNotification("Problem setting up call.");
@@ -375,10 +380,42 @@ CallManager::handleEvent(const RoomEvent<CallHangUp> &callHangUpEvent)
                            callHangUpReasonString(callHangUpEvent.content.reason),
                            callHangUpEvent.sender);
 
-        if (callid_ == callHangUpEvent.content.call_id) {
-                MainWindow::instance()->hideOverlay();
+        if (callid_ == callHangUpEvent.content.call_id)
                 endCall();
-        }
+}
+
+void
+CallManager::toggleMicMute()
+{
+        session_.toggleMicMute();
+        emit micMuteChanged();
+}
+
+bool
+CallManager::callsSupported() const
+{
+#ifdef GSTREAMER_AVAILABLE
+        return true;
+#else
+        return false;
+#endif
+}
+
+QStringList
+CallManager::devices(bool isVideo) const
+{
+        QStringList ret;
+        const QString &defaultDevice = isVideo ? ChatPage::instance()->userSettings()->camera()
+                                               : ChatPage::instance()->userSettings()->microphone();
+        std::vector<std::string> devices =
+          session_.getDeviceNames(isVideo, defaultDevice.toStdString());
+        ret.reserve(devices.size());
+        std::transform(devices.cbegin(),
+                       devices.cend(),
+                       std::back_inserter(ret),
+                       [](const auto &d) { return QString::fromStdString(d); });
+
+        return ret;
 }
 
 void
@@ -393,9 +430,13 @@ void
 CallManager::clear()
 {
         roomid_.clear();
-        callPartyName_.clear();
+        callParty_.clear();
         callPartyAvatarUrl_.clear();
         callid_.clear();
+        isVideo_        = false;
+        haveCallInvite_ = false;
+        emit newInviteState();
+        inviteSDP_.clear();
         remoteICECandidates_.clear();
 }
 
@@ -403,11 +444,8 @@ void
 CallManager::endCall()
 {
         stopRingtone();
-        clear();
-        bool isVideo = session_.isVideo();
         session_.end();
-        if (isVideo)
-                emit newVideoCallState();
+        clear();
 }
 
 void
diff --git a/src/CallManager.h b/src/CallManager.h
index 8004e838..7d388efd 100644
--- a/src/CallManager.h
+++ b/src/CallManager.h
@@ -8,6 +8,7 @@
 #include <QString>
 #include <QTimer>
 
+#include "WebRTCSession.h"
 #include "mtx/events/collections.hpp"
 #include "mtx/events/voip.hpp"
 
@@ -15,34 +16,59 @@ namespace mtx::responses {
 struct TurnServer;
 }
 
+class QStringList;
 class QUrl;
-class WebRTCSession;
 
 class CallManager : public QObject
 {
         Q_OBJECT
+        Q_PROPERTY(bool haveCallInvite READ haveCallInvite NOTIFY newInviteState)
+        Q_PROPERTY(bool isOnCall READ isOnCall NOTIFY newCallState)
+        Q_PROPERTY(bool isVideo READ isVideo NOTIFY newInviteState)
+        Q_PROPERTY(bool haveLocalVideo READ haveLocalVideo NOTIFY newCallState)
+        Q_PROPERTY(webrtc::State callState READ callState NOTIFY newCallState)
+        Q_PROPERTY(QString callParty READ callParty NOTIFY newInviteState)
+        Q_PROPERTY(QString callPartyAvatarUrl READ callPartyAvatarUrl NOTIFY newInviteState)
+        Q_PROPERTY(bool isMicMuted READ isMicMuted NOTIFY micMuteChanged)
+        Q_PROPERTY(bool callsSupported READ callsSupported CONSTANT)
+        Q_PROPERTY(QStringList mics READ mics NOTIFY devicesChanged)
+        Q_PROPERTY(QStringList cameras READ cameras NOTIFY devicesChanged)
 
 public:
         CallManager(QObject *);
 
-        void sendInvite(const QString &roomid, bool isVideo);
-        void hangUp(
-          mtx::events::msg::CallHangUp::Reason = mtx::events::msg::CallHangUp::Reason::User);
-        bool onActiveCall() const;
-        QString callPartyName() const { return callPartyName_; }
+        bool haveCallInvite() const { return haveCallInvite_; }
+        bool isOnCall() const { return session_.state() != webrtc::State::DISCONNECTED; }
+        bool isVideo() const { return isVideo_; }
+        bool haveLocalVideo() const { return session_.haveLocalVideo(); }
+        webrtc::State callState() const { return session_.state(); }
+        QString callParty() const { return callParty_; }
         QString callPartyAvatarUrl() const { return callPartyAvatarUrl_; }
+        bool isMicMuted() const { return session_.isMicMuted(); }
+        bool callsSupported() const;
+        QStringList mics() const { return devices(false); }
+        QStringList cameras() const { return devices(true); }
         void refreshTurnServer();
 
 public slots:
+        void sendInvite(const QString &roomid, bool isVideo);
         void syncEvent(const mtx::events::collections::TimelineEvents &event);
+        void refreshDevices() { session_.refreshDevices(); }
+        void toggleMicMute();
+        void toggleCameraView() { session_.toggleCameraView(); }
+        void acceptInvite();
+        void hangUp(
+          mtx::events::msg::CallHangUp::Reason = mtx::events::msg::CallHangUp::Reason::User);
 
 signals:
         void newMessage(const QString &roomid, const mtx::events::msg::CallInvite &);
         void newMessage(const QString &roomid, const mtx::events::msg::CallCandidates &);
         void newMessage(const QString &roomid, const mtx::events::msg::CallAnswer &);
         void newMessage(const QString &roomid, const mtx::events::msg::CallHangUp &);
-        void newCallParty();
-        void newVideoCallState();
+        void newInviteState();
+        void newCallState();
+        void micMuteChanged();
+        void devicesChanged();
         void turnServerRetrieved(const mtx::responses::TurnServer &);
 
 private slots:
@@ -51,10 +77,13 @@ private slots:
 private:
         WebRTCSession &session_;
         QString roomid_;
-        QString callPartyName_;
+        QString callParty_;
         QString callPartyAvatarUrl_;
         std::string callid_;
         const uint32_t timeoutms_ = 120000;
+        bool isVideo_             = false;
+        bool haveCallInvite_      = false;
+        std::string inviteSDP_;
         std::vector<mtx::events::msg::CallCandidates::Candidate> remoteICECandidates_;
         std::vector<std::string> turnURIs_;
         QTimer turnServerTimer_;
@@ -68,6 +97,7 @@ private:
         void handleEvent(const mtx::events::RoomEvent<mtx::events::msg::CallHangUp> &);
         void answerInvite(const mtx::events::msg::CallInvite &, bool isVideo);
         void generateCallID();
+        QStringList devices(bool isVideo) const;
         void clear();
         void endCall();
         void playRingtone(const QUrl &ringtone, bool repeat);
diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp
index 4e87349a..238c9362 100644
--- a/src/ChatPage.cpp
+++ b/src/ChatPage.cpp
@@ -47,7 +47,6 @@
 
 #include "notifications/Manager.h"
 
-#include "dialogs/PlaceCall.h"
 #include "dialogs/ReadReceipts.h"
 #include "popups/UserMentions.h"
 #include "timeline/TimelineViewManager.h"
diff --git a/src/UserSettingsPage.cpp b/src/UserSettingsPage.cpp
index 7c7ef9ab..f133c87d 100644
--- a/src/UserSettingsPage.cpp
+++ b/src/UserSettingsPage.cpp
@@ -464,7 +464,7 @@ UserSettings::applyTheme()
                 stylefile.setFileName(":/styles/styles/nheko.qss");
                 QPalette lightActive(
                   /*windowText*/ QColor("#333"),
-                  /*button*/ QColor("#333"),
+                  /*button*/ QColor("white"),
                   /*light*/ QColor(0xef, 0xef, 0xef),
                   /*dark*/ QColor(110, 110, 110),
                   /*mid*/ QColor(220, 220, 220),
@@ -477,7 +477,7 @@ UserSettings::applyTheme()
                 lightActive.setColor(QPalette::ToolTipBase, lightActive.base().color());
                 lightActive.setColor(QPalette::ToolTipText, lightActive.text().color());
                 lightActive.setColor(QPalette::Link, QColor("#0077b5"));
-                lightActive.setColor(QPalette::ButtonText, QColor("#495057"));
+                lightActive.setColor(QPalette::ButtonText, QColor("#333"));
                 QApplication::setPalette(lightActive);
         } else if (this->theme() == "dark") {
                 stylefile.setFileName(":/styles/styles/nheko-dark.qss");
diff --git a/src/WebRTCSession.cpp b/src/WebRTCSession.cpp
index 0770a439..094a2906 100644
--- a/src/WebRTCSession.cpp
+++ b/src/WebRTCSession.cpp
@@ -242,12 +242,14 @@ newBusMessage(GstBus *bus G_GNUC_UNUSED, GstMessage *msg, gpointer user_data)
                 GstDevice *device;
                 gst_message_parse_device_added(msg, &device);
                 addDevice(device);
+                emit WebRTCSession::instance().devicesChanged();
                 break;
         }
         case GST_MESSAGE_DEVICE_REMOVED: {
                 GstDevice *device;
                 gst_message_parse_device_removed(msg, &device);
                 removeDevice(device, false);
+                emit WebRTCSession::instance().devicesChanged();
                 break;
         }
         case GST_MESSAGE_DEVICE_CHANGED: {
@@ -553,7 +555,10 @@ getResolution(GstPad *pad)
 void
 addCameraView(GstElement *pipe, const std::pair<int, int> &videoCallSize)
 {
-        GstElement *tee       = gst_bin_get_by_name(GST_BIN(pipe), "videosrctee");
+        GstElement *tee = gst_bin_get_by_name(GST_BIN(pipe), "videosrctee");
+        if (!tee)
+                return;
+
         GstElement *queue     = gst_element_factory_make("queue", nullptr);
         GstElement *videorate = gst_element_factory_make("videorate", nullptr);
         gst_bin_add_many(GST_BIN(pipe), queue, videorate, nullptr);
@@ -1151,6 +1156,19 @@ WebRTCSession::addVideoPipeline(int vp8PayloadType)
 }
 
 bool
+WebRTCSession::haveLocalVideo() const
+{
+        if (isVideo_ && state_ >= State::INITIATED) {
+                GstElement *tee = gst_bin_get_by_name(GST_BIN(pipe_), "videosrctee");
+                if (tee) {
+                        gst_object_unref(tee);
+                        return true;
+                }
+        }
+        return false;
+}
+
+bool
 WebRTCSession::isMicMuted() const
 {
         if (state_ < State::INITIATED)
@@ -1274,6 +1292,7 @@ WebRTCSession::refreshDevices()
                         addDevice(GST_DEVICE_CAST(l->data));
                 g_list_free(devices);
         }
+        emit devicesChanged();
 #endif
 }
 
@@ -1325,6 +1344,12 @@ WebRTCSession::havePlugins(bool, std::string *)
 }
 
 bool
+WebRTCSession::haveLocalVideo() const
+{
+        return false;
+}
+
+bool
 WebRTCSession::createOffer(bool)
 {
         return false;
diff --git a/src/WebRTCSession.h b/src/WebRTCSession.h
index 57002f8f..2f0fb70e 100644
--- a/src/WebRTCSession.h
+++ b/src/WebRTCSession.h
@@ -43,6 +43,7 @@ public:
         bool havePlugins(bool isVideo, std::string *errorMessage = nullptr);
         webrtc::State state() const { return state_; }
         bool isVideo() const { return isVideo_; }
+        bool haveLocalVideo() const;
         bool isOffering() const { return isOffering_; }
         bool isRemoteVideoRecvOnly() const { return isRemoteVideoRecvOnly_; }
 
@@ -75,6 +76,7 @@ signals:
                            const std::vector<mtx::events::msg::CallCandidates::Candidate> &);
         void newICECandidate(const mtx::events::msg::CallCandidates::Candidate &);
         void stateChanged(webrtc::State);
+        void devicesChanged();
 
 private slots:
         void setState(webrtc::State state) { state_ = state; }
diff --git a/src/dialogs/AcceptCall.cpp b/src/dialogs/AcceptCall.cpp
deleted file mode 100644
index 3d25ad82..00000000
--- a/src/dialogs/AcceptCall.cpp
+++ /dev/null
@@ -1,152 +0,0 @@
-#include <QComboBox>
-#include <QLabel>
-#include <QPushButton>
-#include <QString>
-#include <QVBoxLayout>
-
-#include "ChatPage.h"
-#include "Config.h"
-#include "UserSettingsPage.h"
-#include "Utils.h"
-#include "WebRTCSession.h"
-#include "dialogs/AcceptCall.h"
-#include "ui/Avatar.h"
-
-namespace dialogs {
-
-AcceptCall::AcceptCall(const QString &caller,
-                       const QString &displayName,
-                       const QString &roomName,
-                       const QString &avatarUrl,
-                       bool isVideo,
-                       QWidget *parent)
-  : QWidget(parent)
-{
-        std::string errorMessage;
-        WebRTCSession *session = &WebRTCSession::instance();
-        if (!session->havePlugins(false, &errorMessage)) {
-                emit ChatPage::instance()->showNotification(QString::fromStdString(errorMessage));
-                emit close();
-                return;
-        }
-        if (isVideo && !session->havePlugins(true, &errorMessage)) {
-                emit ChatPage::instance()->showNotification(QString::fromStdString(errorMessage));
-                emit close();
-                return;
-        }
-
-        session->refreshDevices();
-        microphones_ = session->getDeviceNames(
-          false, ChatPage::instance()->userSettings()->microphone().toStdString());
-        if (microphones_.empty()) {
-                emit ChatPage::instance()->showNotification(
-                  tr("Incoming call: No microphone found."));
-                emit close();
-                return;
-        }
-        if (isVideo)
-                cameras_ = session->getDeviceNames(
-                  true, ChatPage::instance()->userSettings()->camera().toStdString());
-
-        setAutoFillBackground(true);
-        setWindowFlags(Qt::Tool | Qt::WindowStaysOnTopHint);
-        setWindowModality(Qt::WindowModal);
-        setAttribute(Qt::WA_DeleteOnClose, true);
-
-        setMinimumWidth(conf::modals::MIN_WIDGET_WIDTH);
-        setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
-
-        auto layout = new QVBoxLayout(this);
-        layout->setSpacing(conf::modals::WIDGET_SPACING);
-        layout->setMargin(conf::modals::WIDGET_MARGIN);
-
-        QFont f;
-        f.setPointSizeF(f.pointSizeF());
-
-        QFont labelFont;
-        labelFont.setWeight(QFont::Medium);
-
-        QLabel *displayNameLabel = nullptr;
-        if (!displayName.isEmpty() && displayName != caller) {
-                displayNameLabel = new QLabel(displayName, this);
-                labelFont.setPointSizeF(f.pointSizeF() * 2);
-                displayNameLabel->setFont(labelFont);
-                displayNameLabel->setAlignment(Qt::AlignCenter);
-        }
-
-        QLabel *callerLabel = new QLabel(caller, this);
-        labelFont.setPointSizeF(f.pointSizeF() * 1.2);
-        callerLabel->setFont(labelFont);
-        callerLabel->setAlignment(Qt::AlignCenter);
-
-        auto avatar = new Avatar(this, QFontMetrics(f).height() * 6);
-        if (!avatarUrl.isEmpty())
-                avatar->setImage(avatarUrl);
-        else
-                avatar->setLetter(utils::firstChar(roomName));
-
-        const int iconSize        = 22;
-        QLabel *callTypeIndicator = new QLabel(this);
-        callTypeIndicator->setPixmap(
-          QIcon(isVideo ? ":/icons/icons/ui/video-call.png" : ":/icons/icons/ui/place-call.png")
-            .pixmap(QSize(iconSize * 2, iconSize * 2)));
-
-        QLabel *callTypeLabel = new QLabel(isVideo ? tr("Video Call") : tr("Voice Call"), this);
-        labelFont.setPointSizeF(f.pointSizeF() * 1.1);
-        callTypeLabel->setFont(labelFont);
-        callTypeLabel->setAlignment(Qt::AlignCenter);
-
-        auto buttonLayout = new QHBoxLayout;
-        buttonLayout->setSpacing(18);
-        acceptBtn_ = new QPushButton(tr("Accept"), this);
-        acceptBtn_->setDefault(true);
-        acceptBtn_->setIcon(
-          QIcon(isVideo ? ":/icons/icons/ui/video-call.png" : ":/icons/icons/ui/place-call.png"));
-        acceptBtn_->setIconSize(QSize(iconSize, iconSize));
-
-        rejectBtn_ = new QPushButton(tr("Reject"), this);
-        rejectBtn_->setIcon(QIcon(":/icons/icons/ui/end-call.png"));
-        rejectBtn_->setIconSize(QSize(iconSize, iconSize));
-        buttonLayout->addWidget(acceptBtn_);
-        buttonLayout->addWidget(rejectBtn_);
-
-        microphoneCombo_ = new QComboBox(this);
-        for (const auto &m : microphones_)
-                microphoneCombo_->addItem(QIcon(":/icons/icons/ui/microphone-unmute.png"),
-                                          QString::fromStdString(m));
-
-        if (!cameras_.empty()) {
-                cameraCombo_ = new QComboBox(this);
-                for (const auto &c : cameras_)
-                        cameraCombo_->addItem(QIcon(":/icons/icons/ui/video-call.png"),
-                                              QString::fromStdString(c));
-        }
-
-        if (displayNameLabel)
-                layout->addWidget(displayNameLabel, 0, Qt::AlignCenter);
-        layout->addWidget(callerLabel, 0, Qt::AlignCenter);
-        layout->addWidget(avatar, 0, Qt::AlignCenter);
-        layout->addWidget(callTypeIndicator, 0, Qt::AlignCenter);
-        layout->addWidget(callTypeLabel, 0, Qt::AlignCenter);
-        layout->addLayout(buttonLayout);
-        layout->addWidget(microphoneCombo_);
-        if (cameraCombo_)
-                layout->addWidget(cameraCombo_);
-
-        connect(acceptBtn_, &QPushButton::clicked, this, [this]() {
-                ChatPage::instance()->userSettings()->setMicrophone(
-                  QString::fromStdString(microphones_[microphoneCombo_->currentIndex()]));
-                if (cameraCombo_) {
-                        ChatPage::instance()->userSettings()->setCamera(
-                          QString::fromStdString(cameras_[cameraCombo_->currentIndex()]));
-                }
-                emit accept();
-                emit close();
-        });
-        connect(rejectBtn_, &QPushButton::clicked, this, [this]() {
-                emit reject();
-                emit close();
-        });
-}
-
-}
diff --git a/src/dialogs/AcceptCall.h b/src/dialogs/AcceptCall.h
deleted file mode 100644
index 76ca7ae1..00000000
--- a/src/dialogs/AcceptCall.h
+++ /dev/null
@@ -1,39 +0,0 @@
-#pragma once
-
-#include <string>
-#include <vector>
-
-#include <QWidget>
-
-class QComboBox;
-class QPushButton;
-class QString;
-
-namespace dialogs {
-
-class AcceptCall : public QWidget
-{
-        Q_OBJECT
-
-public:
-        AcceptCall(const QString &caller,
-                   const QString &displayName,
-                   const QString &roomName,
-                   const QString &avatarUrl,
-                   bool isVideo,
-                   QWidget *parent = nullptr);
-
-signals:
-        void accept();
-        void reject();
-
-private:
-        QPushButton *acceptBtn_     = nullptr;
-        QPushButton *rejectBtn_     = nullptr;
-        QComboBox *microphoneCombo_ = nullptr;
-        QComboBox *cameraCombo_     = nullptr;
-        std::vector<std::string> microphones_;
-        std::vector<std::string> cameras_;
-};
-
-}
diff --git a/src/dialogs/PlaceCall.cpp b/src/dialogs/PlaceCall.cpp
deleted file mode 100644
index 85a398a2..00000000
--- a/src/dialogs/PlaceCall.cpp
+++ /dev/null
@@ -1,131 +0,0 @@
-#include <QComboBox>
-#include <QLabel>
-#include <QPushButton>
-#include <QString>
-#include <QVBoxLayout>
-
-#include "ChatPage.h"
-#include "Config.h"
-#include "UserSettingsPage.h"
-#include "Utils.h"
-#include "WebRTCSession.h"
-#include "dialogs/PlaceCall.h"
-#include "ui/Avatar.h"
-
-namespace dialogs {
-
-PlaceCall::PlaceCall(const QString &callee,
-                     const QString &displayName,
-                     const QString &roomName,
-                     const QString &avatarUrl,
-                     QSharedPointer<UserSettings> settings,
-                     QWidget *parent)
-  : QWidget(parent)
-{
-        std::string errorMessage;
-        WebRTCSession *session = &WebRTCSession::instance();
-        if (!session->havePlugins(false, &errorMessage)) {
-                emit ChatPage::instance()->showNotification(QString::fromStdString(errorMessage));
-                emit close();
-                return;
-        }
-        session->refreshDevices();
-        microphones_ = session->getDeviceNames(false, settings->microphone().toStdString());
-        if (microphones_.empty()) {
-                emit ChatPage::instance()->showNotification(tr("No microphone found."));
-                emit close();
-                return;
-        }
-        cameras_ = session->getDeviceNames(true, settings->camera().toStdString());
-
-        setAutoFillBackground(true);
-        setWindowFlags(Qt::Tool | Qt::WindowStaysOnTopHint);
-        setWindowModality(Qt::WindowModal);
-        setAttribute(Qt::WA_DeleteOnClose, true);
-
-        auto layout = new QVBoxLayout(this);
-        layout->setSpacing(conf::modals::WIDGET_SPACING);
-        layout->setMargin(conf::modals::WIDGET_MARGIN);
-
-        auto buttonLayout = new QHBoxLayout;
-        buttonLayout->setSpacing(15);
-        buttonLayout->setMargin(0);
-
-        QFont f;
-        f.setPointSizeF(f.pointSizeF());
-        auto avatar = new Avatar(this, QFontMetrics(f).height() * 3);
-        if (!avatarUrl.isEmpty())
-                avatar->setImage(avatarUrl);
-        else
-                avatar->setLetter(utils::firstChar(roomName));
-
-        voiceBtn_ = new QPushButton(tr("Voice"), this);
-        voiceBtn_->setIcon(QIcon(":/icons/icons/ui/place-call.png"));
-        voiceBtn_->setIconSize(QSize(iconSize_, iconSize_));
-        voiceBtn_->setDefault(true);
-
-        if (!cameras_.empty()) {
-                videoBtn_ = new QPushButton(tr("Video"), this);
-                videoBtn_->setIcon(QIcon(":/icons/icons/ui/video-call.png"));
-                videoBtn_->setIconSize(QSize(iconSize_, iconSize_));
-        }
-        cancelBtn_ = new QPushButton(tr("Cancel"), this);
-
-        buttonLayout->addWidget(avatar);
-        buttonLayout->addStretch();
-        buttonLayout->addWidget(voiceBtn_);
-        if (videoBtn_)
-                buttonLayout->addWidget(videoBtn_);
-        buttonLayout->addWidget(cancelBtn_);
-
-        QString name  = displayName.isEmpty() ? callee : displayName;
-        QLabel *label = new QLabel(tr("Place a call to ") + name + "?", this);
-
-        microphoneCombo_ = new QComboBox(this);
-        for (const auto &m : microphones_)
-                microphoneCombo_->addItem(QIcon(":/icons/icons/ui/microphone-unmute.png"),
-                                          QString::fromStdString(m));
-
-        if (videoBtn_) {
-                cameraCombo_ = new QComboBox(this);
-                for (const auto &c : cameras_)
-                        cameraCombo_->addItem(QIcon(":/icons/icons/ui/video-call.png"),
-                                              QString::fromStdString(c));
-        }
-
-        layout->addWidget(label);
-        layout->addLayout(buttonLayout);
-        layout->addStretch();
-        layout->addWidget(microphoneCombo_);
-        if (videoBtn_)
-                layout->addWidget(cameraCombo_);
-
-        connect(voiceBtn_, &QPushButton::clicked, this, [this, settings]() {
-                settings->setMicrophone(
-                  QString::fromStdString(microphones_[microphoneCombo_->currentIndex()]));
-                emit voice();
-                emit close();
-        });
-        if (videoBtn_)
-                connect(videoBtn_, &QPushButton::clicked, this, [this, settings, session]() {
-                        std::string error;
-                        if (!session->havePlugins(true, &error)) {
-                                emit ChatPage::instance()->showNotification(
-                                  QString::fromStdString(error));
-                                emit close();
-                                return;
-                        }
-                        settings->setMicrophone(
-                          QString::fromStdString(microphones_[microphoneCombo_->currentIndex()]));
-                        settings->setCamera(
-                          QString::fromStdString(cameras_[cameraCombo_->currentIndex()]));
-                        emit video();
-                        emit close();
-                });
-        connect(cancelBtn_, &QPushButton::clicked, this, [this]() {
-                emit cancel();
-                emit close();
-        });
-}
-
-}
diff --git a/src/dialogs/PlaceCall.h b/src/dialogs/PlaceCall.h
deleted file mode 100644
index e042258f..00000000
--- a/src/dialogs/PlaceCall.h
+++ /dev/null
@@ -1,44 +0,0 @@
-#pragma once
-
-#include <string>
-#include <vector>
-
-#include <QSharedPointer>
-#include <QWidget>
-
-class QComboBox;
-class QPushButton;
-class QString;
-class UserSettings;
-
-namespace dialogs {
-
-class PlaceCall : public QWidget
-{
-        Q_OBJECT
-
-public:
-        PlaceCall(const QString &callee,
-                  const QString &displayName,
-                  const QString &roomName,
-                  const QString &avatarUrl,
-                  QSharedPointer<UserSettings> settings,
-                  QWidget *parent = nullptr);
-
-signals:
-        void voice();
-        void video();
-        void cancel();
-
-private:
-        const int iconSize_         = 18;
-        QPushButton *voiceBtn_      = nullptr;
-        QPushButton *videoBtn_      = nullptr;
-        QPushButton *cancelBtn_     = nullptr;
-        QComboBox *microphoneCombo_ = nullptr;
-        QComboBox *cameraCombo_     = nullptr;
-        std::vector<std::string> microphones_;
-        std::vector<std::string> cameras_;
-};
-
-}
diff --git a/src/timeline/InputBar.cpp b/src/timeline/InputBar.cpp
index 5cbc33e0..3cddd613 100644
--- a/src/timeline/InputBar.cpp
+++ b/src/timeline/InputBar.cpp
@@ -13,7 +13,6 @@
 #include <mtx/responses/media.hpp>
 
 #include "Cache.h"
-#include "CallManager.h"
 #include "ChatPage.h"
 #include "CompletionProxyModel.h"
 #include "Logging.h"
@@ -25,7 +24,6 @@
 #include "UserSettingsPage.h"
 #include "UsersModel.h"
 #include "Utils.h"
-#include "dialogs/PlaceCall.h"
 #include "dialogs/PreviewUploadOverlay.h"
 #include "emoji/EmojiModel.h"
 
@@ -594,48 +592,6 @@ InputBar::showPreview(const QMimeData &source, QString path, const QStringList &
 }
 
 void
-InputBar::callButton()
-{
-        auto callManager_ = ChatPage::instance()->callManager();
-        if (callManager_->onActiveCall()) {
-                callManager_->hangUp();
-        } else {
-                auto current_room_ = room->roomId();
-                if (auto roomInfo = cache::singleRoomInfo(current_room_.toStdString());
-                    roomInfo.member_count != 2) {
-                        ChatPage::instance()->showNotification("Calls are limited to 1:1 rooms.");
-                } else {
-                        std::vector<RoomMember> members(
-                          cache::getMembers(current_room_.toStdString()));
-                        const RoomMember &callee = members.front().user_id == utils::localUser()
-                                                     ? members.back()
-                                                     : members.front();
-                        auto dialog =
-                          new dialogs::PlaceCall(callee.user_id,
-                                                 callee.display_name,
-                                                 QString::fromStdString(roomInfo.name),
-                                                 QString::fromStdString(roomInfo.avatar_url),
-                                                 ChatPage::instance()->userSettings(),
-                                                 MainWindow::instance());
-                        connect(dialog,
-                                &dialogs::PlaceCall::voice,
-                                callManager_,
-                                [callManager_, current_room_]() {
-                                        callManager_->sendInvite(current_room_, false);
-                                });
-                        connect(dialog,
-                                &dialogs::PlaceCall::video,
-                                callManager_,
-                                [callManager_, current_room_]() {
-                                        callManager_->sendInvite(current_room_, true);
-                                });
-                        utils::centerWidget(dialog, MainWindow::instance());
-                        dialog->show();
-                }
-        }
-}
-
-void
 InputBar::startTyping()
 {
         if (!typingRefresh_.isActive()) {
diff --git a/src/timeline/InputBar.h b/src/timeline/InputBar.h
index 89ca34fe..c729a6fc 100644
--- a/src/timeline/InputBar.h
+++ b/src/timeline/InputBar.h
@@ -41,7 +41,6 @@ public slots:
         void updateState(int selectionStart, int selectionEnd, int cursorPosition, QString text);
         void openFileSelection();
         bool uploading() const { return uploading_; }
-        void callButton();
         void message(QString body);
 
         QObject *completerFor(QString completerName);
diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp
index adef886d..2b5b5794 100644
--- a/src/timeline/TimelineModel.cpp
+++ b/src/timeline/TimelineModel.cpp
@@ -613,8 +613,13 @@ TimelineModel::addEvents(const mtx::responses::Timeline &timeline)
                         std::visit(
                           [this](auto &event) {
                                   event.room_id = room_id_.toStdString();
-                                  if (event.sender != http::client()->user_id().to_string())
+                                  if constexpr (std::is_same_v<std::decay_t<decltype(event)>,
+                                                               RoomEvent<msg::CallAnswer>>)
                                           emit newCallEvent(event);
+                                  else {
+                                          if (event.sender != http::client()->user_id().to_string())
+                                                  emit newCallEvent(event);
+                                  }
                           },
                           e);
                 else if (std::holds_alternative<StateEvent<state::Avatar>>(e))
diff --git a/src/timeline/TimelineViewManager.cpp b/src/timeline/TimelineViewManager.cpp
index f10c2c0d..97af0065 100644
--- a/src/timeline/TimelineViewManager.cpp
+++ b/src/timeline/TimelineViewManager.cpp
@@ -136,6 +136,10 @@ TimelineViewManager::TimelineViewManager(CallManager *callManager, ChatPage *par
           "im.nheko", 1, 0, "Settings", [](QQmlEngine *, QJSEngine *) -> QObject * {
                   return ChatPage::instance()->userSettings().data();
           });
+        qmlRegisterSingletonType<CallManager>(
+          "im.nheko", 1, 0, "CallManager", [](QQmlEngine *, QJSEngine *) -> QObject * {
+                  return ChatPage::instance()->callManager();
+          });
 
         qRegisterMetaType<mtx::events::collections::TimelineEvents>();
         qRegisterMetaType<std::vector<DeviceInfo>>();
@@ -237,36 +241,6 @@ TimelineViewManager::TimelineViewManager(CallManager *callManager, ChatPage *par
                 isInitialSync_ = true;
                 emit initialSyncChanged(true);
         });
-        connect(&WebRTCSession::instance(),
-                &WebRTCSession::stateChanged,
-                this,
-                &TimelineViewManager::callStateChanged);
-        connect(
-          callManager_, &CallManager::newCallParty, this, &TimelineViewManager::callPartyChanged);
-        connect(callManager_,
-                &CallManager::newVideoCallState,
-                this,
-                &TimelineViewManager::videoCallChanged);
-
-        connect(&WebRTCSession::instance(),
-                &WebRTCSession::stateChanged,
-                this,
-                &TimelineViewManager::onCallChanged);
-}
-
-bool
-TimelineViewManager::isOnCall() const
-{
-        return callManager_->onActiveCall();
-}
-bool
-TimelineViewManager::callsSupported() const
-{
-#ifdef GSTREAMER_AVAILABLE
-        return true;
-#else
-        return false;
-#endif
 }
 
 void
@@ -355,19 +329,6 @@ TimelineViewManager::escapeEmoji(QString str) const
 }
 
 void
-TimelineViewManager::toggleMicMute()
-{
-        WebRTCSession::instance().toggleMicMute();
-        emit micMuteChanged();
-}
-
-void
-TimelineViewManager::toggleCameraView()
-{
-        WebRTCSession::instance().toggleCameraView();
-}
-
-void
 TimelineViewManager::openImageOverlay(QString mxcUrl, QString eventId) const
 {
         if (mxcUrl.isEmpty()) {
diff --git a/src/timeline/TimelineViewManager.h b/src/timeline/TimelineViewManager.h
index 1cec0939..23a960b8 100644
--- a/src/timeline/TimelineViewManager.h
+++ b/src/timeline/TimelineViewManager.h
@@ -36,13 +36,6 @@ class TimelineViewManager : public QObject
           bool isInitialSync MEMBER isInitialSync_ READ isInitialSync NOTIFY initialSyncChanged)
         Q_PROPERTY(
           bool isNarrowView MEMBER isNarrowView_ READ isNarrowView NOTIFY narrowViewChanged)
-        Q_PROPERTY(webrtc::State callState READ callState NOTIFY callStateChanged)
-        Q_PROPERTY(bool onVideoCall READ onVideoCall NOTIFY videoCallChanged)
-        Q_PROPERTY(QString callPartyName READ callPartyName NOTIFY callPartyChanged)
-        Q_PROPERTY(QString callPartyAvatarUrl READ callPartyAvatarUrl NOTIFY callPartyChanged)
-        Q_PROPERTY(bool isMicMuted READ isMicMuted NOTIFY micMuteChanged)
-        Q_PROPERTY(bool isOnCall READ isOnCall NOTIFY onCallChanged)
-        Q_PROPERTY(bool callsSupported READ callsSupported CONSTANT)
 
 public:
         TimelineViewManager(CallManager *callManager, ChatPage *parent = nullptr);
@@ -61,14 +54,6 @@ public:
         Q_INVOKABLE TimelineModel *activeTimeline() const { return timeline_; }
         Q_INVOKABLE bool isInitialSync() const { return isInitialSync_; }
         bool isNarrowView() const { return isNarrowView_; }
-        webrtc::State callState() const { return WebRTCSession::instance().state(); }
-        bool onVideoCall() const { return WebRTCSession::instance().isVideo(); }
-        Q_INVOKABLE void setVideoCallItem();
-        QString callPartyName() const { return callManager_->callPartyName(); }
-        QString callPartyAvatarUrl() const { return callManager_->callPartyAvatarUrl(); }
-        bool isMicMuted() const { return WebRTCSession::instance().isMicMuted(); }
-        Q_INVOKABLE void toggleMicMute();
-        Q_INVOKABLE void toggleCameraView();
         Q_INVOKABLE void openImageOverlay(QString mxcUrl, QString eventId) const;
         Q_INVOKABLE QColor userColor(QString id, QColor background);
         Q_INVOKABLE QString escapeEmoji(QString str) const;
@@ -98,11 +83,6 @@ signals:
         void inviteUsers(QStringList users);
         void showRoomList();
         void narrowViewChanged();
-        void callStateChanged(webrtc::State);
-        void videoCallChanged();
-        void callPartyChanged();
-        void micMuteChanged();
-        void onCallChanged();
 
 public slots:
         void updateReadReceipts(const QString &room_id, const std::vector<QString> &event_ids);
@@ -130,8 +110,7 @@ public slots:
         void queueCallMessage(const QString &roomid, const mtx::events::msg::CallHangUp &);
 
         void updateEncryptedDescriptions();
-        bool isOnCall() const;
-        bool callsSupported() const;
+        void setVideoCallItem();
 
         void enableBackButton()
         {