From 22b8edb0234c58ba2fb71d047dea5dcbd2a020c6 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Wed, 24 Sep 2025 17:05:57 -0400 Subject: [PATCH] OSD: Single component instance. Multi monitor support (follows notifications settings) --- Modules/OSD/OSD.qml | 567 ++++++++++++++++++++++++-------------------- shell.qml | 16 +- 2 files changed, 311 insertions(+), 272 deletions(-) diff --git a/Modules/OSD/OSD.qml b/Modules/OSD/OSD.qml index e8d9064a9..601c97ba9 100644 --- a/Modules/OSD/OSD.qml +++ b/Modules/OSD/OSD.qml @@ -7,314 +7,367 @@ import qs.Commons import qs.Services import qs.Widgets -Loader { - id: windowLoader - active: false +// Unified OSD component - handles both volume and brightness with a single instance +// Loader activates only when showing OSD, deactivates when hidden to save resources +Variants { + model: Quickshell.screens - // OSD Type enum - enum Type { - Volume, - Brightness - } + delegate: Loader { + id: root - property int osdType: OSD.Type.Volume - readonly property real scaling: ScalingService.getScreenScale(Quickshell.screens[0]) + required property ShellScreen modelData + property real scaling: ScalingService.getScreenScale(modelData) - // Volume properties - readonly property real currentVolume: AudioService.volume - readonly property bool isMuted: AudioService.muted - property bool firstVolumeReceived: false - property bool firstMuteReceived: false + // Access the notification model from the service + property ListModel notificationModel: NotificationService.activeList - // Brightness properties - readonly property real currentBrightness: { - if (BrightnessService.monitors.length > 0) { - return BrightnessService.monitors[0].brightness || 0 - } - return 0 - } - property bool firstBrightnessReceived: false + // If no notification display activated in settings, then show them all + property bool canShowOnThisScreen: Settings.isLoaded && modelData && (Settings.data.notifications.monitors.includes(modelData.name) || (Settings.data.notifications.monitors.length === 0)) - // Get appropriate icon based on OSD type - function getIcon() { - if (osdType === OSD.Type.Volume) { - if (AudioService.muted) { - return "volume-mute" + // Loader is only active when actually showing something + active: false + + // Current OSD display state + property string currentOSDType: "" // "volume", "brightness", or "" + + // Volume properties + readonly property real currentVolume: AudioService.volume + readonly property bool isMuted: AudioService.muted + property bool volumeInitialized: false + property bool muteInitialized: false + + // Brightness properties + property bool brightnessInitialized: false + readonly property real currentBrightness: { + if (BrightnessService.monitors.length > 0) { + return BrightnessService.monitors[0].brightness || 0 } - return (AudioService.volume <= Number.EPSILON) ? "volume-zero" : (AudioService.volume <= 0.5) ? "volume-low" : "volume-high" - } else { - // Brightness - var brightness = currentBrightness - return brightness <= 0.5 ? "brightness-low" : "brightness-high" + return 0 } - } - // Get current value (0-1 range) - function getCurrentValue() { - if (osdType === OSD.Type.Volume) { - return isMuted ? 0 : currentVolume - } else { - return currentBrightness + // Get appropriate icon based on current OSD type + function getIcon() { + if (currentOSDType === "volume") { + if (AudioService.muted) { + return "volume-mute" + } + return (AudioService.volume <= Number.EPSILON) ? "volume-zero" : (AudioService.volume <= 0.5) ? "volume-low" : "volume-high" + } else if (currentOSDType === "brightness") { + return currentBrightness <= 0.5 ? "brightness-low" : "brightness-high" + } + return "" } - } - // Get display percentage - function getDisplayPercentage() { - if (osdType === OSD.Type.Volume) { - return isMuted ? "0%" : Math.round(currentVolume * 100) + "%" - } else { - return Math.round(currentBrightness * 100) + "%" + // Get current value (0-1 range) + function getCurrentValue() { + if (currentOSDType === "volume") { + return isMuted ? 0 : currentVolume + } else if (currentOSDType === "brightness") { + return currentBrightness + } + return 0 } - } - // Get progress bar color - function getProgressColor() { - if (osdType === OSD.Type.Volume) { - if (isMuted) - return Color.mError - if (currentVolume > 1.0) - return Color.mError - return Color.mPrimary - } else { + // Get display percentage + function getDisplayPercentage() { + if (currentOSDType === "volume") { + return isMuted ? "0%" : Math.round(currentVolume * 100) + "%" + } else if (currentOSDType === "brightness") { + return Math.round(currentBrightness * 100) + "%" + } + return "" + } + + // Get progress bar color + function getProgressColor() { + if (currentOSDType === "volume") { + if (isMuted) + return Color.mError + if (currentVolume > 1.0) + return Color.mError + return Color.mPrimary + } return Color.mPrimary } - } - // Get icon color - function getIconColor() { - if (osdType === OSD.Type.Volume) { - return isMuted ? Color.mError : Color.mOnSurface - } else { + // Get icon color + function getIconColor() { + if (currentOSDType === "volume" && isMuted) { + return Color.mError + } return Color.mOnSurface } - } - sourceComponent: PanelWindow { - id: panel + sourceComponent: PanelWindow { + screen: modelData - screen: Quickshell.screens[0] // Use primary screen - - anchors { - top: true - } - - implicitWidth: 320 * windowLoader.scaling - implicitHeight: osdItem.height - - // Set margins based on bar position - margins.top: { - switch (Settings.data.bar.position) { - case "top": - return (Style.barHeight + Style.marginS) * windowLoader.scaling + (Settings.data.bar.floating ? Settings.data.bar.marginVertical * Style.marginXL * windowLoader.scaling : 0) - default: - return Style.marginL * windowLoader.scaling + anchors { + top: true } - } - color: Color.transparent + implicitWidth: 320 * root.scaling + implicitHeight: osdItem.height - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - exclusionMode: PanelWindow.ExclusionMode.Ignore - - Rectangle { - id: osdItem - - width: parent.width - height: Math.round(contentLayout.implicitHeight + Style.marginL * 2 * windowLoader.scaling) - radius: Style.radiusL * windowLoader.scaling - color: Color.mSurface - border.color: Color.mOutline - border.width: Math.max(2, Style.borderM * windowLoader.scaling) - visible: false - opacity: 0 - scale: 0.7 - - anchors.horizontalCenter: parent.horizontalCenter - - Behavior on opacity { - NumberAnimation { - duration: Style.animationFast - easing.type: Easing.OutCubic + // Set margins based on bar position + margins.top: { + switch (Settings.data.bar.position) { + case "top": + return (Style.barHeight + Style.marginS) * root.scaling + (Settings.data.bar.floating ? Settings.data.bar.marginVertical * Style.marginXL * root.scaling : 0) + default: + return Style.marginL * root.scaling } } - Behavior on scale { - NumberAnimation { - duration: Style.animationFast - easing.type: Easing.OutCubic + color: Color.transparent + + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + exclusionMode: PanelWindow.ExclusionMode.Ignore + + Rectangle { + id: osdItem + + width: parent.width + height: Math.round(contentLayout.implicitHeight + Style.marginL * 2 * root.scaling) + radius: Style.radiusL * root.scaling + color: Color.mSurface + border.color: Color.mOutline + border.width: Math.max(2, Style.borderM * root.scaling) + visible: false + opacity: 0 + scale: 0.85 + + anchors.horizontalCenter: parent.horizontalCenter + + Behavior on opacity { + NumberAnimation { + id: opacityAnimation + duration: Style.animationNormal + easing.type: Easing.InOutQuad + } } - } - Timer { - id: hideTimer - interval: 2000 - onTriggered: osdItem.hide() - } + Behavior on scale { + NumberAnimation { + id: scaleAnimation + duration: Style.animationNormal + easing.type: Easing.InOutQuad + } + } - RowLayout { - id: contentLayout - anchors.fill: parent - anchors.margins: Style.marginM * windowLoader.scaling - spacing: Style.marginM * windowLoader.scaling + Timer { + id: hideTimer + interval: 2000 + onTriggered: osdItem.hide() + } - NIcon { - icon: windowLoader.getIcon() - color: windowLoader.getIconColor() - font.pointSize: Style.fontSizeXL * windowLoader.scaling - Layout.alignment: Qt.AlignVCenter + // Timer to handle visibility after animations complete + Timer { + id: visibilityTimer + interval: Style.animationNormal + 50 // Add small buffer + onTriggered: { + osdItem.visible = false + root.currentOSDType = "" + // Deactivate the loader when done + root.active = false + } } RowLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - spacing: Style.marginXS * windowLoader.scaling + id: contentLayout + anchors.fill: parent + anchors.margins: Style.marginM * root.scaling + spacing: Style.marginM * root.scaling - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: Math.round(6 * windowLoader.scaling) - radius: Math.round(3 * windowLoader.scaling) - color: Color.mSurfaceVariant + NIcon { + icon: root.getIcon() + color: root.getIconColor() + font.pointSize: Style.fontSizeXL * root.scaling + Layout.alignment: Qt.AlignVCenter - Rectangle { - anchors.left: parent.left - anchors.top: parent.top - anchors.bottom: parent.bottom - width: parent.width * Math.min(1.0, windowLoader.getCurrentValue()) - radius: parent.radius - color: windowLoader.getProgressColor() - - Behavior on width { - NumberAnimation { - duration: Style.animationFast - easing.type: Easing.OutCubic - } - } - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - } + // Smooth icon transitions + Behavior on color { + ColorAnimation { + duration: Style.animationNormal + easing.type: Easing.InOutQuad } } } - NText { - text: windowLoader.getDisplayPercentage() - color: Color.mOnSurfaceVariant - font.pointSize: Style.fontSizeS * windowLoader.scaling + RowLayout { + Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter - Layout.minimumWidth: Math.round(32 * windowLoader.scaling) + spacing: Style.marginXS * root.scaling + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Math.round(6 * root.scaling) + radius: Math.round(3 * root.scaling) + color: Color.mSurfaceVariant + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * Math.min(1.0, root.getCurrentValue()) + radius: parent.radius + color: root.getProgressColor() + + Behavior on width { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.InOutQuad + } + } + + Behavior on color { + ColorAnimation { + duration: Style.animationNormal + easing.type: Easing.InOutQuad + } + } + } + } + + NText { + text: root.getDisplayPercentage() + color: Color.mOnSurface + font.pointSize: Style.fontSizeS * root.scaling + Layout.alignment: Qt.AlignVCenter + Layout.minimumWidth: Math.round(32 * root.scaling) + } } } + + function show() { + // Cancel any pending hide operations + hideTimer.stop() + visibilityTimer.stop() + + // Make visible and animate in + osdItem.visible = true + // Use Qt.callLater to ensure the visible change is processed before animation + Qt.callLater(function () { + osdItem.opacity = 1 + osdItem.scale = 1.0 + }) + + // Start the auto-hide timer + hideTimer.start() + } + + function hide() { + hideTimer.stop() + visibilityTimer.stop() + + // Start fade out animation + osdItem.opacity = 0 + osdItem.scale = 0.85 // Less dramatic scale change for smoother effect + + // Delay hiding the element until after animation completes + visibilityTimer.start() + } + + function hideImmediately() { + hideTimer.stop() + visibilityTimer.stop() + osdItem.opacity = 0 + osdItem.scale = 0.85 + osdItem.visible = false + root.currentOSDType = "" + root.active = false + } } - function show() { - hideTimer.stop() - osdItem.visible = true - osdItem.opacity = 1 - osdItem.scale = 1.0 - hideTimer.start() + function showOSD() { + osdItem.show() + } + } + + // Volume change monitoring + Connections { + target: AudioService + + function onVolumeChanged() { + if (!volumeInitialized) { + volumeInitialized = true + } else { + showOSD("volume") + } } - function hide() { - hideTimer.stop() - osdItem.opacity = 0 - osdItem.scale = 0.7 + function onMutedChanged() { + if (!muteInitialized) { + muteInitialized = true + } else { + showOSD("volume") + } + } + } + // Brightness change monitoring + Connections { + target: BrightnessService + + function onMonitorsChanged() { + connectBrightnessMonitors() + } + } + + Component.onCompleted: { + connectBrightnessMonitors() + } + + function connectBrightnessMonitors() { + for (var i = 0; i < BrightnessService.monitors.length; i++) { + let monitor = BrightnessService.monitors[i] + // Disconnect first to avoid duplicate connections + monitor.brightnessUpdated.disconnect(onBrightnessChanged) + monitor.brightnessUpdated.connect(onBrightnessChanged) + } + } + + function onBrightnessChanged(newBrightness) { + if (!brightnessInitialized) { + brightnessInitialized = true + } else { + showOSD("brightness") + } + } + + function showOSD(type) { + // Check if OSD is enabled in settings and can show on this screen + if (!Settings.data.notifications.enableOSD || !canShowOnThisScreen) { + return + } + + // Update the current OSD type + currentOSDType = type + + // Activate the loader if not already active + if (!root.active) { + root.active = true + } + + // Show the OSD (may need to wait for loader to create the item) + if (root.item) { + root.item.showOSD() + } else { + // If item not ready yet, wait for it Qt.callLater(function () { - osdItem.visible = false - windowLoader.active = false + if (root.item) { + root.item.showOSD() + } }) } + } - function hideImmediately() { - hideTimer.stop() - osdItem.opacity = 0 - osdItem.scale = 0.7 - osdItem.visible = false - windowLoader.active = false + function hideOSD() { + if (root.item && root.item.osdItem) { + root.item.osdItem.hideImmediately() + } else if (root.active) { + // If loader is active but item isn't ready, just deactivate + root.active = false } } - - function showOSD() { - osdItem.show() - } - } - - // Volume change monitoring - Connections { - target: AudioService - enabled: osdType === OSD.Type.Volume - - function onVolumeChanged() { - if (!firstVolumeReceived) { - firstVolumeReceived = true - } else { - showOSD() - } - } - - function onMutedChanged() { - if (!firstMuteReceived) { - firstMuteReceived = true - } else { - showOSD() - } - } - } - - // Brightness change monitoring - Connections { - target: BrightnessService - enabled: osdType === OSD.Type.Brightness - - function onMonitorsChanged() { - for (var i = 0; i < BrightnessService.monitors.length; i++) { - let monitor = BrightnessService.monitors[i] - monitor.brightnessUpdated.connect(windowLoader.onBrightnessChanged) - } - } - } - - Component.onCompleted: { - if (osdType === OSD.Type.Brightness) { - for (var i = 0; i < BrightnessService.monitors.length; i++) { - let monitor = BrightnessService.monitors[i] - monitor.brightnessUpdated.connect(windowLoader.onBrightnessChanged) - } - } - } - - function onBrightnessChanged(newBrightness) { - if (!firstBrightnessReceived) { - firstBrightnessReceived = true - } else { - showOSD() - } - } - - // Signal to coordinate with other OSDs - signal osdShowing - - function showOSD() { - // Check if OSD is enabled in settings - if (!Settings.data.notifications.enableOSD) { - return - } - - osdShowing() // Notify other OSDs to hide - windowLoader.active = true - if (windowLoader.item) { - windowLoader.item.showOSD() - } - } - - function hideOSD() { - if (windowLoader.item && windowLoader.item.osdItem) { - windowLoader.item.osdItem.hideImmediately() - } else if (windowLoader.active) { - // If window exists but osdItem isn't ready, just deactivate the loader - windowLoader.active = false - } } } diff --git a/shell.qml b/shell.qml index b7418b24c..5a90a74e4 100644 --- a/shell.qml +++ b/shell.qml @@ -59,21 +59,7 @@ ShellRoot { } ToastOverlay {} - - // OSD overlays for volume and brightness - OSD { - id: volumeOSD - objectName: "volumeOSD" - osdType: OSD.Type.Volume - onOsdShowing: brightnessOSD.hideOSD() - } - - OSD { - id: brightnessOSD - objectName: "brightnessOSD" - osdType: OSD.Type.Brightness - onOsdShowing: volumeOSD.hideOSD() - } + OSD {} // IPCService is treated as a service // but it's actually an Item that needs to exists in the shell.