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
493 lines
22 KiB
QML
493 lines
22 KiB
QML
// Bottom now-playing / transport bar — QML port of
|
|
// crates/qbz-ui/ui/shell/PlayerBarSmall.slint (Mode C "Small"). Mounted by
|
|
// the NowPlayingBar seam when npbMode == 2 (phase 18).
|
|
//
|
|
// 3px full-width top seekbar (3-color: background / cache / progress),
|
|
// then the symmetric 3-column controls row (~39px):
|
|
// LEFT — the SHARED shell/SongCard.qml in its text-center (Small) arm
|
|
// (37px bordered cover + 13px title over the [layers] · artist ·
|
|
// album meta row) + the fixed 40px time column
|
|
// CENTER — transport cluster (info · shuffle · prev · play · next ·
|
|
// repeat · +)
|
|
// RIGHT — AudioStamp · Connect · Cast · Settings · ViewMode · Lyrics ·
|
|
// Volume · Queue
|
|
// Total height 42px (ShellState.npb-small-height; npb-small-extra is 0 on
|
|
// Linux).
|
|
//
|
|
// BUTTON SET (phase 24): identical to PlayerBar's, in the Small bar's own
|
|
// order — the transport is LITERALLY the shared cluster since phase 25
|
|
// (shell/TransportControls.qml: info · shuffle · prev · play · next · repeat ·
|
|
// "+"), and the right cluster keeps PlayerBarSmall's mount order (Connect ·
|
|
// Cast · Settings · ViewMode · Lyrics · Volume · Queue).
|
|
// The "+" opens the same "Add to…" flyout as the full bar; the quality stamp
|
|
// is AudioStamp.qml — the inline 2-row stamp (quality line over the backend /
|
|
// mode LEDs) that PlayerBarSmall.slint mounts, shared with the Large bar.
|
|
//
|
|
// Connect / Settings / ViewMode are still inert visual replicas where no Qt
|
|
// invokable exists (TODO comments at the call sites); Volume, Queue, Shuffle,
|
|
// Repeat, Mute, Lyrics and the add flyout are live. Cast opens
|
|
// shell/CastPicker.qml (QbzCast — discovery, connect/disconnect, the
|
|
// per-device quality cap) and lights while a renderer is connected. Track
|
|
// Info (the (i) button and the song-card title) opens
|
|
// shell/TrackInfoModal.qml — it needs the QbzAlbum.openTrackInfo glue, see
|
|
// that file's header.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
// surface-card @ 0.5 while the ambient background is active (phase 14,
|
|
// PlayerBarSmall.slint).
|
|
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
|
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
|
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
// Responsive side fraction — IDENTICAL to PlayerBarSmall's mechanism:
|
|
// LEFT and RIGHT columns share this fraction so the CENTER transport
|
|
// column lands PLAY on the window centre. The breakpoints (39/30/25 at
|
|
// 1366 / 1920) are the SHARED theme token, so this bar and PlayerBar can
|
|
// never drift apart — see QbzTheme.npbSideFrac.
|
|
// `root.width` IS the window width: the bar is anchored left-to-right on
|
|
// the shell root, exactly like PlayerBarSmall.slint reads its own root.
|
|
property real sideFrac: theme.npbSideFrac(root.width)
|
|
property real colCentre: 1.0 - 2.0 * root.sideFrac
|
|
// WIDE = inline horizontal volume slider; NARROW = button-only.
|
|
// Same 1366 edge as the side fraction (PlayerBarSmall.slint:152).
|
|
property bool volumeWide: root.width >= theme.npbBreakMid
|
|
|
|
// Favorite state of the now-playing track (Slint:
|
|
// QueueState.now-playing-favorite) — the queue document carries it on its
|
|
// `current` row; re-parsed only when that document changes. The one-slot
|
|
// override is the same seam PlayerBar.qml documents: a heart flipped on
|
|
// another surface for THIS track republishes no queue document, so
|
|
// without it the bar kept a stale glyph until the track changed.
|
|
property string favOverrideId: ""
|
|
property bool favOverrideValue: false
|
|
readonly property bool npFavorite: {
|
|
if (root.favOverrideId !== "" && root.favOverrideId === QbzPlayer.npTrackId)
|
|
return root.favOverrideValue
|
|
try {
|
|
var d = JSON.parse(QbzQueue.queueJson)
|
|
return !!(d && d.current && d.current.isFavorite)
|
|
} catch (e) {
|
|
return false
|
|
}
|
|
}
|
|
Connections {
|
|
target: QbzLibrary
|
|
function onLibraryFavoriteChanged(key, value) {
|
|
var id = QbzPlayer.npTrackId
|
|
if (id !== "" && key === "track:" + id) {
|
|
root.favOverrideId = id
|
|
root.favOverrideValue = value
|
|
}
|
|
}
|
|
}
|
|
|
|
function fmt(secs) {
|
|
var m = Math.floor(secs / 60)
|
|
var s = Math.floor(secs % 60)
|
|
return m + ":" + (s < 10 ? "0" : "") + s
|
|
}
|
|
|
|
// --- Track Info (album/TrackInfoModal.slint) -------------------------
|
|
// The (i) button and the song-card title open the MODAL (scrim + centered
|
|
// card) — PlayerBarSmall.slint fires media-action("track", id,
|
|
// "track-info") and AppShell mounts TrackInfoModal. Slint gates it on
|
|
// !NowPlayingState.is-ephemeral (no metadata page for local / Plex /
|
|
// ephemeral rows); the Qt port publishes no source flag, so a numeric
|
|
// track id stands in for it. TODO(glue): publish np_source.
|
|
readonly property bool qobuzTrack: QbzPlayer.npHasTrack
|
|
&& /^[0-9]+$/.test(QbzPlayer.npTrackId)
|
|
function openTrackInfo() {
|
|
if (!root.qobuzTrack)
|
|
return
|
|
trackInfo.openFor(QbzPlayer.npTrackId)
|
|
}
|
|
|
|
TrackInfoModal { id: trackInfo }
|
|
|
|
// --- Cast picker (shell/CastPicker.slint) ----------------------------
|
|
// Same mount reasoning as TrackInfoModal above: a Popup parented to
|
|
// Overlay.overlay, so AppShell.qml needs no mount, and the NowPlayingBar
|
|
// Loader guarantees exactly ONE bar (hence one instance) is alive.
|
|
// Visibility follows QbzCast.pickerOpen; the right-cluster cast button
|
|
// raises it through QbzCast.openPicker(), which also arms discovery.
|
|
CastPicker { }
|
|
|
|
// Square compact icon button (BarControls.IconButton): disabled = dim
|
|
// 0.3 + non-clickable; active = accent tint.
|
|
|
|
|
|
// Thin vertical separator (PlayerBarSmall's Sep): a 1px line,
|
|
// border-subtle, ~60% of the row height, vertically centred; the
|
|
// horizontal gaps are the caller's.
|
|
component Sep: Item {
|
|
property int gapLeft: 6
|
|
property int gapRight: 6
|
|
width: gapLeft + 1 + gapRight
|
|
height: parent ? parent.height : 0
|
|
Rectangle {
|
|
x: gapLeft
|
|
width: 1
|
|
height: 24
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: theme.borderSubtle
|
|
}
|
|
}
|
|
|
|
Column {
|
|
anchors.fill: parent
|
|
spacing: 0
|
|
|
|
// === A. TOP full-width seekbar (the content/bar divider) ========
|
|
Item {
|
|
width: parent.width
|
|
height: 3
|
|
|
|
Rectangle {
|
|
id: seekTrack
|
|
width: parent.width
|
|
height: 3
|
|
radius: 2
|
|
color: theme.surfaceElevated
|
|
|
|
// Buffered / cache line.
|
|
Rectangle {
|
|
width: parent.width * Math.min(Math.max(QbzPlayer.npCacheProgress, 0), 1)
|
|
height: parent.height
|
|
radius: 2
|
|
color: theme.textMuted
|
|
opacity: 0.35
|
|
}
|
|
// Playback progress line.
|
|
Rectangle {
|
|
width: parent.width * Math.min(Math.max(QbzPlayer.npProgress, 0), 1)
|
|
height: parent.height
|
|
radius: 2
|
|
color: theme.accent
|
|
}
|
|
// Hover thumb.
|
|
Rectangle {
|
|
width: 12
|
|
height: 12
|
|
radius: 6
|
|
color: theme.textPrimary
|
|
x: parent.width * Math.min(Math.max(QbzPlayer.npProgress, 0), 1) - width / 2
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
opacity: seekArea.containsMouse ? 1.0 : 0.0
|
|
Behavior on opacity { NumberAnimation { duration: 100 } }
|
|
}
|
|
}
|
|
// Tall, easy-to-grab hit area over the thin line.
|
|
MouseArea {
|
|
id: seekArea
|
|
width: parent.width
|
|
height: 18
|
|
anchors.verticalCenter: seekTrack.verticalCenter
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
enabled: QbzPlayer.npHasTrack
|
|
onClicked: QbzPlayer.seek(Math.min(Math.max(mouseX / width, 0), 1))
|
|
}
|
|
}
|
|
|
|
// === B. CONTROLS ROW — symmetric 3 columns =======================
|
|
Item {
|
|
width: parent.width
|
|
height: parent.height - 3
|
|
|
|
// ---- LEFT column: SongCard + Sep + time column --------------
|
|
Item {
|
|
anchors.left: parent.left
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
width: (root.width - 6) * root.sideFrac
|
|
clip: true
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
spacing: 0
|
|
|
|
// Song card: the SHARED shell/SongCard.qml in its
|
|
// text-center arm — 37px cover (3px surface-card border,
|
|
// flush to the window's left edge) + the 13px title over
|
|
// the [layers] · artist · album meta row. 1:1 with
|
|
// PlayerBarSmall.slint:238-267, which mounts the very same
|
|
// SongCard with these exact knobs.
|
|
//
|
|
// This REPLACES a hand-rolled 2-line copy (art + title +
|
|
// artist Text) that silently dropped three things living
|
|
// only inside SongCard: the context glyph, the album link
|
|
// and the floating cover preview (SongCard's artHover is
|
|
// the ONLY writer of QbzShell.artPreviewShow, so the
|
|
// shell's ArtPreviewOverlay was simply never armed in
|
|
// Small). Rule 5 — reuse, don't fork.
|
|
//
|
|
// Width = the column minus the Sep + time column, so the
|
|
// title and the meta budget elide at exactly the free
|
|
// space. Height = the 39px controls row: BOTH must be
|
|
// explicit, because SongCard's own defaults are 200x74 and
|
|
// a 74px card would push the 37px cover off-centre.
|
|
SongCard {
|
|
height: parent.height
|
|
width: parent.width - (QbzPlayer.npHasTrack ? 59 : 0)
|
|
artSize: 37
|
|
artBorderWidth: 3
|
|
artBorderColor: theme.surfaceCard
|
|
artTextGap: 9
|
|
titleFontSize: 13
|
|
showBadges: false
|
|
textCenter: true
|
|
onTrackInfoRequested: root.openTrackInfo()
|
|
}
|
|
|
|
// Separator: song-card | time column (8px toward the
|
|
// card, 10px toward the timer).
|
|
Sep {
|
|
visible: QbzPlayer.npHasTrack
|
|
gapLeft: 8
|
|
gapRight: 10
|
|
}
|
|
|
|
// Time column — fixed 40px 2-row block (elapsed /
|
|
// total), monospace for stable digit positions.
|
|
Column {
|
|
visible: QbzPlayer.npHasTrack
|
|
width: 40
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 1
|
|
Text {
|
|
text: root.fmt(QbzPlayer.npElapsedSecs)
|
|
font.family: "monospace"
|
|
font.pixelSize: 10
|
|
color: theme.textMuted
|
|
}
|
|
Text {
|
|
text: root.fmt(QbzPlayer.npDurationSecs)
|
|
font.family: "monospace"
|
|
font.pixelSize: 10
|
|
color: theme.textMuted
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- CENTER column: transport, window-centred --------------
|
|
Item {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: (root.width - 6) * root.sideFrac
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
width: (root.width - 6) * root.colCentre
|
|
clip: true
|
|
|
|
// The SHARED cluster (shell/TransportControls.qml) — the
|
|
// Small bar's own copy was a byte-for-byte twin of the full
|
|
// bar's at playCircle:false / classicActions:false.
|
|
TransportControls {
|
|
anchors.centerIn: parent
|
|
height: 34
|
|
playCircle: false
|
|
favorite: root.npFavorite
|
|
onAddRequested: function (anchorItem) { smallAddMenu.openBelowRight(anchorItem) }
|
|
onTrackInfoRequested: root.openTrackInfo()
|
|
}
|
|
}
|
|
|
|
// ---- RIGHT column: the control SET, right-aligned ----------
|
|
// Order (PlayerBarSmall mount order): AudioStamp · Connect ·
|
|
// Cast · Settings · ViewMode · Lyrics · Volume · Queue.
|
|
Item {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 6
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
width: (root.width - 6) * root.sideFrac
|
|
clip: true
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
height: parent.height
|
|
spacing: 1
|
|
|
|
// AudioStamp — the inline 2-row stamp (quality line over
|
|
// the backend/mode LEDs), FIRST in the right set.
|
|
// PlayerBarSmall.slint clamps it at 150px, widening to
|
|
// 280px when a QConnect renderer name is shown (it grows
|
|
// leftward into the empty space before the centre
|
|
// transport; the right zone's clip is the real guard).
|
|
AudioStamp {
|
|
visible: QbzPlayer.npHasTrack
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
maxWidth: (QbzPlayer.npIsRemote && QbzPlayer.npCastTarget !== "")
|
|
? 280 : 150
|
|
}
|
|
|
|
// Separator: AudioStamp | icon cluster (10px toward the
|
|
// stamp, 8px toward the buttons).
|
|
Sep {
|
|
visible: QbzPlayer.npHasTrack
|
|
gapLeft: 10
|
|
gapRight: 8
|
|
}
|
|
|
|
// Qobuz Connect.
|
|
// TODO(qt-bridge): no qconnect state/toggle exposed
|
|
// (Slint: NowPlayingState.qconnect-connected +
|
|
// qconnect-toggle()). Rendered 1:1, inert.
|
|
QbzIconButton { name: "monitor-speaker" }
|
|
// Cast (Chromecast / DLNA) — opens the picker modal and,
|
|
// with it, device discovery (PlayerBarSmall.slint:596-611:
|
|
// picker-open = true + CastActions.open()); lit while a
|
|
// renderer is connected.
|
|
QbzIconButton {
|
|
name: "cast"
|
|
active: QbzPlayer.npCastActive
|
|
onClicked: QbzCast.openPicker()
|
|
}
|
|
// 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: smallAudioBtn
|
|
name: "settings-2"
|
|
active: false
|
|
onClicked: smallAudioMenu.openBelowRight(smallAudioBtn)
|
|
}
|
|
QbzIconButton {
|
|
id: smallViewBtn
|
|
name: "layout-grid"
|
|
active: smallViewMenu.opened
|
|
onClicked: smallViewMenu.openBelowRight(smallViewBtn)
|
|
}
|
|
QbzIconButton {
|
|
name: "mic-vocal"
|
|
active: QbzShell.lyricsOpen
|
|
onClicked: QbzShell.toggleLyrics()
|
|
}
|
|
|
|
Item { width: 4; height: 1 }
|
|
|
|
// Volume — mute icon + (WIDE) inline horizontal slider.
|
|
QbzIconButton {
|
|
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
|
|
btnEnabled: !QbzPlayer.npVolumeLocked
|
|
active: QbzPlayer.npMuted
|
|
onClicked: QbzPlayer.toggleMute()
|
|
}
|
|
// WIDE: the shared QbzSlider (PlayerBarSmall mounts the
|
|
// same primitive at 72px on the 0..1000 scale).
|
|
QbzSlider {
|
|
enabled: !QbzPlayer.npVolumeLocked
|
|
visible: root.volumeWide
|
|
width: 72
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
minimum: 0
|
|
maximum: 1000
|
|
value: Math.round(QbzPlayer.npVolume * 1000)
|
|
onChanged: function (v) { QbzPlayer.setVolume(v / 1000.0) }
|
|
}
|
|
|
|
Item { width: 4; height: 1 }
|
|
|
|
// Queue panel toggle.
|
|
QbzIconButton {
|
|
name: "list-ordered"
|
|
active: QbzShell.queueOpen
|
|
onClicked: QbzShell.toggleQueue()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
id: smallAddMenu
|
|
menuWidth: 232
|
|
entries: {
|
|
var m = [
|
|
{
|
|
"label": root.npFavorite
|
|
? QbzSession.tr("Remove from library", QbzSession.trRev)
|
|
: QbzSession.tr("Add to library", QbzSession.trRev),
|
|
"icon": root.npFavorite ? "heart-filled" : "heart",
|
|
"action": "favorite"
|
|
},
|
|
{ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "playlist" },
|
|
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
|
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
|
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
|
{ "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "mixtape" },
|
|
]
|
|
if (QbzPlayer.npAlbumId !== "") {
|
|
m.push({
|
|
"label": QbzSession.tr("Add album to collection", QbzSession.trRev),
|
|
"icon": "disc-3",
|
|
"action": "album-favorite"
|
|
})
|
|
}
|
|
return m
|
|
}
|
|
onPicked: function (a) {
|
|
var id = QbzPlayer.npTrackId
|
|
if (a === "favorite") {
|
|
if (id !== "") QbzQueue.queueToggleFavorite("track", id)
|
|
} else if (a === "queue" || a === "later" || a === "next") {
|
|
if (id !== "") QbzPlayer.enqueueTrack(id, a)
|
|
} else if (a === "album-favorite") {
|
|
if (QbzPlayer.npAlbumId !== "")
|
|
QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId)
|
|
} else if (a === "mixtape") {
|
|
// MyQBZ AddItem, built here from the now-playing state:
|
|
// `npArtworkPath` is a file:// CACHE path, so it is NOT the
|
|
// artworkUrl — the store would keep a dead local path.
|
|
if (id !== "")
|
|
QbzMyQbzAdd.open(JSON.stringify([{
|
|
"itemType": "track", "source": "qobuz",
|
|
"sourceItemId": id, "title": QbzPlayer.npTitle,
|
|
"subtitle": QbzPlayer.npArtist, "artworkUrl": "",
|
|
"year": null, "trackCount": null
|
|
}]))
|
|
}
|
|
// TODO(qt-bridge): "playlist" (add-to-playlist modal) has no
|
|
// invokable in the Qt port yet — the row is rendered 1:1 with the
|
|
// Slint flyout and does nothing for now.
|
|
}
|
|
}
|
|
}
|