merge: integrate Windows port from PR #725

cx
This commit is contained in:
vicrodh committed 2026-08-28 04:18:06 -06:00
commit 912638c387
93 files changed
+6794 -261

No files matched your search

+60 -2
View File
@@ -140,11 +140,66 @@ ApplicationWindow {
// the frame, and the reference never draws its own there
// ("Linux only — macOS keeps the native traffic lights",
// WindowControls.slint:1-2).
// WINDOWS takes the macOS shape, not the Linux one, and the difference is
// the whole feature (W27).
//
// `Qt.FramelessWindowHint` removes WS_THICKFRAME, and THAT is what the
// shell keys tiling on: Win+Arrow and Aero Snap by dragging to an edge both
// stop, along with the DWM drop shadow and the Win11 rounded corners. So
// "hide title bar" was not making the app refuse the shortcuts -- it had
// stopped being a window the shell could tile.
//
// NOT restored, and it cannot be from flags alone: the Windows 11 Snap
// Layouts flyout that appears when hovering a maximise button. Windows asks
// for it by hit-testing that pixel as HTMAXBUTTON, and QBZ's maximise
// button is a QML item inside the client area, so it answers HTCLIENT.
// Wiring it would mean reporting HTMAXBUTTON over that one rect from the
// native filter -- deliberate future work, not something this arm does.
//
// Qt 6.9's Windows plugin already implements WM_NCCALCSIZE -> 0,
// DwmExtendFrameIntoClientArea, the eight resize edges and
// safeAreaMargins() for `ExpandedClientAreaHint` -- the same flags the
// macOS arm uses. `CustomizeWindowHint` with no button hints then
// suppresses Qt's own painted title cluster
// (qwindowswindow.cpp :3561/:3600/:3624/:3645) while the window KEEPS
// WS_CAPTION, so the shell still treats it as tileable.
//
// It also provides no HTCAPTION drag band of its own (:3332-3338: with
// both isDefaultTitleBar and isCustomized false the whole title block is
// skipped), which is why HeaderBar's QML `startSystemMove()` stays: it
// issues SC_MOVE, and SC_MOVE drags DO snap. The eight QML resize grips
// stay enabled too and do not fight the native edges -- Qt answers
// WM_NCHITTEST first, so a press on the border never reaches QML.
flags: QbzShell.systemTitleBar
? Qt.Window
: (QbzShell.isMacos
? (Qt.Window | Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint)
: (Qt.Window | Qt.FramelessWindowHint))
: (QbzShell.isWindows
// Both halves were MEASURED on the box, and neither is
// optional:
//
// WITHOUT CustomizeWindowHint, Qt populates the default title
// hints and its customized-title path DRAWS minimise/maximise/
// close over the QBZ header.
//
// WITH it, Qt answers WM_NCHITTEST with HTNOWHERE for every
// pixel -- centre, header, player bar and sidebar all returned
// 0 -- and Windows sends no mouse input to HTNOWHERE, so the
// whole app becomes unclickable while still running.
//
// No public flag combination gives no-buttons + clicks +
// the WS_THICKFRAME that tiling needs, so the hint stays and
// `win_shell::install_hittest_filter()` answers the hit test
// instead. What this arm must NOT do is drop the hint: that
// trades the buttons back in.
//
// Frameless is not the answer either: it removes
// WS_THICKFRAME, and THAT is what Win+Arrow and Aero Snap
// actually key on -- not WS_CAPTION, which this window does
// not have in either arrangement.
? (Qt.Window | Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint
| Qt.CustomizeWindowHint)
: (Qt.Window | Qt.FramelessWindowHint)))
// THE OTHER HALF OF ExpandedClientAreaHint, and the piece that made the
// flag look broken when it was not.
@@ -369,7 +424,10 @@ ApplicationWindow {
// all stay at their defaults, and nothing would be logged on either
// side. The wizard is the loudest case after QbzBlacklist, because its
// first step ends on "Checking your audio stack…" forever.
QbzDacWizard.boot()
// Linux only: the wizard's row is hidden elsewhere (AudioSettings.qml),
// so booting it would spin up an audio-stack probe nothing can reach.
if (QbzShell.isLinux)
QbzDacWizard.boot()
// Offline Cache Manager. Nothing to seed — the view is unmounted until
// the Settings row opens it — but without this line the manager's very
// first publish is dropped and the view sits on its spinner forever:
@@ -43,7 +43,20 @@ Window {
// clients — there is no such request in the core protocol; it takes a
// compositor policy or a KWin rule, and Qt has nothing to send. The owner
// chose removal over a button that lies.
// WINDOWS ONLY, owner decision D8 (2026-08-27), reversing nothing about
// K1: that ruling removed always-on-top because the owner smoked it on
// real Plasma Wayland and the hint did nothing -- a Wayland client cannot
// raise itself above other clients, there is no such request in the core
// protocol, and Qt had nothing to send. On Windows the same hint IS
// HWND_TOPMOST and does exactly what it says.
//
// So this is a DELIBERATE PER-PLATFORM DIVERGENCE, not a revert: the pin
// exists where the window manager can honour it and stays absent where it
// cannot. Linux and macOS keep the exact flags they had.
property bool pinnedOnTop: false
flags: Qt.Window | Qt.FramelessWindowHint
| ((QbzShell.isWindows && root.pinnedOnTop) ? Qt.WindowStaysOnTopHint : 0)
// Literal, never translated (app.slint:283 uses a plain string, not @tr).
title: "QBZ Mini Player"
@@ -187,7 +187,21 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMini.exit()
}
// NO PIN. Owner ruling K1 shipped always-on-top as NEW work
// THE PIN, on Windows only (owner decision D8, 2026-08-27).
// `Qt.WindowStaysOnTopHint` is HWND_TOPMOST there and works;
// everything below is why it is absent everywhere else, and
// that reasoning still stands unchanged.
CapBtn {
name: "pin"
visible: QbzShell.isWindows
active: root.hostWindow ? root.hostWindow.pinnedOnTop : false
anchors.verticalCenter: parent.verticalCenter
onClicked: {
if (root.hostWindow)
root.hostWindow.pinnedOnTop = !root.hostWindow.pinnedOnTop
}
}
// NO PIN ELSEWHERE. Owner ruling K1 shipped always-on-top as NEW work
// (the reference promises it in three comments and implements
// it nowhere), and the owner smoked it on real Plasma Wayland
// on 2026-08-04: `Qt.WindowStaysOnTopHint` did not keep the
@@ -613,7 +613,12 @@ Column {
SettingRow {
// Absent while the tray is off — there is no icon to give a
// variant to (owner 2026-08-21).
visible: root.doc.trayEnable === true
//
// And absent on Windows, where the notification area draws the
// executable's own icon: Windows owns light/dark there and the tray
// has no theme arm, so every choice here would render, persist and
// change nothing.
visible: root.doc.trayEnable === true && QbzShell.isWindows !== true
label: QbzSession.tr("Tray icon variant", QbzSession.trRev)
description: QbzSession.tr("Pick a mono glyph to match your panel (Plasma, GNOME's permanently dark top bar) or the full colour vinyl logo", QbzSession.trRev)
QbzSelect {
+34 -2
View File
@@ -150,6 +150,13 @@ Column {
}
}
SettingRow {
// ALSA only. fc0d35e29 opened this to WASAPI expecting plan B Task 6
// (DoP over exclusive mode); the spike then measured this DAC refusing
// 176400, which is the DSD64 DoP carrier, and Task 6 lost its target.
// Both non-convert modes are `cfg(target_os = "linux")` in the audio
// thread and answer "DoP playback is Linux-only" with a stream error,
// so on Windows the row could only ever offer two ways to fail. DSD
// still plays there -- converted to PCM, which is the default.
visible: root.doc.backendIsAlsa === true
label: QbzSession.tr("DSD playback", QbzSession.trRev)
description: QbzSession.tr("How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed and seeking is disabled in DoP/Native mode. Native additionally needs kernel support for the DAC.", QbzSession.trRev)
@@ -174,6 +181,12 @@ Column {
// Opening it resets the wizard and kicks the audio-stack probe off the UI
// thread, so there is nothing to arm here.
SettingRow {
// The generated config is PipeWire/WirePlumber and qbz-dac-wizard-core
// knows no other stack, so off Linux the wizard cannot finish. Hidden,
// not disabled: a disabled row invites a bug report. This is NOT the
// reference's `backend-is-pipewire` gate coming back - within Linux
// every backend still sees it, which is the owner ruling above.
visible: QbzShell.isLinux
label: QbzSession.tr("HiFi Wizard", QbzSession.trRev)
description: QbzSession.tr("Auto-detect your DACs and set up bit-perfect playback, step by step.", QbzSession.trRev)
SettingsButton {
@@ -190,16 +203,35 @@ Column {
// =========================== BIT-PERFECT =============================
GroupHeader { text: QbzSession.tr("BIT-PERFECT", QbzSession.trRev) }
SettingRow {
// Hidden where no backend can honour it: on Windows until WASAPI
// exclusive lands (Phase B publishes backendIsWasapi), and on macOS,
// whose only backend is System default. Linux is unchanged - visible
// always, enabled only on ALSA.
//
// WINDOWS: hidden on purpose, even now that Phase B publishes
// `backendIsWasapi`. The exclusive path is chosen by the BACKEND
// dropdown ("WASAPI Exclusive"), not by this toggle -- the selection
// arm in qbz-player keys on `backend_type` and never reads
// `exclusive_mode`. Showing the toggle would put a control under the
// BIT-PERFECT header that changes nothing, which is worse than no
// control at all when the thing being judged IS bit-perfect. Whether
// Windows should instead mirror the Linux split (backend = transport,
// toggle = hw/plughw) is an open design question for the owner.
visible: QbzShell.isLinux
label: QbzSession.tr("Exclusive mode", QbzSession.trRev)
description: QbzSession.tr("Lock the device so no other app can resample it.", QbzSession.trRev)
rowEnabled: root.doc.backendIsAlsa === true
rowEnabled: root.doc.backendIsAlsa === true || root.doc.backendIsWasapi === true
QbzToggle {
checked: root.doc.exclusiveMode === true
enabled: root.doc.backendIsAlsa === true
enabled: root.doc.backendIsAlsa === true || root.doc.backendIsWasapi === true
onToggled: function (v) { QbzBridge.settingsBool("exclusive-mode", v) }
}
}
SettingRow {
// D-Bus org.freedesktop.ReserveDevice1, which is Linux only. On
// Windows exclusive mode IS the reservation (Phase B); on macOS it is
// Hog Mode.
visible: QbzShell.isLinux
label: QbzSession.tr("Reserve DAC while running", QbzSession.trRev)
description: QbzSession.tr("Hold the device reserved so other apps can't grab it.", QbzSession.trRev)
QbzToggle {
+12
View File
@@ -929,4 +929,16 @@ Rectangle {
WhatsNewModal {
anchors.fill: parent
}
// A LOADER, not a static mount. `visible: false` still CONSTRUCTS the
// whole subtree -- panel, Flickable, fourteen Repeater delegates, their
// texts and bindings -- on Linux and macOS, where this modal can never be
// shown. The Loader makes that cost real only where the modal exists.
//
// Last of the three so it lands on top at equal z: it is shown once per
// version at startup and has to be answered.
Loader {
anchors.fill: parent
active: QbzShell.isWindows && QbzShell.windowsDisclaimerOpen === true
sourceComponent: WindowsDisclaimerModal { }
}
}
+14 -2
View File
@@ -72,11 +72,23 @@ Item {
// otherwise. Empty when no detail exists (never a bare "CD: ").
readonly property string qualityTooltip: {
if (QbzPlayer.npQualityDetail === "") return ""
if (!showDelivered) return tierLabel + ": " + QbzPlayer.npQualityDetail
if (!showDelivered)
return tierLabel + ": " + QbzPlayer.npQualityDetail + root.streamModeLine
var t = QbzSession.tr("Source: {}", QbzSession.trRev).replace("{}", QbzPlayer.npQualityDetail)
+ "\n"
+ QbzSession.tr("Output: {}", QbzSession.trRev).replace("{}", QbzPlayer.npQualityTrueDetail)
return causeLine === "" ? t : t + "\n" + causeLine
t = causeLine === "" ? t : t + "\n" + causeLine
return t + root.streamModeLine
}
// What the OPENED stream negotiated, appended to the tooltip. The mode LED
// beside it is derived from SETTINGS, so it says what was ASKED FOR; this
// says what the engine actually got. When they disagree, that is the whole
// point. Four technical tokens, so nothing new to translate.
readonly property string streamModeLine: {
var m = QbzPlayer.npBitPerfectMode
if (m === "" || m === "unknown") return ""
return "\nstream: " + m
}
readonly property bool localOutput: !QbzPlayer.npIsRemote && !QbzPlayer.npCastActive
+11 -3
View File
@@ -250,9 +250,17 @@ Item {
}
entries.push(
{ "label": nav.trs("Open folder…"), "icon": "folder-open",
"view": "", "tab": "", "action": "openFolder", "enabled": true },
{ "label": nav.trs("Open audio CD"), "icon": "disc",
"view": "", "tab": "", "action": "openCd", "enabled": true },
"view": "", "tab": "", "action": "openFolder", "enabled": true })
// W16: qbz-disc has no Windows CDDA backend -- `mod unsupported`
// compiles and every call returns NoDrive. Offering the entry would
// only ever produce "no drive found" on a machine that has one. SACD
// stays: that opens an image file, which needs no drive.
if (!QbzShell.isWindows) {
entries.push(
{ "label": nav.trs("Open audio CD"), "icon": "disc",
"view": "", "tab": "", "action": "openCd", "enabled": true })
}
entries.push(
{ "label": nav.trs("Open SACD image…"), "icon": "disc-3",
"view": "", "tab": "", "action": "openSacd", "enabled": true })
if (QbzLocal.localEphemeralActive) {
+2 -2
View File
@@ -34,11 +34,11 @@
// MSL 1.2 for Metal, SPIR-V always embedded for Vulkan). Regenerate after
// ANY shader-source edit, from crates/qbz-qt:
// for m in bars wave energy; do \
// /usr/lib64/qt6/bin/qsb --glsl 440 --msl 12 \
// /usr/lib64/qt6/bin/qsb --glsl "100 es,300 es,310 es,320 es,120,150,440" --hlsl 50 --msl 12 \
// -o qml/assets/shaders/spectrum_$m.frag.qsb \
// qml/assets/shaders/spectrum_$m.frag; \
// done
// /usr/lib64/qt6/bin/qsb --glsl 440 --msl 12 \
// /usr/lib64/qt6/bin/qsb --glsl "100 es,300 es,310 es,320 es,120,150,440" --hlsl 50 --msl 12 -b \
// -o qml/assets/shaders/spectrum.vert.qsb qml/assets/shaders/spectrum.vert
// (macOS: qsb is at $(brew --prefix qt)/bin/qsb.) A .qsb is keyed to the Qt
// Shader Tools version that baked it — rebake when the system Qt moves.
@@ -0,0 +1,216 @@
// The Windows "as-is" disclaimer, shown once per version at startup.
//
// Structure, scrim, shadow, z and the self-gate follow WhatsNewModal.qml,
// which follows LogViewerModal.qml. Colour literals are `Qt.rgba(...)`
// because Slint is #RRGGBBAA and Qt is #AARRGGBB -- the copy that shipped an
// invisible scrim in five modals, fixed 2026-08-14.
//
// ── WHY THE BODY IS NOT TRANSLATED ─────────────────────────────────────────
//
// This is the author's own statement of policy, quoted from the release notes
// ("Windows -- yes, but read this first"). The wording is the point: it says
// what is and is not promised, and in whose voice. The CHROME around it (the
// title, the checkbox, the button) goes through `tr` like everything else, so
// the frame localises and the statement does not get paraphrased by a
// translator into something that promises more or less than it does.
//
// ── WHY THIS IS NOT `Text.MarkdownText` ────────────────────────────────────
//
// Same reason WhatsNewModal gives, minus the parser: the source has exactly
// two constructs, `**bold**` and one link, so the paragraphs below carry
// `<b>` and `<a>` and render as StyledText. No dependency, no subset to
// disagree about.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Item {
id: root
anchors.fill: parent
z: 3000
QbzTheme { id: theme }
// The body, verbatim from the release notes. One entry per paragraph.
// `lead: true` renders at the emphasis size -- the two sentences that are
// the actual disclaimer rather than the explanation around it.
readonly property var paragraphs: [
{ lead: false, text: "There is now a Win32 build of QBZ." },
{ lead: false, text: "There is, however, one important distinction:" },
{ lead: true, text: "<b>Windows is not a supported QBZ platform.</b>" },
{ lead: false, text: "The Windows build is provided <b>as-is</b>. Think of it like buying something second-hand: what you see is what you get." },
{ lead: false, text: "I won't promise feature parity, chase Windows-specific regressions, maintain a separate Windows roadmap, or guarantee that something Microsoft changes six months from now won't break it." },
{ lead: false, text: "If it works for you, great. If it doesn't, there is no promise that I'll fix it." },
{ lead: false, text: "And to be completely transparent about why: <b>I hated using Windows while working on this port.</b>" },
{ lead: false, text: "Installing it, configuring the build box, dealing with the environment and especially figuring out its audio setup reminded me why I don't use it in the first place. I have no desire to spend more time than absolutely necessary inside Redmond's OS every time QBZ gets a release." },
{ lead: false, text: "Sorry. Long-time Gentoo Linux user. Some biases are too old to fix." },
{ lead: false, text: "So why ship it?" },
{ lead: false, text: "Because the hard part — getting QBZ running properly on Win32 — is done, and useful code is better in people's hands than sitting in a branch because I personally don't want to maintain another operating system." },
{ lead: false, text: "More importantly, <b>the Windows port is open for adoption</b>." },
{ lead: false, text: "That's essentially what happened with macOS. <a href=\"https://github.com/afonsojramos\">@afonsojramos</a> took ownership of the macOS port and is the reason it grew from an experiment into a properly supported QBZ platform." },
{ lead: false, text: "If someone wants to do the same for Windows, maintain it and eventually remove this rather large disclaimer, get in touch." }
]
property bool remember: false
// Component.onCompleted, NOT onVisibleChanged. `Item.visible` defaults to
// true and the Loader only creates this when it should already be shown,
// so there is no false->true transition to observe: the old handler never
// ran, focus was never taken, Escape never reached the handler below, and
// ordinary hotkeys kept working BEHIND the modal.
Component.onCompleted: keyScope.forceActiveFocus()
// Qt strands activeFocus on an item that is being destroyed, which kills
// AppShell's key dispatcher until the next click. Hand focus back to the
// shell root on the way out -- the same duck-walk QbzConfirmModal uses,
// and for the same measured reason.
Component.onDestruction: root._restoreShellFocus()
function _restoreShellFocus() {
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
return
}
p = p.parent
}
}
FocusScope {
id: keyScope
anchors.fill: parent
// Escape closes WITHOUT remembering: dismissing a disclaimer by
// reflex must never be read as having accepted it for good.
Keys.onEscapePressed: QbzShell.dismissWindowsDisclaimer(false)
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.75)
// No click-through close. Unlike the other modals this one is the
// first thing a new user sees and says what is not promised; a stray
// click on the scrim should not dismiss it.
MouseArea { anchors.fill: parent }
}
Rectangle {
anchors.centerIn: panel
width: panel.width + 8
height: panel.height + 8
radius: theme.radiusMd
color: Qt.rgba(0, 0, 0, 0.5)
}
Rectangle {
id: panel
anchors.centerIn: parent
width: Math.min(root.width - 80, 720)
height: Math.min(root.height - 80, 640)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
MouseArea { anchors.fill: parent }
Item {
anchors.fill: parent
anchors.margins: 24
Text {
id: headerText
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
text: QbzSession.tr("Windows — yes, but read this first", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: 18
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Flickable {
id: scroller
anchors.top: headerText.bottom
anchors.topMargin: 16
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: footerRow.top
anchors.bottomMargin: 16
clip: true
contentWidth: width
contentHeight: body.height
boundsBehavior: Flickable.StopAtBounds
Column {
id: body
width: scroller.width
spacing: 12
Repeater {
model: root.paragraphs
Text {
width: parent.width
text: modelData.text
// StyledText, not RichText: it is the cheap
// subset and it is all these paragraphs use.
textFormat: Text.StyledText
wrapMode: Text.WordWrap
color: modelData.lead ? theme.textPrimary : theme.textSecondary
font.pixelSize: modelData.lead ? 15 : 13
linkColor: theme.accent
onLinkActivated: function (url) { Qt.openUrlExternally(url) }
}
}
}
}
Item {
id: footerRow
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 32
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 8
QbzCheckbox {
id: rememberBox
anchors.verticalCenter: parent.verticalCenter
checked: root.remember
// `toggled()` carries NO argument here (see
// QbzCheckbox.qml): the control reports that it was
// hit, and the owner of the state flips it.
onToggled: root.remember = !root.remember
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Don't show this again", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 13
MouseArea {
anchors.fill: parent
onClicked: root.remember = !root.remember
}
}
}
QbzPrimaryButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
// `label`, not `text` -- this control is not a Qt Button.
label: QbzSession.tr("I understand", QbzSession.trRev)
btnHeight: 32
onClicked: QbzShell.dismissWindowsDisclaimer(root.remember)
}
}
}
}
}
@@ -242,14 +242,19 @@ Item {
CardMenu {
id: openMenu
menuWidth: 220
// W16: the audio-CD row is dropped on Windows -- qbz-disc's CDDA
// backend is `mod unsupported` there and answers NoDrive to
// everything. SACD is an image file, so it stays on every platform.
entries: [
{ "label": QbzSession.tr("Open folder…", QbzSession.trRev),
"icon": "folder-open", "action": "folder" },
"icon": "folder-open", "action": "folder" }
].concat(QbzShell.isWindows ? [] : [
{ "label": QbzSession.tr("Open audio CD", QbzSession.trRev),
"icon": "disc", "action": "cd" },
"icon": "disc", "action": "cd" }
]).concat([
{ "label": QbzSession.tr("Open SACD image…", QbzSession.trRev),
"icon": "disc-3", "action": "sacd" }
]
])
onPicked: function (action) {
if (action === "folder") QbzLocal.ephemeralOpen()
else if (action === "cd") QbzLocal.ephemeralOpenCd()