Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,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
Expand Down
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
20 changes: 18 additions & 2 deletions music_player_desktop/AppShell.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -1135,6 +1149,7 @@ Item {
onClicked: if (shell.hostWindow) shell.hostWindow.showMinimized()
}
WindowTrafficButton {
externalDrawing: compositorControls.compositorActive
id: closeWindowControl
objectName: "windowCloseButton"
action: "close"
Expand Down Expand Up @@ -2107,6 +2122,7 @@ Item {
Component {
id: nowPlayingComponent
NowPlayingPage {
compositorControlsActive: compositorControls.compositorActive
playerController: shell.playerController
track: shell.displayedTrack
immersiveService: typeof backendImmersive !== "undefined" ? backendImmersive : null
Expand Down
39 changes: 39 additions & 0 deletions music_player_desktop/components/CooperativeWindowControls.qml
Original file line number Diff line number Diff line change
@@ -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() }
}
}
4 changes: 4 additions & 0 deletions music_player_desktop/components/NativeWindowControls.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import QtQuick
import Kos.SurfaceShape 1.0

WindowControlSlot {}
7 changes: 6 additions & 1 deletion music_player_desktop/components/WindowTrafficButton.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
14 changes: 12 additions & 2 deletions music_player_desktop/pages/NowPlayingPage.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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"
Expand All @@ -585,6 +594,7 @@ Item {
onClicked: if (page.hostWindow) page.hostWindow.showMinimized()
}
WindowTrafficButton {
externalDrawing: page.compositorControlsActive
id: closeWindowControl
action: "close"
fillColor: "#ff5f57"
Expand Down
3 changes: 2 additions & 1 deletion music_player_desktop/translations/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
13 changes: 13 additions & 0 deletions packaging/linux/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions src/app/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<QObject *>(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();
Expand Down
56 changes: 56 additions & 0 deletions tests/window-controls/tst_controls.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import QtQuick
import QtTest
import "../../music_player_desktop/components"

TestCase {
name: "SingleSurfaceControls"
when: windowShown
visible: true
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)
}
}
Loading