Files
qbz/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml
T
vicrodh 7c71c6bf52 feat(qt): the player bar keeps the content pane's gutter on every mode, wider on macOS
Every now-playing bar (New / Classic / Large / Small) is inset by the same
8 px the content pane keeps to its frame — left, right and bottom — behind
a frame-tier backdrop, so the bar's ends line up with the pane's, nothing
in it touches the window edge, and the gutter reads as one continuous
frame with the sidebar rather than a band of raw background. The Large
cover dock lifts with the bar. On macOS the gutter is 16 px: Tahoe's
Liquid Glass windows round their corners far more than before (larger
still around a toolbar) and clip whatever sits inside the curve — the
cover at the bar's left end lost its corner — and Qt has no LayoutRegion
to nest content into the curve, so the margin is the fix.

cc
2026-09-13 11:37:11 -06:00

656 lines
32 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: theme.ambientOn
/// AppShell's one shared hover-tooltip overlay.
property Item tooltip: null
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 minus the two side gutters
// (AppShell `npbGutter`): 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
/// The playing track is EPHEMERAL — a disc, an image or an ad-hoc folder.
/// Same flag and same rule as the full bar: nothing that implies
/// persistence is offered, because there is nothing to point at once the
/// session ends. `queue_qt.rs` publishes it on every row.
readonly property bool npEphemeral: {
try {
var d = JSON.parse(QbzQueue.queueJson)
return !!(d && d.current && d.current.isEphemeral)
} catch (e) {
return 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
}
// --- 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) ========
QbzCompactSeekBar {
width: parent.width
hasTrack: QbzPlayer.npHasTrack
progress: QbzPlayer.npProgress
cacheProgress: QbzPlayer.npCacheProgress
seekableMax: QbzPlayer.npSeekableMax
durationSecs: QbzPlayer.npDurationSecs
onSeekRequested: function (fraction) { QbzPlayer.seek(fraction) }
}
// === B. CONTROLS ROW — symmetric 3 columns =======================
Item {
id: controlsLayout
width: parent.width
height: parent.height - 3
// Intrinsic boundary of the centred transport. The metadata/time
// group may use every pixel before it, regardless of how wide the
// nominal centre zone is at this window size.
readonly property real transportLeft:
centreZone.x + centreTransport.x
// ---- LEFT column: SongCard + Sep + time column --------------
Item {
id: leftZone
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: Math.max(0, controlsLayout.transportLeft - 8)
// No clip: the Row sums to exactly this column (width-59 + 19
// + 40, spacing 0) and the two time Texts above are now
// bounded, which was the only escape path.
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.
//
// The live runway is the column minus Sep + time; the
// shared 80% cap keeps this mode consistent with the three
// full bars. 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
readonly property real availableWidth: Math.max(0,
parent.width - (QbzPlayer.npHasTrack ? 59 : 0))
width: availableWidth * theme.npbSongCardMaxFraction
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 {
// Bounded so the column below needs no scissor: a
// NaN transient or a >16h duration is the only way
// these overflow, and truncating here lands at the
// same x the clip did.
width: parent.width
horizontalAlignment: Text.AlignLeft
elide: Text.ElideRight
text: root.fmt(QbzPlayer.npElapsedSecs)
font.family: "monospace"
font.pixelSize: 10
color: theme.textMuted
}
Text {
width: parent.width
horizontalAlignment: Text.AlignLeft
elide: Text.ElideRight
text: root.fmt(QbzPlayer.npDurationSecs)
font.family: "monospace"
font.pixelSize: 10
color: theme.textMuted
}
}
}
}
// ---- CENTER column: transport, window-centred --------------
Item {
id: centreZone
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 {
id: centreTransport
anchors.centerIn: parent
height: 34
compact: root.width < 900
playCircle: false
favorite: root.npFavorite
ephemeral: root.npEphemeral
tooltip: root.tooltip
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
tooltipHost: root.tooltip
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.openAboveRight(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()
// A-B loop entries for the "+" menu (see PlayerBar.abEntries): local
// playback only.
function abEntries() {
if (QbzPlayer.npIsRemote || QbzPlayer.npCastActive)
return []
var m = [{ "sep": true }]
if (QbzPlayer.abState === 0)
m.push({ "label": QbzSession.tr("Set loop start (A)", QbzSession.trRev), "icon": "repeat", "action": "ab-mark" })
else if (QbzPlayer.abState === 1)
m.push({ "label": QbzSession.tr("Set loop end (B)", QbzSession.trRev), "icon": "repeat", "action": "ab-mark" })
if (QbzPlayer.abState !== 0)
m.push({ "label": QbzSession.tr("Clear loop", QbzSession.trRev), "icon": "x", "action": "ab-clear" })
return m
}
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 above-
// right of the Connect button so it never covers its own trigger.
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: {
// The three QUEUE actions are the only ones an ephemeral track can
// honour; the rest write a reference that outlives the session.
var m = []
if (!root.npEphemeral) {
m.push({
"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"
})
}
m.push({ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" })
m.push({ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" })
m.push({ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" })
if (root.npEphemeral)
return m.concat(root.abEntries())
// "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.concat(root.abEntries())
}
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 === "ab-mark") {
QbzPlayer.abMark()
} else if (a === "ab-clear") {
QbzPlayer.abClear()
} 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,
// artworkUrl STAYS EMPTY here, deliberately: the player bridge exposes only np_artwork_path.
// A file:// cache path must NOT be stored — the collection's
// artwork_url is a snapshot other machines read — so this needs a
// remote-url field on the document first. The five sister sites
// that HAD one were stamped 2026-08-22.
"subtitle": QbzPlayer.npArtist, "artworkUrl": "",
"year": null, "trackCount": null
}]))
}
}
}
}