fix(qt): the now-playing audio button opens the Normalization + Gapless flyout, and reads the real state

It was not a flyout and it did not read the persisted value, which together
made normalization impossible to turn off.

- `shell/AudioSettingsMenu.qml` (new) ports the `audio-menu` PopupWindow both
  bars mount (PlayerBar.slint:686-705, PlayerBarSmall.slint:635-712): 252 wide,
  two 44px ToggleRows with 12px side padding and a 12px gap to the switch,
  label text-primary 13px over hint text-muted 10px at spacing 1 — the numbers
  are PlayerBar.slint's file-private ToggleRow (:80-115).
- `PlayerBar.qml` had `normTouched` / `normLocal` and toggled normalization on
  click. `NowPlayingBarSmall.qml` was worse: `property bool normOn: false`,
  local state that started at false no matter what was persisted, so the FIRST
  click on that button always sent `normalization = true`. Both now just open
  the flyout. `active` mirrors normalization on the full bar and is neutral on
  the Small one, which is the reference's own asymmetry
  (PlayerBarSmall.slint:628-630 states it out loud).
- Gapless had no now-playing control at all.

The local shadow state existed because the document never carried the truth:
`publish_settings()` ran ONLY on `navigate_to("settings")` and on a language
change, and no handler in `settings_qt` ever republished. So until the user
opened Settings the shell read an empty document, the control drew OFF while
the backend had it ON, and the first interaction wrote the wrong value back.
Slint has no equivalent hole — `SettingsState` is a global seeded at startup.
So: seed the document at shell entry, and republish after every audio apply.
Verified by effect, not by diff — the boot log now reports the snapshot as
5006 bytes carrying the persisted `normalization` and `gapless`, before any
view is opened.

Seeding at shell entry also seeds the now-playing stamp's two output LEDs,
which `publish_snapshot` derives from the same audio settings.

cc
This commit is contained in:
vicrodh committed 2026-07-30 00:43:04 -06:00
1 parent 494596616e
commit 01f3819da1
6 files changed
+172 -35

No files matched your search

@@ -0,0 +1,104 @@
// Audio settings flyout — the port of the "Audio settings" PopupWindow that
// BOTH now-playing bars mount (`shell/PlayerBar.slint:686-705` and
// `shell/PlayerBarSmall.slint:635-712`). Two rows, Normalization and Gapless,
// and nothing else: the Slint popup is 252x118 with exactly these two
// ToggleRows (`PlayerBar.slint:80-115` is the file-private ToggleRow this
// reproduces — 44px tall, 12px side padding, 12px gap to the switch, label
// text-primary 13px over hint text-muted 10px, spacing 1).
//
// It replaces a direct-toggle button that both bars had shipped with LOCAL
// state (`normLocal` / `property bool normOn: false`), which is what made
// normalization impossible to turn off: the bars never learned the persisted
// value, so the button drew OFF on a fresh launch and the first click SET IT
// ON. The state now comes from the published document, which is seeded at
// shell entry and republished after every apply.
//
// Mounted as a QbzContextMenu (the port's popup primitive, same as
// ViewModeMenu next to it) rather than a raw Popup, so the panel chrome,
// placement clamping and close policy are the ones every other flyout in the
// bar already uses.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
QbzContextMenu {
id: root
// PlayerBar.slint:689 — `width: 252px`.
menuWidth: 252
/// The live document both rows read. The host passes its already-parsed
/// `settingsDoc` so this file never parses the JSON a second time.
property var doc: ({})
QbzTheme { id: theme }
Repeater {
model: [
{
"key": "normalization",
"label": QbzSession.tr("Normalization", QbzSession.trRev),
"hint": QbzSession.tr("Level loudness across tracks", QbzSession.trRev),
"on": root.doc.normalization === true,
},
{
"key": "gapless",
"label": QbzSession.tr("Gapless", QbzSession.trRev),
"hint": QbzSession.tr("Seamless track transitions", QbzSession.trRev),
"on": root.doc.gapless === true,
},
]
delegate: Item {
required property var modelData
width: parent ? parent.width : 0
// ToggleRow.slint:86 — `height: 44px`.
height: 44
Row {
anchors.fill: parent
// ToggleRow.slint:88-90 — padding 12/12, spacing 12.
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 12
Column {
width: parent.width - 12 - rowToggle.width
height: parent.height
// The label/hint pair is vertically centred as a block
// (ToggleRow's inner VerticalLayout `alignment: center`),
// so the Column is centred rather than filling.
anchors.verticalCenter: parent.verticalCenter
// ToggleRow.slint:93 — `spacing: 1px`.
spacing: 1
Text {
width: parent.width
text: modelData.label
color: theme.textPrimary
// ToggleRow.slint:98 — literal 13px, not a token.
font.pixelSize: 13
elide: Text.ElideRight
}
Text {
width: parent.width
text: modelData.hint
color: theme.textMuted
// ToggleRow.slint:103 — literal 10px.
font.pixelSize: 10
elide: Text.ElideRight
}
}
QbzToggle {
id: rowToggle
anchors.verticalCenter: parent.verticalCenter
checked: modelData.on
onToggled: function (v) {
QbzBridge.settingsBool(modelData.key, v)
}
}
}
}
}
}
+29 -14
View File
@@ -352,22 +352,23 @@ Rectangle {
active: QbzPlayer.npCastActive
onClicked: QbzCast.openPicker()
}
// Audio settings — Tauri's normalization toggle (2.0
// groups normalization + gapless behind this button).
// TODO(glue): `normalization` is settable through
// settingsBool but NOT published in settingsJson, so the
// state can only be tracked locally; TODO(qt-bridge): the
// Slint two-toggle flyout is not ported — the click
// toggles normalization, exactly like the Tauri button.
// Audio settings — opens the Normalization + Gapless
// flyout (PlayerBarSmall.slint:620-712). NOT a toggle, and
// `active` is NEUTRAL here on purpose: the .slint says so
// out loud at :628-630 — "this button opens a flyout, so
// its colour should NOT reflect a toggle (normalization)".
// The full bar DOES mirror it; that asymmetry is the
// reference's, not a slip.
//
// It previously carried `property bool normOn: false` —
// local state that started at false regardless of what was
// persisted, so the first click on this button always sent
// `true` and SWITCHED NORMALIZATION ON.
QbzIconButton {
id: smallNormBtn
property bool normOn: false
id: smallAudioBtn
name: "settings-2"
active: normOn
onClicked: {
normOn = !normOn
QbzBridge.settingsBool("normalization", normOn)
}
active: false
onClicked: smallAudioMenu.openBelowRight(smallAudioBtn)
}
QbzIconButton {
id: smallViewBtn
@@ -419,6 +420,20 @@ Rectangle {
// The Now-Playing-view mode menu (phase 18 — shared with PlayerBar).
ViewModeMenu { id: smallViewMenu }
// Audio settings flyout — Normalization + Gapless (PlayerBarSmall.slint's
// `audio-menu` PopupWindow). Guarded parse, the PlaylistView.qml:55-61
// idiom: a raw JSON.parse in a binding throws on the pre-publish frame and
// takes the whole bar down with it.
readonly property var settingsDoc: parseSettings()
function parseSettings() {
try {
return JSON.parse(QbzBridge.settingsJson)
} catch (e) {
return ({})
}
}
AudioSettingsMenu { id: smallAudioMenu; doc: root.settingsDoc }
// "Add to…" flyout behind the transport "+" (TransportControls.slint's
// add-menu) — same seven entries, order and icons as the full bar.
CardMenu {
+23 -21
View File
@@ -134,21 +134,20 @@ Rectangle {
}
}
// Audio settings / normalization (Tauri's normalization button; Slint's
// "Audio settings" flyout). `normalization` IS settable through
// QbzBridge.settingsBool but is NOT published in settingsJson yet, so the
// toggle keeps its own value once the user touches it and falls back to
// the (currently absent) published field before that.
// TODO(glue): publish `normalization` in SettingsDoc — see the report.
property bool normTouched: false
property bool normLocal: false
readonly property bool normalizationOn: normTouched ? normLocal
: (settingsDoc.normalization === true)
function toggleNormalization() {
normLocal = !normalizationOn
normTouched = true
QbzBridge.settingsBool("normalization", normLocal)
}
// Audio settings — the button OPENS A FLYOUT (Normalization + Gapless),
// it is not itself a toggle: `PlayerBar.slint:666-706`. Its `active`
// colour does mirror normalization here (`:677
// active: SettingsState.normalization`) — the Small bar deliberately does
// NOT, see its own note.
//
// The state is read from the PUBLISHED document. It used to be shadowed
// locally (`normTouched` / `normLocal`) because settingsJson did not carry
// it, and that is what made normalization impossible to turn off: on a
// fresh launch the fallback read `undefined`, the button drew OFF while
// the backend was ON, and the first click SET IT ON. The document is now
// seeded at shell entry (main.rs enter_shell) and republished after every
// apply (settings_qt::apply_audio), so there is nothing left to shadow.
readonly property bool normalizationOn: settingsDoc.normalization === true
function fmt(secs) {
var m = Math.floor(secs / 60)
@@ -402,16 +401,14 @@ Rectangle {
onClicked: viewMenu.openBelowRight(viewBtn)
}
// Audio settings — Tauri's normalization toggle (2.0
// groups normalization + gapless behind this button).
// TODO(qt-bridge): the Slint two-toggle flyout is not
// ported; the click toggles normalization directly, which
// is exactly what the Tauri button did.
// Audio settings — opens the Normalization + Gapless
// flyout (PlayerBar.slint:666-706). NOT a toggle.
QbzIconButton {
id: audioBtn
name: "settings-2"
active: root.normalizationOn
anchors.verticalCenter: parent.verticalCenter
onClicked: root.toggleNormalization()
onClicked: audioMenu.openBelowRight(audioBtn)
}
Item { width: 6; height: 1 }
@@ -473,6 +470,11 @@ Rectangle {
// The Now-Playing-view mode menu (shared with the Small bar).
ViewModeMenu { id: viewMenu }
// Audio settings flyout — Normalization + Gapless (PlayerBar.slint's
// `audio-menu` PopupWindow). Fed the already-parsed document so it does
// not re-parse settingsJson on every open.
AudioSettingsMenu { id: audioMenu; doc: root.settingsDoc }
// "Add to…" flyout behind the transport "+" (TransportControls.slint's
// add-menu), on the shared CardMenu surface. Same seven entries, same
// order, same icons.