Mark A, mark B, loop between them until cleared; local playback only, never persisted, cleared on track change or a seek outside the section. A 100 ms watcher reads the millisecond position and reissues the local seek; the seekbar shows the section and its two marks. cc
655 lines
32 KiB
QML
655 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: 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
|
|
}]))
|
|
}
|
|
}
|
|
}
|
|
}
|