From ee6f7b479859b2de5126281cc45ee8fedb27f369 Mon Sep 17 00:00:00 2001 From: bbedward Date: Tue, 7 Jul 2026 18:16:26 -0400 Subject: [PATCH] qs/media: replace FBOs with ClippingRectangle and rewrite seekbar as a shader --- quickshell/Modals/PowerMenuModal.qml | 36 +-- .../Modules/DankDash/DankDashPopout.qml | 6 +- .../Modules/DankDash/MediaPlayerTab.qml | 35 ++- .../DankDash/Overview/MediaOverviewCard.qml | 4 +- quickshell/Modules/Lock/LockPowerMenu.qml | 36 +-- quickshell/Modules/OSD/MediaPlaybackOSD.qml | 26 +- .../Modules/Settings/ThemeColorsTab.qml | 51 ++-- quickshell/Modules/Settings/WallpaperTab.qml | 161 +++++------ .../WorkspaceOverlays/OverviewWindow.qml | 99 +++---- quickshell/Services/MediaAccentService.qml | 73 +++++ quickshell/Shaders/frag/wave_progress.frag | 115 ++++++++ quickshell/Shaders/qsb/wave_progress.frag.qsb | Bin 0 -> 6025 bytes quickshell/Widgets/DankAlbumArt.qml | 5 +- quickshell/Widgets/DankCircularImage.qml | 93 +++---- quickshell/Widgets/DankSeekbar.qml | 6 +- quickshell/Widgets/DankTabBar.qml | 5 + quickshell/Widgets/M3WaveProgress.qml | 254 ++---------------- 17 files changed, 427 insertions(+), 578 deletions(-) create mode 100644 quickshell/Services/MediaAccentService.qml create mode 100644 quickshell/Shaders/frag/wave_progress.frag create mode 100644 quickshell/Shaders/qsb/wave_progress.frag.qsb diff --git a/quickshell/Modals/PowerMenuModal.qml b/quickshell/Modals/PowerMenuModal.qml index fd61f68da..3c36b712d 100644 --- a/quickshell/Modals/PowerMenuModal.qml +++ b/quickshell/Modals/PowerMenuModal.qml @@ -1,6 +1,6 @@ import QtQuick -import QtQuick.Effects import Quickshell +import Quickshell.Widgets import qs.Common import qs.Modals.Common import qs.Services @@ -591,24 +591,11 @@ DankModal { border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.primary, 0) border.width: isSelected ? 2 : 0 - Rectangle { - id: gridProgressMask + ClippingRectangle { anchors.fill: parent radius: parent.radius - visible: false - layer.enabled: true - } - - Item { - anchors.fill: parent + color: "transparent" visible: gridButtonRect.isHolding - layer.enabled: gridButtonRect.isHolding - layer.effect: MultiEffect { - maskEnabled: true - maskSource: gridProgressMask - maskSpreadAtMin: 1 - maskThresholdMin: 0.5 - } Rectangle { anchors.left: parent.left @@ -729,24 +716,11 @@ DankModal { border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.primary, 0) border.width: isSelected ? 2 : 0 - Rectangle { - id: listProgressMask + ClippingRectangle { anchors.fill: parent radius: parent.radius - visible: false - layer.enabled: true - } - - Item { - anchors.fill: parent + color: "transparent" visible: listButtonRect.isHolding - layer.enabled: listButtonRect.isHolding - layer.effect: MultiEffect { - maskEnabled: true - maskSource: listProgressMask - maskSpreadAtMin: 1 - maskThresholdMin: 0.5 - } Rectangle { anchors.left: parent.left diff --git a/quickshell/Modules/DankDash/DankDashPopout.qml b/quickshell/Modules/DankDash/DankDashPopout.qml index 5319fc12a..849be319f 100644 --- a/quickshell/Modules/DankDash/DankDashPopout.qml +++ b/quickshell/Modules/DankDash/DankDashPopout.qml @@ -240,8 +240,10 @@ DankPopout { Connections { target: root function onShouldBeVisibleChanged() { - if (root.shouldBeVisible) - mainContainer.forceActiveFocus(); + if (!root.shouldBeVisible) + return; + mainContainer.forceActiveFocus(); + tabBar.snapIndicator(); } } diff --git a/quickshell/Modules/DankDash/MediaPlayerTab.qml b/quickshell/Modules/DankDash/MediaPlayerTab.qml index 0a57f8746..ea2345af0 100644 --- a/quickshell/Modules/DankDash/MediaPlayerTab.qml +++ b/quickshell/Modules/DankDash/MediaPlayerTab.qml @@ -25,6 +25,11 @@ Item { property string section: "" property int barPosition: SettingsData.Position.Top + readonly property color accent: MediaAccentService.accent + readonly property color onAccent: MediaAccentService.onAccent + readonly property color accentHover: MediaAccentService.accentHover + readonly property color accentPressed: MediaAccentService.accentPressed + signal showVolumeDropdown(point pos, var screen, bool rightEdge, var player, var players) signal showAudioDevicesDropdown(point pos, var screen, bool rightEdge) signal showPlayersDropdown(point pos, var screen, bool rightEdge, var player, var players) @@ -298,8 +303,16 @@ Item { Component.onCompleted: syncArt() function syncArt() { - if (curArt === "" || layerA.art == curArt || layerB.art == curArt) + if (curArt === "") return; + const frontArt = _showA ? layerA.art : layerB.art; + const backArt = _showA ? layerB.art : layerA.art; + if (frontArt == curArt) + return; + if (backArt == curArt) { + _showA = !_showA; + return; + } if (_showA) layerB.art = curArt; else @@ -564,13 +577,13 @@ Item { height: 40 radius: 20 anchors.centerIn: parent - color: shuffleArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0) + color: shuffleArea.containsMouse ? root.accentHover : Theme.withAlpha(root.accent, 0) DankIcon { anchors.centerIn: parent name: "shuffle" size: 20 - color: activePlayer && activePlayer.shuffle ? Theme.primary : Theme.surfaceText + color: activePlayer && activePlayer.shuffle ? root.accent : Theme.surfaceText } MouseArea { @@ -626,13 +639,13 @@ Item { height: 50 radius: 25 anchors.centerIn: parent - color: Theme.primary + color: root.accent DankIcon { anchors.centerIn: parent name: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow" size: 28 - color: Theme.background + color: root.onAccent weight: 500 } @@ -696,7 +709,7 @@ Item { height: 40 radius: 20 anchors.centerIn: parent - color: repeatArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0) + color: repeatArea.containsMouse ? root.accentHover : Theme.withAlpha(root.accent, 0) DankIcon { anchors.centerIn: parent @@ -713,7 +726,7 @@ Item { } } size: 20 - color: activePlayer && activePlayer.loopState !== MprisLoopState.None ? Theme.primary : Theme.surfaceText + color: activePlayer && activePlayer.loopState !== MprisLoopState.None ? root.accent : Theme.surfaceText } MouseArea { @@ -752,7 +765,7 @@ Item { radius: 20 x: isRightEdge ? Theme.spacingM : parent.width - 40 - Theme.spacingM y: 185 - color: playerSelectorArea.containsMouse || playersExpanded ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0) + color: playerSelectorArea.containsMouse || playersExpanded ? root.accentPressed : Theme.withAlpha(root.accentPressed, 0) border.color: Theme.outlineStrong border.width: 1 z: 100 @@ -819,7 +832,7 @@ Item { radius: 20 x: isRightEdge ? Theme.spacingM : parent.width - 40 - Theme.spacingM y: 130 - color: volumeButtonArea.containsMouse && volumeAvailable || volumeExpanded ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0) + color: volumeButtonArea.containsMouse && volumeAvailable || volumeExpanded ? root.accentPressed : Theme.withAlpha(root.accentPressed, 0) border.color: volumeAvailable ? Theme.outlineStrong : Theme.outlineMedium border.width: 1 z: 101 @@ -831,7 +844,7 @@ Item { anchors.centerIn: parent name: getVolumeIcon() size: 18 - color: volumeAvailable && currentVolume > 0 ? Theme.primary : Theme.withAlpha(Theme.surfaceText, volumeAvailable ? 1.0 : 0.5) + color: volumeAvailable && currentVolume > 0 ? root.accent : Theme.withAlpha(Theme.surfaceText, volumeAvailable ? 1.0 : 0.5) } MouseArea { @@ -882,7 +895,7 @@ Item { radius: 20 x: isRightEdge ? Theme.spacingM : parent.width - 40 - Theme.spacingM y: 240 - color: audioDevicesArea.containsMouse || devicesExpanded ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0) + color: audioDevicesArea.containsMouse || devicesExpanded ? root.accentPressed : Theme.withAlpha(root.accentPressed, 0) border.color: Theme.outlineStrong border.width: 1 z: 100 diff --git a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml index 648e3ebdf..c2cbed7fc 100644 --- a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml +++ b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml @@ -154,13 +154,13 @@ Card { width: 32 height: 32 radius: 16 - color: Theme.primary + color: MediaAccentService.accent DankIcon { anchors.centerIn: parent name: activePlayer?.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow" size: 16 - color: Theme.background + color: MediaAccentService.onAccent } MouseArea { diff --git a/quickshell/Modules/Lock/LockPowerMenu.qml b/quickshell/Modules/Lock/LockPowerMenu.qml index 2f20d1f11..a9898f217 100644 --- a/quickshell/Modules/Lock/LockPowerMenu.qml +++ b/quickshell/Modules/Lock/LockPowerMenu.qml @@ -1,7 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick -import QtQuick.Effects +import Quickshell.Widgets import qs.Common import qs.Services import qs.Widgets @@ -567,24 +567,11 @@ Rectangle { border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.primary, 0) border.width: isSelected ? 2 : 0 - Rectangle { - id: gridProgressMask + ClippingRectangle { anchors.fill: parent radius: parent.radius - visible: false - layer.enabled: true - } - - Item { - anchors.fill: parent + color: "transparent" visible: gridButtonRect.isHolding - layer.enabled: gridButtonRect.isHolding - layer.effect: MultiEffect { - maskEnabled: true - maskSource: gridProgressMask - maskSpreadAtMin: 1 - maskThresholdMin: 0.5 - } Rectangle { anchors.left: parent.left @@ -700,24 +687,11 @@ Rectangle { border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.primary, 0) border.width: isSelected ? 2 : 0 - Rectangle { - id: listProgressMask + ClippingRectangle { anchors.fill: parent radius: parent.radius - visible: false - layer.enabled: true - } - - Item { - anchors.fill: parent + color: "transparent" visible: listButtonRect.isHolding - layer.enabled: listButtonRect.isHolding - layer.effect: MultiEffect { - maskEnabled: true - maskSource: listProgressMask - maskSpreadAtMin: 1 - maskThresholdMin: 0.5 - } Rectangle { anchors.left: parent.left diff --git a/quickshell/Modules/OSD/MediaPlaybackOSD.qml b/quickshell/Modules/OSD/MediaPlaybackOSD.qml index 5d92388f7..91c602ddf 100644 --- a/quickshell/Modules/OSD/MediaPlaybackOSD.qml +++ b/quickshell/Modules/OSD/MediaPlaybackOSD.qml @@ -4,6 +4,7 @@ import qs.Common import qs.Services import qs.Widgets import Quickshell.Services.Mpris +import Quickshell.Widgets DankOSD { id: root @@ -185,10 +186,11 @@ DankOSD { visible: false } - Item { - id: blurredBg + ClippingRectangle { anchors.fill: parent - visible: false + radius: Theme.cornerRadius + color: "transparent" + opacity: 0.7 MultiEffect { anchors.centerIn: parent @@ -203,24 +205,6 @@ DankOSD { } } - Rectangle { - id: bgMask - anchors.fill: parent - radius: Theme.cornerRadius - visible: false - layer.enabled: true - } - - MultiEffect { - anchors.fill: parent - source: blurredBg - maskEnabled: true - maskSource: bgMask - maskThresholdMin: 0.5 - maskSpreadAtMin: 1.0 - opacity: 0.7 - } - Rectangle { anchors.fill: parent radius: Theme.cornerRadius diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml index ac35d0fc8..808cedabb 100644 --- a/quickshell/Modules/Settings/ThemeColorsTab.qml +++ b/quickshell/Modules/Settings/ThemeColorsTab.qml @@ -1,7 +1,7 @@ import QtCore import QtQuick -import QtQuick.Effects import Quickshell +import Quickshell.Widgets import qs.Common import qs.Modals.FileBrowser import qs.Services @@ -520,28 +520,27 @@ Item { radius: Theme.cornerRadius color: Theme.surfaceVariant - Image { + ClippingRectangle { anchors.fill: parent anchors.margins: 1 - source: { - var wp = Theme.wallpaperPath; - if (!wp || wp === "" || wp.startsWith("#")) - return ""; - if (wp.startsWith("file://")) - wp = wp.substring(7); - return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); - } - fillMode: Image.PreserveAspectCrop - visible: Theme.wallpaperPath && !Theme.wallpaperPath.startsWith("#") - sourceSize.width: 120 - sourceSize.height: 120 - asynchronous: true - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: autoWallpaperMask - maskThresholdMin: 0.5 - maskSpreadAtMin: 1 + radius: Theme.cornerRadius - 1 + color: "transparent" + + Image { + anchors.fill: parent + source: { + var wp = Theme.wallpaperPath; + if (!wp || wp === "" || wp.startsWith("#")) + return ""; + if (wp.startsWith("file://")) + wp = wp.substring(7); + return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); + } + fillMode: Image.PreserveAspectCrop + visible: Theme.wallpaperPath && !Theme.wallpaperPath.startsWith("#") + sourceSize.width: 120 + sourceSize.height: 120 + asynchronous: true } } @@ -553,16 +552,6 @@ Item { visible: Theme.wallpaperPath && Theme.wallpaperPath.startsWith("#") } - Rectangle { - id: autoWallpaperMask - anchors.fill: parent - anchors.margins: 1 - radius: Theme.cornerRadius - 1 - color: "black" - visible: false - layer.enabled: true - } - DankIcon { anchors.centerIn: parent name: (ToastService.wallpaperErrorStatus === "error" || ToastService.wallpaperErrorStatus === "matugen_missing") ? "error" : "palette" diff --git a/quickshell/Modules/Settings/WallpaperTab.qml b/quickshell/Modules/Settings/WallpaperTab.qml index bf054b095..90afda2c3 100644 --- a/quickshell/Modules/Settings/WallpaperTab.qml +++ b/quickshell/Modules/Settings/WallpaperTab.qml @@ -1,6 +1,6 @@ import QtQuick -import QtQuick.Effects import Quickshell +import Quickshell.Widgets import qs.Common import qs.Modals.FileBrowser import qs.Services @@ -75,28 +75,27 @@ Item { radius: Theme.cornerRadius color: Theme.surfaceVariant - Image { + ClippingRectangle { anchors.fill: parent anchors.margins: 1 - source: { - var wp = root.currentWallpaper; - if (wp === "" || wp.startsWith("#")) - return ""; - if (wp.startsWith("file://")) - wp = wp.substring(7); - return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); - } - fillMode: Image.PreserveAspectCrop - visible: root.currentWallpaper !== "" && !root.currentWallpaper.startsWith("#") - sourceSize.width: 160 - sourceSize.height: 160 - asynchronous: true - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: wallpaperMask - maskThresholdMin: 0.5 - maskSpreadAtMin: 1 + radius: Theme.cornerRadius - 1 + color: "transparent" + + Image { + anchors.fill: parent + source: { + var wp = root.currentWallpaper; + if (wp === "" || wp.startsWith("#")) + return ""; + if (wp.startsWith("file://")) + wp = wp.substring(7); + return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); + } + fillMode: Image.PreserveAspectCrop + visible: root.currentWallpaper !== "" && !root.currentWallpaper.startsWith("#") + sourceSize.width: 160 + sourceSize.height: 160 + asynchronous: true } } @@ -108,16 +107,6 @@ Item { visible: root.currentWallpaper !== "" && root.currentWallpaper.startsWith("#") } - Rectangle { - id: wallpaperMask - anchors.fill: parent - anchors.margins: 1 - radius: Theme.cornerRadius - 1 - color: "black" - visible: false - layer.enabled: true - } - DankIcon { anchors.centerIn: parent name: "image" @@ -421,31 +410,30 @@ Item { radius: Theme.cornerRadius color: Theme.surfaceVariant - Image { + ClippingRectangle { anchors.fill: parent anchors.margins: 1 - source: { - var wp = SessionData.wallpaperPathLight; - if (wp === "" || wp.startsWith("#")) - return ""; - if (wp.startsWith("file://")) - wp = wp.substring(7); - return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); - } - fillMode: Image.PreserveAspectCrop - visible: { - var lightWallpaper = SessionData.wallpaperPathLight; - return lightWallpaper !== "" && !lightWallpaper.startsWith("#"); - } - sourceSize.width: 160 - sourceSize.height: 160 - asynchronous: true - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: lightMask - maskThresholdMin: 0.5 - maskSpreadAtMin: 1 + radius: Theme.cornerRadius - 1 + color: "transparent" + + Image { + anchors.fill: parent + source: { + var wp = SessionData.wallpaperPathLight; + if (wp === "" || wp.startsWith("#")) + return ""; + if (wp.startsWith("file://")) + wp = wp.substring(7); + return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); + } + fillMode: Image.PreserveAspectCrop + visible: { + var lightWallpaper = SessionData.wallpaperPathLight; + return lightWallpaper !== "" && !lightWallpaper.startsWith("#"); + } + sourceSize.width: 160 + sourceSize.height: 160 + asynchronous: true } } @@ -463,16 +451,6 @@ Item { } } - Rectangle { - id: lightMask - anchors.fill: parent - anchors.margins: 1 - radius: Theme.cornerRadius - 1 - color: "black" - visible: false - layer.enabled: true - } - DankIcon { anchors.centerIn: parent name: "light_mode" @@ -611,31 +589,30 @@ Item { radius: Theme.cornerRadius color: Theme.surfaceVariant - Image { + ClippingRectangle { anchors.fill: parent anchors.margins: 1 - source: { - var wp = SessionData.wallpaperPathDark; - if (wp === "" || wp.startsWith("#")) - return ""; - if (wp.startsWith("file://")) - wp = wp.substring(7); - return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); - } - fillMode: Image.PreserveAspectCrop - visible: { - var darkWallpaper = SessionData.wallpaperPathDark; - return darkWallpaper !== "" && !darkWallpaper.startsWith("#"); - } - sourceSize.width: 160 - sourceSize.height: 160 - asynchronous: true - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: darkMask - maskThresholdMin: 0.5 - maskSpreadAtMin: 1 + radius: Theme.cornerRadius - 1 + color: "transparent" + + Image { + anchors.fill: parent + source: { + var wp = SessionData.wallpaperPathDark; + if (wp === "" || wp.startsWith("#")) + return ""; + if (wp.startsWith("file://")) + wp = wp.substring(7); + return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); + } + fillMode: Image.PreserveAspectCrop + visible: { + var darkWallpaper = SessionData.wallpaperPathDark; + return darkWallpaper !== "" && !darkWallpaper.startsWith("#"); + } + sourceSize.width: 160 + sourceSize.height: 160 + asynchronous: true } } @@ -653,16 +630,6 @@ Item { } } - Rectangle { - id: darkMask - anchors.fill: parent - anchors.margins: 1 - radius: Theme.cornerRadius - 1 - color: "black" - visible: false - layer.enabled: true - } - DankIcon { anchors.centerIn: parent name: "dark_mode" diff --git a/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml b/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml index aea653d58..d8fc38642 100644 --- a/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml +++ b/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml @@ -1,8 +1,8 @@ import QtQuick -import QtQuick.Effects import QtQuick.Layouts import Quickshell import Quickshell.Wayland +import Quickshell.Widgets import qs.Common Item { @@ -58,23 +58,6 @@ Item { visible: intersectsViewport opacity: (monitorObj?.id ?? -1) == widgetMonitorId ? 1 : 0.4 - Rectangle { - id: maskRect - width: root.width - height: root.height - radius: Theme.cornerRadius - visible: false - layer.enabled: true - } - - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: maskRect - maskSpreadAtMin: 1 - maskThresholdMin: 0.5 - } - Behavior on x { NumberAnimation { duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) @@ -104,51 +87,55 @@ Item { } } - ScreencopyView { - id: windowPreview + ClippingRectangle { anchors.fill: parent - captureSource: root.overviewOpen ? root.toplevel?.wayland : null - live: true + radius: Theme.cornerRadius + color: "transparent" - Rectangle { + ScreencopyView { + id: windowPreview anchors.fill: parent - radius: Theme.cornerRadius - color: pressed ? Theme.withAlpha(Theme.surfaceContainerHigh, 0.5) : - hovered ? Theme.withAlpha(Theme.surfaceVariant, 0.3) : - Theme.withAlpha(Theme.surfaceContainer, 0.1) - border.color: Theme.withAlpha(Theme.outline, 0.3) - border.width: 1 - } + captureSource: root.overviewOpen ? root.toplevel?.wayland : null + live: true - ColumnLayout { - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.right: parent.right - spacing: Theme.fontSizeSmall * 0.5 + Rectangle { + anchors.fill: parent + radius: Theme.cornerRadius + color: pressed ? Theme.withAlpha(Theme.surfaceContainerHigh, 0.5) : hovered ? Theme.withAlpha(Theme.surfaceVariant, 0.3) : Theme.withAlpha(Theme.surfaceContainer, 0.1) + border.color: Theme.withAlpha(Theme.outline, 0.3) + border.width: 1 + } - Image { - id: windowIcon - property var iconSize: { - return Math.min(targetWindowWidth, targetWindowHeight) * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) / (root.monitorData?.scale ?? 1) - } - Layout.alignment: Qt.AlignHCenter - source: root.iconPath - width: iconSize - height: iconSize - sourceSize: Qt.size(iconSize, iconSize) + ColumnLayout { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + spacing: Theme.fontSizeSmall * 0.5 - Behavior on width { - NumberAnimation { - duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.variantModalEnterCurve + Image { + id: windowIcon + property var iconSize: { + return Math.min(targetWindowWidth, targetWindowHeight) * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) / (root.monitorData?.scale ?? 1); } - } - Behavior on height { - NumberAnimation { - duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.variantModalEnterCurve + Layout.alignment: Qt.AlignHCenter + source: root.iconPath + width: iconSize + height: iconSize + sourceSize: Qt.size(iconSize, iconSize) + + Behavior on width { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.variantModalEnterCurve + } + } + Behavior on height { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.variantModalEnterCurve + } } } } diff --git a/quickshell/Services/MediaAccentService.qml b/quickshell/Services/MediaAccentService.qml new file mode 100644 index 000000000..1c7d0de87 --- /dev/null +++ b/quickshell/Services/MediaAccentService.qml @@ -0,0 +1,73 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import qs.Common +import qs.Services + +// Accent color extracted from the current track's album art via ColorQuantizer, +// falling back to Theme.primary when no usable accent is available. +Singleton { + id: root + + readonly property bool hasAccent: _accent !== null + readonly property color accent: _accent !== null ? _accent : Theme.primary + + readonly property color onAccent: { + const c = accent; + const lum = 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b; + return lum > 0.6 ? Qt.rgba(0, 0, 0, 1) : Qt.rgba(1, 1, 1, 1); + } + + readonly property color accentHover: Theme.withAlpha(accent, 0.12) + readonly property color accentPressed: Theme.withAlpha(accent, Theme.transparentBlurLayers ? 0.24 : 0.16) + + // Plain-named alias: underscore-prefixed props with onChanged handlers crash config load. + readonly property string artUrl: TrackArtService.resolvedArtUrl + onArtUrlChanged: { + if (artUrl === "") + _accent = null; + } + + property var _accent: null + + ColorQuantizer { + id: quantizer + source: root.artUrl + depth: 4 + rescaleSize: 64 + onColorsChanged: root._accent = root._pickAccent(colors) + } + + function _pickAccent(colors) { + if (!colors || colors.length === 0) + return null; + + let best = null; + let bestScore = -1; + for (let i = 0; i < colors.length; i++) { + const c = colors[i]; + const s = c.hsvSaturation; + const v = c.hsvValue; + if (v < 0.22 || v > 0.96 || s < 0.22) + continue; + const score = s * (1 - Math.abs(v - 0.68)); + if (score > bestScore) { + bestScore = score; + best = c; + } + } + + if (!best) + return null; + return _normalize(best); + } + + function _normalize(c) { + const hue = c.hsvHue < 0 ? 0 : c.hsvHue; + const s = Math.min(1, c.hsvSaturation * 1.05); + const v = Math.max(c.hsvValue, 0.62); + return Qt.hsva(hue, s, v, 1); + } +} diff --git a/quickshell/Shaders/frag/wave_progress.frag b/quickshell/Shaders/frag/wave_progress.frag new file mode 100644 index 000000000..0fe6a1bed --- /dev/null +++ b/quickshell/Shaders/frag/wave_progress.frag @@ -0,0 +1,115 @@ +#version 450 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float widthPx; + float heightPx; + float value; + float actualValue; + float phase; + float ampPx; + float wavelengthPx; + float lineWidthPx; + float showActual; + vec4 fillColor; + vec4 trackColor; + vec4 playheadColor; + vec4 actualColor; +} ubuf; + +const float TAU = 6.28318530718; +const float AA = 0.75; // pixel-space antialias band + +// Signed distance to a rounded box centered at the origin. +float sdRoundBar(vec2 p, vec2 halfSize, float r) { + vec2 q = abs(p) - halfSize + vec2(r); + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; +} + +// Composite a straight-alpha color over a premultiplied accumulator. +vec4 blendOver(vec4 dst, vec3 rgb, float a) { + return vec4(rgb * a + dst.rgb * (1.0 - a), a + dst.a * (1.0 - a)); +} + +void main() { + float w = ubuf.widthPx; + float h = ubuf.heightPx; + vec2 px = vec2(qt_TexCoord0.x * w, qt_TexCoord0.y * h); + + float mid = h * 0.5; + float halfW = ubuf.lineWidthPx * 0.5; + float k = TAU / max(ubuf.wavelengthPx, 1e-3); + + float playX = clamp(ubuf.value, 0.0, 1.0) * w; + float actualX = clamp(ubuf.actualValue, 0.0, 1.0) * w; + bool seeking = ubuf.showActual > 0.5; + + float loX = min(playX, actualX); + float hiX = max(playX, actualX); + float fillEnd = seeking ? loX : playX; // filled progress ends here + float actStart = seeking ? loX : playX; // seek-preview segment + float actEnd = seeking ? hiX : playX; + float trackStart = seeking ? hiX : playX; // unplayed remainder + + // Perpendicular distance to the animated sine stroke. + float ang = k * px.x + ubuf.phase; + float wy = mid + ubuf.ampPx * sin(ang); + float slope = ubuf.ampPx * k * cos(ang); + float dWave = abs(px.y - wy) / sqrt(1.0 + slope * slope); + float aaW = AA; + float waveStroke = 1.0 - smoothstep(halfW - aaW, halfW + aaW, dWave); + + // Straight remainder line. + float dLine = abs(px.y - mid); + float aaL = AA; + float lineStroke = 1.0 - smoothstep(halfW - aaL, halfW + aaL, dLine); + + vec4 col = vec4(0.0); + + // 1. Track (unplayed remainder), to the right of the progress head. + { + float m = lineStroke * step(trackStart, px.x); + col = blendOver(col, ubuf.trackColor.rgb, ubuf.trackColor.a * m); + } + + // 2. Seek-preview segment (only while seeking). + if (seeking) { + float m = waveStroke * step(actStart, px.x) * step(px.x, actEnd); + col = blendOver(col, ubuf.actualColor.rgb, ubuf.actualColor.a * m); + } + + // 3. Filled progress wave. + { + float m = waveStroke * step(halfW, px.x) * step(px.x, fillEnd); + // Rounded start cap. + float capS = length(px - vec2(halfW, mid + ubuf.ampPx * sin(k * halfW + ubuf.phase))) - halfW; + float capM = 1.0 - smoothstep(-aaW, aaW, capS); + m = max(m, capM * step(halfW - 1.0, px.x)); + col = blendOver(col, ubuf.fillColor.rgb, ubuf.fillColor.a * m); + } + + // 4. Actual-position marker (only while seeking). + if (seeking) { + float amH = max(ubuf.lineWidthPx + 4.0, 10.0); + float d = sdRoundBar(px - vec2(actualX, mid), vec2(1.0, amH * 0.5), 1.0); + float aa = AA; + float m = 1.0 - smoothstep(-aa, aa, d); + col = blendOver(col, ubuf.actualColor.rgb, ubuf.actualColor.a * m); + } + + // 5. Playhead pill (on top). + { + float phW = 3.5; + float phH = max(ubuf.lineWidthPx + 12.0, 16.0); + float d = sdRoundBar(px - vec2(playX, mid), vec2(phW * 0.5, phH * 0.5), phW * 0.5); + float aa = AA; + float m = 1.0 - smoothstep(-aa, aa, d); + col = blendOver(col, ubuf.playheadColor.rgb, ubuf.playheadColor.a * m); + } + + fragColor = col * ubuf.qt_Opacity; +} diff --git a/quickshell/Shaders/qsb/wave_progress.frag.qsb b/quickshell/Shaders/qsb/wave_progress.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..2347db10703232ad765089176b504a4b266f42fb GIT binary patch literal 6025 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 root.actualValue - readonly property bool previewBehind: root.showActualPlaybackState && root.value < root.actualValue - readonly property real previewGapStartX: Math.min(root.playX, root.actualX) - readonly property real previewGapEndX: Math.max(root.playX, root.actualX) - Behavior on currentAmp { NumberAnimation { duration: 300 @@ -39,236 +27,34 @@ Item { } } onIsPlayingChanged: currentAmp = isPlaying ? amp : 0 + Component.onCompleted: currentAmp = isPlaying ? amp : 0 - Shape { - id: flatTrack + ShaderEffect { anchors.fill: parent - antialiasing: true - preferredRendererType: Shape.CurveRenderer - layer.enabled: true + blending: true - ShapePath { - strokeColor: root.trackColor - strokeWidth: snap(root.lineWidth) - capStyle: ShapePath.RoundCap - joinStyle: ShapePath.RoundJoin - fillColor: "transparent" - PathMove { - id: flatStart - x: Math.min(root.width, snap(root.playX + playhead.width / 2)) - y: root.midY - } - PathLine { - id: flatEnd - x: root.width - y: root.midY - } - } + readonly property real widthPx: width + readonly property real heightPx: height + readonly property real value: root.value + readonly property real actualValue: root.actualValue + readonly property real phase: root.phase + readonly property real ampPx: root.currentAmp + readonly property real wavelengthPx: root.wavelength + readonly property real lineWidthPx: root.lineWidth + readonly property real showActual: root.showActualPlaybackState ? 1.0 : 0.0 + readonly property color fillColor: root.fillColor + readonly property color trackColor: root.trackColor + readonly property color playheadColor: root.playheadColor + readonly property color actualColor: root.actualProgressColor + + fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wave_progress.frag.qsb") } - Item { - id: waveClip - anchors.fill: parent - clip: true - - readonly property real startX: snap(root.lineWidth / 2) - readonly property real aaBias: (0.25 / root.dpr) - readonly property real endX: root.previewAhead ? Math.max(startX, Math.min(root.actualX - aaBias, width)) : Math.max(startX, Math.min(root.playX - startX - aaBias, width)) - readonly property real gapStartX: root.previewAhead ? Math.max(startX, Math.min(root.actualX + aaBias, width)) : Math.max(startX, Math.min(root.playX + playhead.width / 2, width)) - readonly property real gapEndX: root.previewAhead ? Math.max(gapStartX, Math.min(root.playX - playhead.width / 2 - aaBias, width)) : Math.max(gapStartX, Math.min(root.actualX - aaBias, width)) - - Rectangle { - id: mask - anchors.top: parent.top - anchors.bottom: parent.bottom - x: 0 - width: waveClip.endX - color: "transparent" - clip: true - - Shape { - id: waveShape - anchors.top: parent.top - anchors.bottom: parent.bottom - width: parent.width + 4 * root.wavelength - antialiasing: true - preferredRendererType: Shape.CurveRenderer - x: waveOffsetX - - ShapePath { - id: wavePath - strokeColor: root.fillColor - strokeWidth: snap(root.lineWidth) - capStyle: ShapePath.RoundCap - joinStyle: ShapePath.RoundJoin - fillColor: "transparent" - PathSvg { - id: waveSvg - path: "" - } - } - } - } - - Rectangle { - id: actualMask - anchors.top: parent.top - anchors.bottom: parent.bottom - x: waveClip.gapStartX - width: Math.max(0, waveClip.gapEndX - waveClip.gapStartX) - color: "transparent" - clip: true - visible: (root.previewBehind || root.previewAhead) && width > 0 - - Shape { - anchors.top: parent.top - anchors.bottom: parent.bottom - width: root.width + 4 * root.wavelength - antialiasing: true - preferredRendererType: Shape.CurveRenderer - x: waveOffsetX - - ShapePath { - strokeColor: root.actualProgressColor - strokeWidth: snap(root.lineWidth) - capStyle: ShapePath.RoundCap - joinStyle: ShapePath.RoundJoin - fillColor: "transparent" - PathSvg { - path: waveSvg.path - } - } - } - } - - Rectangle { - id: startCap - width: snap(root.lineWidth) - height: snap(root.lineWidth) - radius: width / 2 - color: root.fillColor - x: waveClip.startX - width / 2 - y: waveY(waveClip.startX) - height / 2 - visible: waveClip.endX > waveClip.startX - z: 2 - } - - Rectangle { - id: endCap - width: snap(root.lineWidth) - height: snap(root.lineWidth) - radius: width / 2 - color: root.fillColor - x: waveClip.endX - width / 2 - y: waveY(waveClip.endX) - height / 2 - visible: waveClip.endX > waveClip.startX - z: 2 - } - - Rectangle { - id: actualEndCap - width: snap(root.lineWidth) - height: snap(root.lineWidth) - radius: width / 2 - color: root.actualProgressColor - x: waveClip.gapEndX - width / 2 - y: waveY(waveClip.gapEndX) - height / 2 - visible: (root.previewBehind || root.previewAhead) && actualMask.width > 0 - z: 2 - } - - Rectangle { - id: actualMarker - width: 2 - height: Math.max(root.lineWidth + 4, 10) - radius: width / 2 - color: root.actualProgressColor - x: root.actualX - width / 2 - y: root.midY - height / 2 - visible: root.showActualPlaybackState - z: 2 - } - } - - Rectangle { - id: playhead - width: 3.5 - height: Math.max(root.lineWidth + 12, 16) - radius: width / 2 - color: root.playheadColor - x: root.playX - width / 2 - y: root.midY - height / 2 - z: 3 - } - - property real k: (2 * Math.PI) / Math.max(1e-6, wavelength) - function wrapMod(a, m) { - let r = a % m; - return r < 0 ? r + m : r; - } - function waveY(x, amplitude = root.currentAmp, phaseOffset = root.phase) { - return root.midY + amplitude * Math.sin((x / root.wavelength) * 2 * Math.PI + phaseOffset); - } - - readonly property real waveOffsetX: -wrapMod(phase / k, wavelength) - FrameAnimation { running: root.visible && (root.isPlaying || root.currentAmp > 0) onTriggered: { if (root.isPlaying) root.phase += 0.03 * frameTime * 60; - startCap.y = waveY(waveClip.startX) - startCap.height / 2; - endCap.y = waveY(waveClip.endX) - endCap.height / 2; - actualEndCap.y = waveY(waveClip.gapEndX) - actualEndCap.height / 2; } } - - function buildStaticWave() { - const start = waveClip.startX - 2 * root.wavelength; - const end = width + 2 * root.wavelength; - if (end <= start) { - waveSvg.path = ""; - return; - } - - const kLocal = k; - const halfPeriod = root.wavelength / 2; - function y0(x) { - return root.midY + root.currentAmp * Math.sin(kLocal * x); - } - function dy0(x) { - return root.currentAmp * Math.cos(kLocal * x) * kLocal; - } - - let x0 = start; - let d = `M ${x0} ${y0(x0)}`; - while (x0 < end) { - const x1 = Math.min(x0 + halfPeriod, end); - const dx = x1 - x0; - const yA = y0(x0), yB = y0(x1); - const dyA = dy0(x0), dyB = dy0(x1); - const c1x = x0 + dx / 3; - const c1y = yA + (dyA * dx) / 3; - const c2x = x1 - dx / 3; - const c2y = yB - (dyB * dx) / 3; - d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${x1} ${yB}`; - x0 = x1; - } - waveSvg.path = d; - } - - Component.onCompleted: { - currentAmp = isPlaying ? amp : 0; - buildStaticWave(); - } - onWidthChanged: { - flatEnd.x = width; - buildStaticWave(); - } - onHeightChanged: buildStaticWave() - onCurrentAmpChanged: buildStaticWave() - onWavelengthChanged: { - k = (2 * Math.PI) / Math.max(1e-6, wavelength); - buildStaticWave(); - } }