An agent that did not write this code audited it against the contract's §9, §7 and §11.3. Four of its five findings are fixed here (the fifth, the sync() ordering, landed in the previous commit). 1. QBZ_KIOSK_FULLSCREEN was INERT and the log LIED about it. fullscreen_at_boot() had exactly one caller -- choosing a log string -- so an appliance boot with the env set stayed windowed while printing "-> fullscreen-at-boot". The reference does the real thing at qbz/src/main.rs:8611. Now a seeded-at- construction QbzShell.kioskFullscreenBoot drives Main.qml's `visibility` declaratively, so it is part of the FIRST mapped frame. It is one-shot by construction and requires BOTH the profile and the env, which is what keeps a desktop user who toggles kiosk and restarts from being trapped in a shell with no titlebar control (incident 2026-07-11). 2. Shift+I was a reachable DEAD STATE. The rebindable ui.focusMode action reaches the hotkeys dispatcher from the kiosk root like any other non-nav key, so QbzImmersive.open went true over the sanctioned EMPTY mount -- and the kiosk root then rejects every arrow, Enter and Esc while it is true, so the whole zone model froze against a surface that renders nothing. The visualizer tile already declined to open it for this exact reason; the guard now sits in apply_open, the funnel every entry passes through, because the tile was not the only door. It refuses to OPEN, never to close. 3. Kiosk chrome on the small bar was half-ported. The reference HIDES the Lyrics and Queue buttons in kiosk (PlayerBarSmall.slint:846, :983); the port gated only their click handlers, so both still rendered and still toggled side panels the kiosk shell never mounts -- inert affordances, which §9.2 forbids. 4. "Kiosk mode" and "Desktop mode" shipped untranslated. My check_msgids.py compared against the 31 @tr strings in the Slint KIOSK sources, which do not include the two strings this port adds to the view-mode menu -- the checker was right and its input was incomplete. Both now in all 7 translated locales. Verified by driving, in one frame: the small bar's right cluster no longer carries the Lyrics and Queue glyphs, and after Shift+I two Rights still move the shelf cursor (scratchpad/kiosk/f-02-after-shiftI.png). The fullscreen arm is verified only as far as a headless run can go -- both env branches log correctly and the binding resolves -- since actual fullscreen needs a real display, which is Pi/owner-smoke territory. cc
651 lines
31 KiB
QML
651 lines
31 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 is LIVE (the golden ConnectButton opens shell/QconnectFlyout.qml —
|
|
// NO tooltip, the reference asymmetry of contract §8); Settings / ViewMode
|
|
// are live too. 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 }
|
|
|
|
// Volume lock, both halves (contract §11.3 — the same `vol-locked`
|
|
// formula PlayerBar.slint:152-158 carries and PlayerBar.qml repeats):
|
|
// the ALSA-Direct hw derivation inside npVolumeLocked is LIFTED while a
|
|
// peer owns playback; npRemoteVolumeLocked is the sink-pushed "peer
|
|
// disallows remote volume" half.
|
|
readonly property bool volLocked:
|
|
(QbzPlayer.npVolumeLocked && !QbzPlayer.npIsRemote)
|
|
|| QbzPlayer.npRemoteVolumeLocked
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// SOURCE of the now-playing track for the MyQBZ AddItem payload
|
|
// ("qobuz" | "local"), read off the queue document's `current` row — the
|
|
// same row the heart above reads — and never written as a literal. The
|
|
// full bar carries the long-form rationale (PlayerBar.qml `npSource`); the
|
|
// short version: `QbzPlayer` publishes no source word, `isLocal === false`
|
|
// proves a Qobuz catalog id, and `isLocal === true` conflates four id
|
|
// namespaces (local row / Plex / ephemeral / offline-cached Qobuz) that
|
|
// this bar cannot separate without sniffing the id. "" = unknown, and the
|
|
// menu then does not offer "Add to mixtape" at all.
|
|
readonly property string npSource: {
|
|
try {
|
|
var d = JSON.parse(QbzQueue.queueJson)
|
|
if (d && d.current && d.current.id === QbzPlayer.npTrackId)
|
|
return d.current.isLocal === true ? "" : "qobuz"
|
|
} catch (e) {
|
|
}
|
|
return ""
|
|
}
|
|
|
|
function fmt(secs) {
|
|
var m = Math.floor(secs / 60)
|
|
var s = Math.floor(secs % 60)
|
|
return m + ":" + (s < 10 ? "0" : "") + s
|
|
}
|
|
|
|
// --- Seek clamp (closes PARITY-DEBT #15, QML half) -------------------
|
|
// Same SeekBar the full bar mounts (PlayerBarSmall.slint:186 passes
|
|
// seekable-max: NowPlayingState.seekable-max into SeekBar.slint), so the
|
|
// behaviour is identical here: while a track is still downloading the
|
|
// seek target is LOCKED to the furthest fraction that has arrived and the
|
|
// cursor turns not-allowed past it. Source: state.slint:4402, fed by
|
|
// playback.rs:5304 `buffer_progress.clamp(0,1)`, published as
|
|
// QbzPlayer.npSeekableMax. A fully-available track reports 1.0, so the
|
|
// Math.min() is a no-op and local/cached seeking stays free.
|
|
function clamp01(v) {
|
|
return Math.min(Math.max(v, 0), 1)
|
|
}
|
|
// SeekBar.slint:98 — Math.min(clamp01(mouse-x / width), seekable-max).
|
|
function seekTarget(fraction) {
|
|
return Math.min(root.clamp01(fraction), QbzPlayer.npSeekableMax)
|
|
}
|
|
// SeekBar.slint:93 — clamp01(mouse-x / width) > seekable-max.
|
|
function beyondSeekable(fraction) {
|
|
return root.clamp01(fraction) > QbzPlayer.npSeekableMax
|
|
}
|
|
|
|
// --- 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
|
|
// No-drop cursor over the not-yet-downloaded region
|
|
// (SeekBar.slint:93-95).
|
|
cursorShape: root.beyondSeekable(mouseX / width) ? Qt.ForbiddenCursor
|
|
: Qt.PointingHandCursor
|
|
enabled: QbzPlayer.npHasTrack
|
|
// Lock the seek target to what has downloaded while streaming
|
|
// (SeekBar.slint:96-99).
|
|
onClicked: QbzPlayer.seek(root.seekTarget(mouseX / width))
|
|
}
|
|
}
|
|
|
|
// === 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
|
|
|
|
// VERTICAL CENTRING IS PER-CHILD HERE. This Row is as tall as
|
|
// the whole ~39px controls row (`height: parent.height`), and
|
|
// a QML Row TOP-ALIGNS every child that does not anchor itself
|
|
// — so a 32px button with no anchor rendered at y=0, flush
|
|
// against the 3px seekbar, with all 7px of slack below it. The
|
|
// active-state fill then read as "too big" when it was simply
|
|
// sitting too high (the button IS 32x32, matching
|
|
// controls/QbzIconButton.qml's btnSize and the reference's
|
|
// 32x32 ConnectButton Rectangle).
|
|
// The reference has no such bug because PlayerBarSmall.slint
|
|
// builds this cluster as a HorizontalLayout, which centres by
|
|
// default; every wrapper there is a `VerticalLayout {
|
|
// alignment: center; }` (e.g. Connect :366-368, cast :601-603).
|
|
// A child of a Row MAY anchor vertically — only horizontal
|
|
// anchoring is forbidden — which is what AudioStamp and
|
|
// QbzSlider already did, and what every sibling below now does
|
|
// so the whole cluster sits on one line with the volume bar.
|
|
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 — the same golden ConnectButton as the
|
|
// full bar (Slint keeps a file-private copy in each bar,
|
|
// PlayerBarSmall.slint:52-55), minus the tooltip: the
|
|
// reference binds NO tooltip on the small bar's button
|
|
// (contract §8). Opens the shared flyout.
|
|
Rectangle {
|
|
id: smallQconnectBtn
|
|
readonly property bool qcActive: QbzQConnect.qconnectConnected
|
|
readonly property color gold: "#e0b341"
|
|
width: 32
|
|
height: 32
|
|
radius: theme.radiusSm
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: qcActive ? Qt.rgba(gold.r, gold.g, gold.b, 0.16)
|
|
: (smallQcArea.containsMouse ? theme.surfaceHover : "transparent")
|
|
border.width: qcActive ? 1 : 0
|
|
border.color: Qt.rgba(gold.r, gold.g, gold.b, 0.45)
|
|
QbzIcon {
|
|
name: "monitor-speaker"
|
|
width: 16
|
|
height: 16
|
|
anchors.centerIn: parent
|
|
tintName: smallQconnectBtn.qcActive ? "amber"
|
|
: smallQcArea.containsMouse ? "textPrimary" : "secondary"
|
|
}
|
|
MouseArea {
|
|
id: smallQcArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: smallQcFlyout.openBelowRight(smallQconnectBtn)
|
|
}
|
|
}
|
|
// 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
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
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
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: smallAudioMenu.openBelowRight(smallAudioBtn)
|
|
}
|
|
QbzIconButton {
|
|
id: smallViewBtn
|
|
name: "layout-grid"
|
|
active: smallViewMenu.opened
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
// In kiosk the button IS the toggle, skipping the menu
|
|
// (`shell/PlayerBarSmall.slint:746-750`). Every other
|
|
// row of that flyout is gated `!kiosk-profile` in the
|
|
// reference, so in kiosk it would carry a single row —
|
|
// which the fixed-height popup floats high above the
|
|
// button (the reason given at `:741-745`).
|
|
onClicked: {
|
|
if (QbzShell.kioskProfile)
|
|
QbzSession.toggleProfile()
|
|
else
|
|
smallViewMenu.openBelowRight(smallViewBtn)
|
|
}
|
|
}
|
|
// HIDDEN in kiosk (PlayerBarSmall.slint:846 gates the
|
|
// whole block on `!ShellState.kiosk-profile`). The kiosk
|
|
// shell mounts no lyrics side panel, so the button would
|
|
// toggle a surface that does not exist -- an inert
|
|
// affordance, which the kiosk contract forbids (§9.2).
|
|
QbzIconButton {
|
|
visible: !QbzShell.kioskProfile
|
|
name: "mic-vocal"
|
|
active: QbzShell.lyricsOpen
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzShell.toggleLyrics()
|
|
}
|
|
|
|
Item { width: 4; height: 1 }
|
|
|
|
// Volume — mute icon + (WIDE) inline horizontal slider.
|
|
// Both gate on root.volLocked (§11.3), like the full bar.
|
|
QbzIconButton {
|
|
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
|
|
btnEnabled: !root.volLocked
|
|
active: QbzPlayer.npMuted
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzPlayer.toggleMute()
|
|
}
|
|
// WIDE: the shared QbzSlider (PlayerBarSmall mounts the
|
|
// same primitive at 72px on the 0..1000 scale).
|
|
QbzSlider {
|
|
enabled: !root.volLocked
|
|
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) }
|
|
// Persist only the settled value, like the full bar.
|
|
onReleased: function (v) { QbzPlayer.persistVolume(v / 1000.0) }
|
|
}
|
|
|
|
Item { width: 4; height: 1 }
|
|
|
|
// Queue panel toggle. HIDDEN in kiosk for the same
|
|
// reason as the lyrics button above
|
|
// (PlayerBarSmall.slint:983) -- the kiosk reaches its
|
|
// queue through the Now Playing view's Up Next tab, and
|
|
// the side panel is never mounted.
|
|
QbzIconButton {
|
|
visible: !QbzShell.kioskProfile
|
|
name: "list-ordered"
|
|
active: QbzShell.queueOpen
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
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 }
|
|
|
|
// Qobuz Connect device flyout — the ONE shared component both bars mount
|
|
// (contract §8; the Slint `qconnect-menu` PopupWindow). Opened below-
|
|
// right of the Connect button.
|
|
QconnectFlyout { id: smallQcFlyout }
|
|
|
|
// "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 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" },
|
|
]
|
|
// "Add to playlist" is SECOND (TransportControls.slint:143 — the
|
|
// full bar carries the rationale). Same `npSource` gate as the
|
|
// mixtape entry: the picker's Qobuz arm takes catalog ids and an
|
|
// `isLocal` current row is four different id spaces this bar
|
|
// cannot tell apart, so unknown provenance drops the entry rather
|
|
// than rendering one that cannot work.
|
|
if (root.npSource === "qobuz") {
|
|
m.splice(1, 0, {
|
|
"label": QbzSession.tr("Add to playlist", QbzSession.trRev),
|
|
"icon": "list-music",
|
|
"action": "playlist"
|
|
})
|
|
}
|
|
// Only when the track's source is KNOWN (see `npSource`).
|
|
if (root.npSource !== "") {
|
|
m.push({
|
|
"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 === "playlist") {
|
|
// Only built when `npSource === "qobuz"`, so `id` is a catalog
|
|
// id here by the gate above.
|
|
if (id !== "") QbzPlaylistPicker.openForTrack(id)
|
|
} 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.
|
|
// Source from the queue's current ROW (`npSource`), not a
|
|
// literal; the entry is absent when that row cannot answer.
|
|
if (id !== "" && root.npSource !== "")
|
|
QbzMyQbzAdd.open(JSON.stringify([{
|
|
"itemType": "track", "source": root.npSource,
|
|
"sourceItemId": id, "title": QbzPlayer.npTitle,
|
|
"subtitle": QbzPlayer.npArtist, "artworkUrl": "",
|
|
"year": null, "trackCount": null
|
|
}]))
|
|
}
|
|
}
|
|
}
|
|
}
|