From 335b2c62635beaa2218c78eaa0e37f2fac84c2c4 Mon Sep 17 00:00:00 2001 From: liubw Date: Wed, 11 Nov 2026 01:13:19 +0800 Subject: [PATCH 1/3] fix(window): share cooperative KWin controls across player headers --- .github/workflows/linux.yml | 6 +- CMakeLists.txt | 5 ++ music_player_desktop/AppShell.qml | 20 ++++++- .../components/CooperativeWindowControls.qml | 39 +++++++++++++ .../components/NativeWindowControls.qml | 4 ++ .../components/WindowTrafficButton.qml | 7 ++- music_player_desktop/pages/NowPlayingPage.qml | 14 ++++- music_player_desktop/translations/en_US.json | 3 +- packaging/linux/README.md | 13 +++++ tests/window-controls/tst_controls.qml | 55 +++++++++++++++++++ 10 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 music_player_desktop/components/CooperativeWindowControls.qml create mode 100644 music_player_desktop/components/NativeWindowControls.qml create mode 100644 tests/window-controls/tst_controls.qml diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 1432ca0..f313c60 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -21,7 +21,11 @@ jobs: libqt6sql6-sqlite qml6-module-qtcore qml6-module-qtqml qml6-module-qtqml-models \ qml6-module-qtqml-workerscript qml6-module-qtquick qml6-module-qtquick-window \ qml6-module-qtquick-layouts qml6-module-qtquick-controls qml6-module-qtquick-templates \ - qml6-module-qtquick-dialogs qml6-module-qtquick-effects qml6-module-qtquick-shapes + qml6-module-qtquick-dialogs qml6-module-qtquick-effects qml6-module-qtquick-shapes qml6-module-qttest + - name: Cooperative controls preserve one slot across headers + env: + QT_QPA_PLATFORM: offscreen + run: /usr/lib/qt6/bin/qmltestrunner -input tests/window-controls - name: Installer network and lifecycle regressions run: python3 -m unittest discover -s packaging/tests -p test_installer.py -v - name: Check the actual Qt and native dependencies diff --git a/CMakeLists.txt b/CMakeLists.txt index a2bfcd3..b834e0a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -476,6 +476,7 @@ qt_add_qml_module(listenfree music_player_desktop/AppShell.qml music_player_desktop/components/WindowChrome.qml music_player_desktop/components/WindowTrafficButton.qml + music_player_desktop/components/CooperativeWindowControls.qml music_player_desktop/components/WindowResizeHandles.qml music_player_desktop/components/AppTheme.qml music_player_desktop/components/GlassSurface.qml @@ -747,3 +748,7 @@ endif() if(LISTENFREE_BUILD_TOOLS) add_custom_target(listenfree_tools ALL DEPENDS listenfree-library-scan-probe listenfree-scan-bench listenfree-artist-visual-probe) endif() + +# Optional platform import stays outside qmlcachegen on unsupported desktops. +qt_add_resources(listenfree "window-control-platform" PREFIX "/listenfree/platform" + BASE music_player_desktop/components FILES music_player_desktop/components/NativeWindowControls.qml) diff --git a/music_player_desktop/AppShell.qml b/music_player_desktop/AppShell.qml index 86535aa..0d7d937 100644 --- a/music_player_desktop/AppShell.qml +++ b/music_player_desktop/AppShell.qml @@ -19,6 +19,13 @@ Item { } } + CooperativeWindowControls { + id: compositorControls + objectName: "windowControls" + controlGroup: nowPlayingLoader.item && shell.nowPlayingOpen + ? nowPlayingLoader.item.windowControlGroup : topTrafficCluster + } + property var catalog: null property var libraryCatalog: typeof backendLibraryCatalog !== "undefined" ? backendLibraryCatalog : catalog property var facade: null @@ -1111,20 +1118,27 @@ Item { TopGlyph { objectName: "topSettingsButton"; selected: shell.settingsOpen; kind: "gear"; onClicked: shell.settingsOpen = !shell.settingsOpen } Row { id: topTrafficCluster + property int layoutRevision: 0 + onPositioningComplete: layoutRevision++ objectName: "topTrafficCluster" anchors.verticalCenter: parent.verticalCenter spacing: 10 WindowTrafficButton { + externalDrawing: compositorControls.compositorActive objectName: "windowFullscreenButton" - action: "fullscreen" + action: "maximize" fillColor: "#2fc866" revealGlyph: topTrafficHover.hovered || shell.captureView === "traffic-hover" || Qt.application.arguments.indexOf("traffic-hover") >= 0 reducedMotion: !shell.animationsEnabled - onClicked: if (shell.hostWindow) shell.hostWindow.toggleFullScreen() + onClicked: if (shell.hostWindow) { + if (shell.hostWindow.visibility === Window.Maximized) shell.hostWindow.showNormal() + else shell.hostWindow.showMaximized() + } } WindowTrafficButton { + externalDrawing: compositorControls.compositorActive objectName: "windowMinimizeButton" action: "minimize" fillColor: "#ffbf18" @@ -1135,6 +1149,7 @@ Item { onClicked: if (shell.hostWindow) shell.hostWindow.showMinimized() } WindowTrafficButton { + externalDrawing: compositorControls.compositorActive id: closeWindowControl objectName: "windowCloseButton" action: "close" @@ -2107,6 +2122,7 @@ Item { Component { id: nowPlayingComponent NowPlayingPage { + compositorControlsActive: compositorControls.compositorActive playerController: shell.playerController track: shell.displayedTrack immersiveService: typeof backendImmersive !== "undefined" ? backendImmersive : null diff --git a/music_player_desktop/components/CooperativeWindowControls.qml b/music_player_desktop/components/CooperativeWindowControls.qml new file mode 100644 index 0000000..da8010c --- /dev/null +++ b/music_player_desktop/components/CooperativeWindowControls.qml @@ -0,0 +1,39 @@ +import QtQuick + +// One protocol object per native surface, shared by normal and player headers. +Item { + id: root + property Item controlGroup: null + property Component platformComponent: Qt.createComponent("qrc:/listenfree/platform/NativeWindowControls.qml") + property var platformSlot: null + readonly property bool platformReady: platformSlot !== null + readonly property bool compositorActive: platformReady && platformSlot.active + readonly property var buttonLayout: { + const revision = controlGroup ? controlGroup.layoutRevision : 0 + const result = [] + if (!controlGroup) return result + for (const button of controlGroup.children) { + if (button.action !== undefined && button.visible) + result.push({action: button.action, + x: button.x + (button.width - 18) / 2, + y: button.y + (button.height - 18) / 2, + width: 18, height: 18}) + } + return result + } + function connectPlatform() { + if (platformSlot) { platformSlot.destroy(); platformSlot = null } + if (platformComponent && platformComponent.status === Component.Ready) + platformSlot = platformComponent.createObject(root, { + target: Qt.binding(() => root.controlGroup), + buttons: Qt.binding(() => root.buttonLayout), + enabled: Qt.binding(() => root.controlGroup !== null) + }) + } + onPlatformComponentChanged: connectPlatform() + Component.onCompleted: connectPlatform() + Connections { + target: root.platformComponent + function onStatusChanged() { root.connectPlatform() } + } +} diff --git a/music_player_desktop/components/NativeWindowControls.qml b/music_player_desktop/components/NativeWindowControls.qml new file mode 100644 index 0000000..4fb22f5 --- /dev/null +++ b/music_player_desktop/components/NativeWindowControls.qml @@ -0,0 +1,4 @@ +import QtQuick +import Kos.SurfaceShape 1.0 + +WindowControlSlot {} diff --git a/music_player_desktop/components/WindowTrafficButton.qml b/music_player_desktop/components/WindowTrafficButton.qml index 15bee20..7defbbb 100644 --- a/music_player_desktop/components/WindowTrafficButton.qml +++ b/music_player_desktop/components/WindowTrafficButton.qml @@ -9,10 +9,15 @@ Item { property color fillColor: "#ff5f57" property bool revealGlyph: false property bool reducedMotion: false + property bool externalDrawing: false + opacity: externalDrawing ? 0 : 1 + enabled: !externalDrawing + Accessible.ignored: externalDrawing signal clicked activeFocusOnTab: true Accessible.role: Accessible.Button - Accessible.name: action === "fullscreen" ? qsTr("全屏 / 退出全屏") + Accessible.name: action === "maximize" ? qsTr("最大化 / 还原") + : action === "fullscreen" ? qsTr("全屏 / 退出全屏") : action === "minimize" ? qsTr("最小化") : qsTr("关闭") Keys.onSpacePressed: clicked() Keys.onReturnPressed: clicked() diff --git a/music_player_desktop/pages/NowPlayingPage.qml b/music_player_desktop/pages/NowPlayingPage.qml index 702a150..0b20fad 100644 --- a/music_player_desktop/pages/NowPlayingPage.qml +++ b/music_player_desktop/pages/NowPlayingPage.qml @@ -28,6 +28,8 @@ Item { const revision = settingsStore.revision return settingsStore.value(key, fallback) } + property alias windowControlGroup: nowPlayingTrafficCluster + property bool compositorControlsActive: false property var hostWindow property bool immersiveActive: false onImmersiveActiveChanged: { @@ -567,16 +569,23 @@ Item { HeaderGlyph { objectName: "nowPlayingThemeButton"; kind: page.darkMode ? "moon" : "sun"; onClicked: page.themeToggleRequested() } Row { id: nowPlayingTrafficCluster + property int layoutRevision: 0 + onPositioningComplete: layoutRevision++ spacing: 10 WindowTrafficButton { + externalDrawing: page.compositorControlsActive objectName: "nowPlayingFullscreenButton" - action: "fullscreen" + action: "maximize" fillColor: "#2fc866" revealGlyph: nowPlayingTrafficHover.hovered || Qt.application.arguments.indexOf("nowplaying-traffic-hover") >= 0 reducedMotion: page.reducedMotion - onClicked: page.toggleFullScreen() + onClicked: if (page.hostWindow) { + if (page.hostWindow.visibility === Window.Maximized) page.hostWindow.showNormal() + else page.hostWindow.showMaximized() + } } WindowTrafficButton { + externalDrawing: page.compositorControlsActive objectName: "nowPlayingMinimizeButton" action: "minimize" fillColor: "#ffbf18" @@ -585,6 +594,7 @@ Item { onClicked: if (page.hostWindow) page.hostWindow.showMinimized() } WindowTrafficButton { + externalDrawing: page.compositorControlsActive id: closeWindowControl action: "close" fillColor: "#ff5f57" diff --git a/music_player_desktop/translations/en_US.json b/music_player_desktop/translations/en_US.json index 05b6454..69c58fb 100644 --- a/music_player_desktop/translations/en_US.json +++ b/music_player_desktop/translations/en_US.json @@ -1178,5 +1178,6 @@ "扫码登录需要可选组件 listenfree-web-login。安装后即可使用,无需重装播放器。也可以直接使用 Cookie 登录。": "QR sign-in requires the optional listenfree-web-login component. Install it to continue without reinstalling the player, or sign in with cookies.", "查看安装说明": "View installation instructions", "选择歌词来源": "Choose a lyrics source", - "歌词换源": "Change lyrics source" + "歌词换源": "Change lyrics source", + "最大化 / 还原": "Maximize / Restore" } diff --git a/packaging/linux/README.md b/packaging/linux/README.md index e4ed4fe..586f73c 100644 --- a/packaging/linux/README.md +++ b/packaging/linux/README.md @@ -14,3 +14,16 @@ The established CMake install layout is preserved: No NextKDE checkout, shell service, Go service, `kosctl` or migration tool is invoked. Existing ListenFree Qt identity, profile and MPRIS behavior remain unchanged. Installing does not remove `kos-music`, migrate its data, or change the user's default audio associations. Video and the separate WebEngine login helper remain optional and disabled by default. Build the latter from `packaging/web-login` or enable `LISTENFREE_BUILD_WEB_LOGIN`; install only with component `listenfree-web-login`. The ordinary player install does not pull in the helper. See [helper instructions](../web-login/install.txt). + +## KWin window controls + +The main and now-playing headers share one optional `Kos.SurfaceShape` protocol +slot per window. KWin draws maximize/restore, minimize and close after takeover; +only button children disappear, leaving the geometry target opaque. Unsupported +desktops retain the client fallback. Fullscreen remains available through F11 +and the player action, independently of maximize/restore. + +A native installation needs the matching NextKde module on its QML import path. +A future Flatpak must bundle the canonical module against its own Qt SDK; the +host plugin is not accessible through the sandbox. Current published releases +are source releases, not Flatpak artifacts. diff --git a/tests/window-controls/tst_controls.qml b/tests/window-controls/tst_controls.qml new file mode 100644 index 0000000..3c08d8d --- /dev/null +++ b/tests/window-controls/tst_controls.qml @@ -0,0 +1,55 @@ +import QtQuick +import QtTest +import "../../music_player_desktop/components" + +TestCase { + name: "SingleSurfaceControls" + when: windowShown + width: 400; height: 200 + Component { id: mock; Item { + property Item target + property var buttons: [] + property bool active: false + } } + Row { + id: normal + property int layoutRevision: 0 + onPositioningComplete: layoutRevision++ + spacing: 10 + Rectangle { property string action: "maximize"; width: 22; height: 32 } + Rectangle { property string action: "minimize"; width: 22; height: 32 } + Rectangle { property string action: "close"; width: 22; height: 32 } + } + Row { + id: player + property int layoutRevision: 0 + onPositioningComplete: layoutRevision++ + spacing: 12 + Rectangle { property string action: "maximize"; width: 22; height: 32 } + Rectangle { property string action: "close"; width: 22; height: 32 } + } + CooperativeWindowControls { id: slot; controlGroup: normal; platformComponent: mock } + function test_one_slot_tracks_headers() { + tryVerify(() => slot.platformReady) + const protocol = slot.platformSlot + compare(protocol.target, normal) + compare(protocol.buttons.length, 3) + compare(protocol.buttons[0].x, 2) + compare(protocol.buttons[1].x, 34) + protocol.active = true + compare(slot.compositorActive, true) + slot.controlGroup = player + compare(slot.platformSlot, protocol) + compare(protocol.target, player) + compare(protocol.buttons.length, 2) + compare(protocol.buttons[1].action, "close") + compare(protocol.buttons[1].x, 36) + slot.controlGroup = null + compare(protocol.enabled, false) + compare(protocol.buttons.length, 0) + slot.controlGroup = normal + compare(protocol.enabled, true) + slot.platformComponent = null + compare(slot.compositorActive, false) + } +} From 01c960d5b84b9b8f267992ef53b7f3491eca4e52 Mon Sep 17 00:00:00 2001 From: liubw Date: Wed, 11 Nov 2026 01:17:56 +0800 Subject: [PATCH 2/3] test(window): expose fixture controls before checking advertised geometry --- tests/window-controls/tst_controls.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/window-controls/tst_controls.qml b/tests/window-controls/tst_controls.qml index 3c08d8d..64d22d2 100644 --- a/tests/window-controls/tst_controls.qml +++ b/tests/window-controls/tst_controls.qml @@ -5,6 +5,7 @@ import "../../music_player_desktop/components" TestCase { name: "SingleSurfaceControls" when: windowShown + visible: true width: 400; height: 200 Component { id: mock; Item { property Item target From 34d7be5a6ce595cf86571a6749e48bf88ef112d9 Mon Sep 17 00:00:00 2001 From: liubw Date: Wed, 11 Nov 2026 01:19:15 +0800 Subject: [PATCH 3/3] test(window): retain native candidate and require takeover for review captures --- .github/workflows/linux.yml | 7 +++++++ src/app/main.cpp | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index f313c60..9cd1491 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -43,6 +43,13 @@ jobs: run: cmake --build build/release --parallel 4 - name: Install run: cmake --install build/release + - name: Retain installed candidate for isolated VM acceptance + run: tar -C "${{ runner.temp }}/listenfree-install" -czf build/listenfree-native-candidate.tar.gz . + - uses: actions/upload-artifact@v4 + with: + name: listenfree-native-candidate + path: build/listenfree-native-candidate.tar.gz + retention-days: 7 release: if: startsWith(github.ref, 'refs/tags/v') needs: build diff --git a/src/app/main.cpp b/src/app/main.cpp index fab56f6..ae079d9 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -390,6 +390,14 @@ int main(int argc, char* argv[]) { app.exit(3); return; } + if (qEnvironmentVariableIntValue("LISTENFREE_REVIEW_REQUIRE_COMPOSITOR_CONTROLS") == 1) { + const auto *controls = window->findChild(QStringLiteral("windowControls")); + if (!controls || !controls->property("platformReady").toBool() + || !controls->property("compositorActive").toBool()) { + app.exit(5); + return; + } + } const QFileInfo target(capturePath); QDir().mkpath(target.absolutePath()); const QImage image = window->grabWindow();