diff --git a/qml.qrc b/qml.qrc
index fa1d2ebe32423b143dca4c97456a0ec25ee95e00..50e329b37efe98749ac3b1788ba9765855e2023d 100644
--- a/qml.qrc
+++ b/qml.qrc
@@ -177,5 +177,6 @@
         <file>src/mainview/components/KeyboardShortcutTable.qml</file>
         <file>src/mainview/components/KeyboardShortcutKeyDelegate.qml</file>
         <file>src/mainview/components/KeyboardShortcutTabButton.qml</file>
+        <file>src/LayoutManager.qml</file>
     </qresource>
 </RCC>
diff --git a/src/LayoutManager.qml b/src/LayoutManager.qml
new file mode 100644
index 0000000000000000000000000000000000000000..d971edb61ba14f98d7063366572413e247ba4c46
--- /dev/null
+++ b/src/LayoutManager.qml
@@ -0,0 +1,184 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ * Author: Andreas Traczyk <andreas.traczyk@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
+import QtQuick.Controls
+
+import net.jami.Adapters 1.1
+
+import "mainview/components"
+
+QtObject {
+    id: root
+
+    // A window-sized container for reparenting components.
+    required property Item appContainer
+
+    // True if the main window is fullscreen.
+    readonly property bool isFullScreen: visibility === Window.FullScreen
+
+    // Both the Hidden and Minimized states combined for convenience.
+    readonly property bool isHidden: visibility === Window.Hidden ||
+                                     visibility === Window.Minimized
+
+    // Used to store if a OngoingCallPage component is fullscreened.
+    property bool isCallFullscreen: false
+
+    // Restore a visible windowed mode.
+    function restoreApp() {
+        if (isHidden) {
+            if (priv.windowedVisibility === Window.Hidden
+                    || priv.windowedVisibility === Window.Minimized) {
+                showNormal()
+                return
+            }
+            visibility = priv.windowedVisibility
+        }
+    }
+
+    // Adds an item to the fullscreen item stack. Automatically puts
+    // the main window in fullscreen mode if needed. Callbacks should be used
+    // to perform component-specific tasks upon successful transitions.
+    function pushFullScreenItem(item, originalParent, pushedCb, removedCb) {
+        if (item === null || item === undefined
+                || priv.fullScreenItems.length >= 3) {
+            return
+        }
+
+        // Make sure our window is in fullscreen mode.
+        priv.requestWindowModeChange(true)
+
+        // Add the item to our list and reparent it to appContainer.
+        priv.fullScreenItems.push({
+                                 "item": item,
+                                 "originalParent": originalParent,
+                                 "removedCb": removedCb
+                             })
+        item.parent = appContainer
+        if (pushedCb) {
+            pushedCb()
+        }
+
+        // Reevaluate isCallFullscreen.
+        priv.fullScreenItemsChanged()
+    }
+
+    // Remove an item if specified, or by default, the top item. Automatically
+    // resets the main window to windowed mode if no items remain in the stack.
+    function popFullScreenItem(obj=null) {
+        // Remove the item and reparent it to its original parent.
+        if (obj === null) {
+            obj = priv.fullScreenItems.pop()
+        } else {
+            const index = priv.fullScreenItems.indexOf(obj);
+            if (index > -1) {
+                priv.fullScreenItems.splice(index, 1);
+            }
+        }
+        if (obj !== undefined) {
+            if (obj.item !== appWindow) {
+                obj.item.parent = obj.originalParent
+                if (obj.removedCb) {
+                    obj.removedCb()
+                }
+            }
+
+            // Reevaluate isCallFullscreen.
+            priv.fullScreenItemsChanged()
+        }
+
+        // Only leave fullscreen mode if our window isn't in fullscreen
+        // mode already.
+        if (priv.fullScreenItems.length === 0) {
+            // Simply recall the last visibility state.
+            visibility = priv.windowedVisibility
+        }
+    }
+
+    // Used to filter removal for a specific item.
+    function removeFullScreenItem(item) {
+        priv.fullScreenItems.forEach(o => {
+            if (o.item === item) {
+                popFullScreenItem(o)
+                return
+            }
+        });
+    }
+
+    // Toggle the application window in fullscreen mode.
+    function toggleWindowFullScreen() {
+        priv.requestWindowModeChange(!isFullScreen)
+
+        // If we succeeded, place a dummy item onto the stack as
+        // a state indicator to prevent returning to windowed mode
+        // when popping an item on top. The corresponding pop will
+        // be made within requestWindowModeChange.
+        if (isFullScreen) {
+            priv.fullScreenItems.push({ "item": appWindow })
+        }
+    }
+
+    property var data: QtObject {
+        id: priv
+
+        // Used to store the last windowed mode visibility.
+        property int windowedVisibility
+
+        // An stack of items that are fullscreened.
+        property variant fullScreenItems: []
+
+        // When fullScreenItems is changed, we can recompute isCallFullscreen.
+        onFullScreenItemsChanged: {
+            isCallFullscreen = fullScreenItems
+                .filter(o => o.item instanceof OngoingCallPage)
+                .length
+        }
+
+        // Listen for a hangup combined with a fullscreen call state and
+        // remove the OngoingCallPage component.
+        property var data: Connections {
+            target: CallAdapter
+            function onHasCallChanged() {
+                if (!CallAdapter.hasCall && isCallFullscreen) {
+                    priv.fullScreenItems.forEach(o => {
+                        if (o.item instanceof OngoingCallPage) {
+                            popFullScreenItem(o)
+                            return
+                        }
+                    });
+                }
+            }
+        }
+
+        // Used internally to switch modes.
+        function requestWindowModeChange(fullScreen) {
+            if (fullScreen) {
+                if (!isFullScreen) {
+                    // Save the previous visibility state.
+                    windowedVisibility = visibility
+                    showFullScreen()
+                }
+            } else {
+                // Clear the stack.
+                while (fullScreenItems.length) {
+                    popFullScreenItem()
+                }
+            }
+        }
+    }
+}
diff --git a/src/MainApplicationWindow.qml b/src/MainApplicationWindow.qml
index acaf6bbce6d98664c0cabc6cad8135b0e50c01b7..17e3a9d09ebebb39c47b1f2430114ec2d48e4fe0 100644
--- a/src/MainApplicationWindow.qml
+++ b/src/MainApplicationWindow.qml
@@ -33,6 +33,7 @@ import net.jami.Helpers 1.1
 import net.jami.Constants 1.1
 
 import "mainview"
+import "mainview/components"
 import "wizardview"
 import "commoncomponents"
 
@@ -46,15 +47,9 @@ ApplicationWindow {
         None
     }
 
-    property ApplicationWindow appWindow : root
-    property bool isFullscreen: visibility === Window.FullScreen
-
-    function toggleFullScreen() {
-        if (isFullscreen) {
-            showNormal()
-        } else {
-            showFullScreen()
-        }
+    property ApplicationWindow appWindow: root
+    property LayoutManager layoutManager: LayoutManager {
+        appContainer: appContainer
     }
 
     function checkLoadedSource() {
@@ -165,17 +160,13 @@ ApplicationWindow {
 
         function onRestoreAppRequested() {
             requestActivate()
-            if (visibility === Window.Hidden || visibility === Window.Minimized) {
-                showNormal()
-            }
+            layoutManager.restoreApp()
         }
 
         function onNotificationClicked() {
             requestActivate()
             raise()
-            if (visibility === Window.Hidden || visibility === Window.Minimized) {
-                showNormal()
-            }
+            layoutManager.restoreApp()
         }
     }
 
diff --git a/src/commoncomponents/DataTransferMessageDelegate.qml b/src/commoncomponents/DataTransferMessageDelegate.qml
index daea1bf4ff686602f46b854e5bf52373c1ccae62..707ab5630fc16f2e25a49cf4006842f7a6ee2779 100644
--- a/src/commoncomponents/DataTransferMessageDelegate.qml
+++ b/src/commoncomponents/DataTransferMessageDelegate.qml
@@ -35,7 +35,6 @@ Loader {
     property bool showTime: false
     property int seq: MsgSeq.single
     property string author: Author
-    property bool changeWindowVisibility: false
 
     width: ListView.view ? ListView.view.width : 0
 
@@ -279,7 +278,7 @@ Loader {
                             settings.fullScreenSupportEnabled: mediaInfo.isVideo
                             settings.javascriptCanOpenWindows: false
                             Component.onCompleted: loadHtml(mediaInfo.html, 'file://')
-                            layer.enabled: parent !== appContainer && !appWindow.isFullscreen
+                            layer.enabled: !isFullScreen
                             layer.effect: OpacityMask {
                                 maskSource: MessageBubble {
                                     out: isOutgoing
@@ -289,43 +288,20 @@ Loader {
                                     radius: msgRadius
                                 }
                             }
-
-                            function leaveFullScreen() {
-                                parent = localMediaCompLoader
-                                if (root.changeWindowVisibility) {
-                                    root.changeWindowVisibility = false
-                                    appWindow.showNormal()
-                                }
-                            }
-
                             onFullScreenRequested: function(request) {
-                                if (JamiQmlUtils.callIsFullscreen)
-                                    return
                                 if (request.toggleOn) {
-                                    parent = appContainer
-                                    if (!appWindow.isFullscreen) {
-                                        root.changeWindowVisibility = true
-                                        appWindow.showFullScreen()
-                                    }
-                                } else {
-                                    leaveFullScreen()
+                                    layoutManager.pushFullScreenItem(
+                                                this,
+                                                localMediaCompLoader,
+                                                null,
+                                                function() { wev.fullScreenCancelled() })
+                                } else if (!request.toggleOn) {
+                                    layoutManager.removeFullScreenItem(this)
                                 }
                                 request.accept()
                             }
-
-                            Connections {
-                                target: appWindow
-
-                                function onVisibilityChanged() {
-                                    if (wev.isFullScreen && !appWindow.isFullScreen) {
-                                        wev.fullScreenCancelled()
-                                        leaveFullScreen()
-                                    }
-                                }
-                            }
                         }
                     }
-
                     Component {
                         id: animatedImageComp
                         AnimatedImage {
diff --git a/src/constant/JamiQmlUtils.qml b/src/constant/JamiQmlUtils.qml
index 5028853e7caa2f4e8ff4749222c0056a2a981d8e..4863b8a8daaba6d4a993e76ba924368028bc5408 100644
--- a/src/constant/JamiQmlUtils.qml
+++ b/src/constant/JamiQmlUtils.qml
@@ -33,9 +33,6 @@ Item {
 
     property var mainApplicationScreen: ""
 
-    property bool callIsFullscreen: false
-    signal fullScreenCallEnded
-
     property var accountCreationInputParaObject: ({})
 
     function setUpAccountCreationInputPara(inputPara) {
@@ -71,15 +68,6 @@ Item {
         }
     }
 
-    Connections {
-        target: CallAdapter
-
-        function onHasCallChanged() {
-            if (!CallAdapter.hasCall && callIsFullscreen)
-                fullScreenCallEnded()
-        }
-    }
-
     Text {
         id: globalTextMetrics
     }
diff --git a/src/mainview/MainView.qml b/src/mainview/MainView.qml
index 5792f288225697c202650cc0ae57aa3afa796dc9..16a978268755120de9a9c7585434a1c588400d86 100644
--- a/src/mainview/MainView.qml
+++ b/src/mainview/MainView.qml
@@ -528,13 +528,13 @@ Rectangle {
     Shortcut {
         sequence: "F11"
         context: Qt.ApplicationShortcut
-        onActivated: {
-            // Don't toggle fullscreen mode when we're already
-            // in a fullscreen call.
-            if (JamiQmlUtils.callIsFullscreen)
-                return
-            appWindow.toggleFullScreen()
-        }
+        onActivated: layoutManager.toggleWindowFullScreen()
+    }
+
+    Shortcut {
+        sequence: "Escape"
+        context: Qt.ApplicationShortcut
+        onActivated: layoutManager.popFullScreenItem()
     }
 
     Shortcut {
@@ -556,17 +556,6 @@ Rectangle {
         onActivated: startWizard()
     }
 
-    Shortcut {
-        sequence: "Escape"
-        context: Qt.ApplicationShortcut
-        onActivated: {
-            if (JamiQmlUtils.callIsFullscreen)
-                callStackView.toggleFullScreen()
-            else if (appWindow.visibility === Window.FullScreen)
-                appWindow.toggleFullScreen()
-        }
-    }
-
     Shortcut {
         sequence: StandardKey.Quit
         context: Qt.ApplicationShortcut
diff --git a/src/mainview/components/CallStackView.qml b/src/mainview/components/CallStackView.qml
index 1f1d927fbcfeadcd0d5e1a83a21c70cb5ec7b75c..fd65c5a97e0c7ec89165f556c23798e48c0303c2 100644
--- a/src/mainview/components/CallStackView.qml
+++ b/src/mainview/components/CallStackView.qml
@@ -29,7 +29,6 @@ Rectangle {
     id: root
 
     property bool isAudioOnly: false
-    property bool changeWindowVisibility: false
     property var sipKeys: [
         "1", "2", "3", "A",
         "4", "5", "6", "B",
@@ -111,36 +110,22 @@ Rectangle {
     }
 
     function toggleFullScreen() {
-        var callPage = callStackMainView.currentItem
-        if (!callPage)
-            return
-
-        // manual toggle here because of our fake fullscreen mode (F11)
-        // TODO: handle and save window states, not just a boolean isFullScreen
-        if (!appWindow.isFullscreen && !JamiQmlUtils.callIsFullscreen) {
-            root.changeWindowVisibility = true
-            appWindow.showFullScreen()
-        } else if (JamiQmlUtils.callIsFullscreen && root.changeWindowVisibility) {
-            root.changeWindowVisibility = false
-            appWindow.showNormal()
-        }
-
-        JamiQmlUtils.callIsFullscreen = !JamiQmlUtils.callIsFullscreen
-        callPage.parent = JamiQmlUtils.callIsFullscreen ?
-                    appContainer :
-                    callStackMainView
-        if (!root.isAudioOnly) {
-            ongoingCallPage.handleParticipantsInfo(CallAdapter.getConferencesInfos())
+        const transitionCb = function() {
+            if (!root.isAudioOnly) {
+                ongoingCallPage.handleParticipantsInfo(
+                            CallAdapter.getConferencesInfos())
+            }
         }
-    }
-
-    Connections {
-        target: JamiQmlUtils
 
-        function onFullScreenCallEnded() {
-            if (appWindow.isFullscreen) {
-                toggleFullScreen()
-            }
+        if (!layoutManager.isCallFullscreen) {
+            layoutManager.pushFullScreenItem(
+                        callStackMainView.currentItem,
+                        callStackMainView,
+                        transitionCb,
+                        transitionCb)
+        } else {
+            layoutManager.removeFullScreenItem(
+                        callStackMainView.currentItem)
         }
     }
 
diff --git a/src/mainview/components/CallViewContextMenu.qml b/src/mainview/components/CallViewContextMenu.qml
index 80ec92f32b3ba76c9d66f404762ca5a5ee74f44a..d83ab7793d036c06ada54fdbbf412adbd87ff8be 100644
--- a/src/mainview/components/CallViewContextMenu.qml
+++ b/src/mainview/components/CallViewContextMenu.qml
@@ -88,9 +88,10 @@ ContextMenuAutoLoader {
         GeneralMenuItem {
             id: fullScreen
 
-            itemName: JamiQmlUtils.callIsFullscreen ?
-                          JamiStrings.exitFullScreen : JamiStrings.fullScreen
-            iconSource: JamiQmlUtils.callIsFullscreen ?
+            itemName: layoutManager.callIsFullscreen ?
+                          JamiStrings.exitFullScreen :
+                          JamiStrings.fullScreen
+            iconSource: layoutManager.callIsFullscreen ?
                             JamiResources.close_fullscreen_24dp_svg :
                             JamiResources.open_in_full_24dp_svg
             onClicked: {