Files
qbz/crates/qbz-qt/qml/shell/PlayerBar.qml
T
vicrodh 1817de0743 feat(qt): release-aware album links, an unavailable-release page, Copy submenus and Buy on Qobuz
Unavailable tracks (8): playlist rows now say when the WHOLE release is
gone (`releaseUnavailable`, one probe per distinct album of the pulled rows
through the library's helper); TrackRow's album link and "Go to album" go
dark on it instead of opening an empty page. Every row that opens an album
passes the names it knows (`openAlbumFrom`), so when the catalog answers
404 the page names the release: AlbumView renders an "unavailable" arm —
heading, "This release is no longer available on Qobuz." — with the
best-ranked alternatives Qobuz still sells (the replacement flow's ranking,
exposed as `ranked_alternatives`) in a rail; a network failure gets the
same page with "Couldn't load this release." and no rail.

Copy (9): the player bar's "+" and the track row menus (shared TrackRow
and the artist's Popular Tracks rows) gain a Copy entry with a submenu —
"Track name" or "Track - Album - Artist" — through the shared clipboard
carrier; CardMenu marks `submenu: true` entries with a chevron and the
host reopens the second menu on the same anchor. Row menus keep both lazy.

Buy on Qobuz (10): the album header shows a store action when the catalog
says the release is purchasable and it is not already owned; the track
menus and the player bar carry the same entry for catalog rows and resolve
it at click time — the store page opens in the browser, or a toast says
the release is not sold. The link is `https://www.qobuz.com/album/-/{id}`,
which the store answers with a 301 to the localised page (verified).

cc
2026-09-13 20:26:11 -06:00

1049 lines
51 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PlayerBar — the full 112px now-playing bar for modes 0 (New), 1
// (Classic) and 3 (Large) — QML port of crates/qbz-ui/ui/shell/
// PlayerBar.slint (phase 18). Mode 2 (Small) mounts NowPlayingBarSmall
// instead (the NowPlayingBar seam picks the body).
//
// Composition (1:1): a full-width SeekBar with times (6px margins; in
// Large the left edge insets by the sidebar width so the seek arm clears
// the docked cover), then the controls row in three responsive symmetric
// columns (the side columns share a fraction so PLAY lands on the window
// centre at every width):
// width < 1366 -> 39% | 22% | 39%
// 1366..<1920 -> 30% | 40% | 30%
// width >= 1920 -> 25% | 50% | 25%
// Classic uses a fixed 32.4/35.2/32.4 split (col-side 0.324).
//
// New (0): LEFT SongCard · CENTER transport (filled disc) · RIGHT cluster
// Classic (1): LEFT transport · elastic glass SongCard · RIGHT cluster
// Large (3): LEFT SongCard WITHOUT the cover (it lives in the sidebar
// dock, shifted right to clear it) · CENTER transport ·
// RIGHT inline AudioStamp + cluster.
//
// QUALITY STACK (which bar mounts which component — SongCard.slint /
// PlayerBar.slint / PlayerBarSmall.slint):
// New (0), Classic (1) -> shell/SongCardStamp.qml, mounted INSIDE the song
// card: QualityBadgeFull (icon + stacked
// tier/detail) over the dot LEDs.
// Large (3), Small (2) -> shell/AudioStamp.qml, the inline 2-row stamp.
// They are DIFFERENT components in Slint and they stay different here.
//
// BUTTON SET (phase 24 — 1:1 with the Tauri NowPlayingBar.svelte, via the
// Slint TransportControls/PlayerBar that descend from it):
// transport: info · shuffle · prev · play · next · repeat · "+" (Add to…)
// (+ the inline favorite heart in Classic, like Tauri)
// right: Connect · Cast · Lyrics · Now-Playing view · Audio settings
// (normalization) · [6px] · Mute · slider · −/+ steppers ·
// [6px] · Queue
// Connect BEFORE Cast is the reference's order, not a preference: PlayerBar.
// slint mounts the ConnectButton at :395-401 and the cast IconButton at
// :646-653. This file had the pair inverted (Cast first) until the order was
// homologated; NowPlayingBarSmall.qml always matched (PlayerBarSmall.slint
// ConnectButton :368, cast :603).
// Tauri's Miniplayer + Full screen buttons live inside the Now-Playing view
// flyout (ViewModeMenu) since 2.0 — that ONE button holds both.
//
// Wired: transport (shuffle/prev/play/next/repeat), the add flyout
// (library / queue / play next / play later / album), seek, volume + mute +
// steppers, lyrics, queue, the Now-Playing-view flyout (npbSetMode),
// normalization, open album/artist via the SongCard meta links, and Track
// Info — the (i) button and the song-card title open shell/TrackInfoModal.qml
// (needs the QbzAlbum.openTrackInfo glue; see that file's header). Cast opens
// shell/CastPicker.qml (QbzCast — discovery, connect/disconnect, the
// per-device quality cap) and lights while a renderer is connected. Qobuz
// Connect is LIVE: the golden ConnectButton opens shell/QconnectFlyout.qml
// (QbzQConnect — device list, set-active, connect/disconnect) and carries
// the "Qobuz Connect: On/Off" hover bubble via the shell's tooltip overlay.
// Add-to-playlist and add-to-mixtape are live through the two app-wide
// pickers (QbzPlaylistPicker / QbzMyQbzAdd).
//
// SIZE (project rule): the inline SongCard / TransportControls / FavToggle
// components moved out to shell/SongCard.qml, shell/TransportControls.qml and
// shell/FavToggle.qml in phase 25 — TransportControls is now shared with the
// Small bar instead of being duplicated there.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
// No background of its own: the chrome band is NowPlayingBar.qml's root
// (surface-card @ 0.5 under the ambient background, opaque surface-card
// otherwise), full width behind this inset layout — see THE BAND there.
color: "transparent"
readonly property bool ambientOn: theme.ambientOn
readonly property bool largeActive: QbzShell.npbMode === 3 && QbzShell.sidebarState === 0
readonly property bool isClassic: QbzShell.npbMode === 1
// Feature parked for a later release. The renderer and preference remain
// intact; every full-height NPB uses the classic seekbar for now.
readonly property bool waveformVisible: false
// The shell's shared hover-tooltip overlay (controls/QbzTooltip.qml),
// fed in through NowPlayingBar.qml's Binding. Consumed by the dynamic
// Shuffle/Repeat state bubbles and by the Qobuz Connect button's
// "Qobuz Connect: On/Off" bubble.
property Item tooltip: null
// Volume lock, both halves (contract §11.3 = PlayerBar.slint:152-158's
// `vol-locked`): npVolumeLocked carries the ALSA-Direct hw derivation
// INSIDE it, and that local bit-perfect lock is LIFTED when a peer owns
// playback (the `&& !npIsRemote` term) so the user CAN adjust the remote
// renderer; npRemoteVolumeLocked is the sink-pushed "peer disallows
// remote volume" half. The natural misread (`!npVolumeLocked &&
// !npIsRemote && ...`) would disable the slider for EVERY peer, breaking
// §7 volume routing to volume-ALLOWING peers.
readonly property bool volLocked:
(QbzPlayer.npVolumeLocked && !QbzPlayer.npIsRemote)
|| QbzPlayer.npRemoteVolumeLocked
QbzTheme { id: theme }
// Responsive zones (PlayerBar.slint). Classic fixes col-side at 0.324;
// New/Large use the width-driven side fraction. Both keep the two side
// columns EQUAL so the centre column stays dead centre. The breakpoints
// are the SHARED theme token (QbzTheme.npbSideFrac) — the Small bar reads
// the same one, so the two bars cannot drift apart.
// `root.width` is the window width minus the two side gutters
// (NowPlayingBar.qml `gutter`): the bar is anchored left-to-right on the shell
// root, exactly like PlayerBar.slint reads its own root.
property real sideFrac: theme.npbSideFrac(root.width)
property real colSide: isClassic ? 0.324 : sideFrac
property real colCentre: 1.0 - 2.0 * colSide
// The docked-cover width the Large seek arm + song card must clear
// (sidebar rendered width; 240 when open).
readonly property int dockWidth: largeActive ? 240 : 0
readonly property var settingsDoc: parseSettings()
function parseSettings() {
try {
return JSON.parse(QbzBridge.settingsJson)
} catch (e) {
return ({})
}
}
// (The old backendPipewire / dacPassthrough LED sources are gone: the
// stamp's LEDs now read np_output_backend_* / np_output_mode_* off
// QbzPlayer, which is where Slint reads them too — the settings document
// never knew whether the backend was actually ENGAGED.)
// AppearanceState.show-volume-steppers (PlayerBar.slint gates the −/+
// pair on it; Tauri always showed them).
readonly property bool showVolumeSteppers: settingsDoc.showVolumeSteppers === true
// A-B loop entries for the "+" menu: one contextual verb + clear. Local
// playback only — the loop cannot follow a cast / Connect renderer.
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
}
// Favorite state of the now-playing track (Slint:
// QueueState.now-playing-favorite). The queue document carries it on its
// `current` row; this re-parses ONLY when that document changes.
//
// The bar's OWN heart settles fine on its own — `queue_qt::toggle_favorite`
// republishes the queue document straight after the write. What it could
// not see was a heart flipped ANYWHERE ELSE for the same track (the album
// page's row, a search result, a card): nothing republishes the queue for
// those, so the bar sat on a stale glyph for the rest of the track. The
// one-slot override below closes it, keyed on the track id so it expires
// by itself the moment playback moves on — no map to keep clean.
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
}
}
}
/// The playing track is EPHEMERAL — a disc, an image or an ad-hoc folder.
///
/// It leaves no trace in QBZ: no `library.db` row, no catalog id, nothing
/// a favourite or a playlist entry could point at tomorrow. So every
/// affordance that implies PERSISTENCE has to go, and the queue ones stay
/// — which is exactly what `QueueSidebar` already does with the same flag
/// (`queue_qt.rs` publishes `isEphemeral` on every row; this bar simply
/// never read it).
///
/// Read from the queue's `current` row rather than guessed from the id
/// range: Rust tests BOTH halves there (the row's own source tag and the
/// id floor), and a second, weaker copy of that test in QML is a second
/// thing to get wrong.
readonly property bool npEphemeral: {
try {
var d = JSON.parse(QbzQueue.queueJson)
return !!(d && d.current && d.current.isEphemeral)
} catch (e) {
return false
}
}
// SOURCE of the now-playing track — the word the MyQBZ AddItem payload
// needs ("qobuz" | "local"), NEVER a literal.
//
// This bar's "row" is the queue document's `current` row, the same row the
// heart above reads. `QbzPlayer` publishes no source word at all
// (player_bridge.rs:31-114 — there is no `np_source`), so the only
// published fact about the current track's provenance is that row's
// `isLocal` (queue_qt.rs:46-50, straight off `QueueTrack::is_local`).
//
// `isLocal === false` is unambiguous: the queue id IS a Qobuz catalog id.
// `isLocal === true` is NOT one thing — it covers a `library.db` row id, a
// `PLEX_TRACK_ID_FLOOR`-namespaced id, an `EPHEMERAL_ID_FLOOR` id, and the
// offline cache, whose queue id is a QOBUZ id (local_playback.rs:53-58).
// Those four need four different payloads and this bar cannot tell them
// apart without sniffing the id shape, which is exactly the scattered
// knowledge the source seam exists to end. So it answers "" = UNKNOWN, and
// the menu below drops "Add to mixtape" instead of stamping a guess: an
// action that cannot work must not be offered, and an ephemeral track must
// leave no trace outside the queue.
//
// The id guard is not paranoia-for-free: it is what keeps a stale queue
// document from lending the PREVIOUS track's provenance to this one. Every
// track edge republishes both (playback_qt.rs:1119-1120 and siblings), so
// it holds in steady state.
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 ""
}
// Audio settings — the button OPENS A FLYOUT (Normalization + Gapless),
// it is not itself a toggle: `PlayerBar.slint:666-706`. Its `active`
// colour does mirror normalization here (`:677
// active: SettingsState.normalization`) — the Small bar deliberately does
// NOT, see its own note.
//
// The state is read from the PUBLISHED document. It used to be shadowed
// locally (`normTouched` / `normLocal`) because settingsJson did not carry
// it, and that is what made normalization impossible to turn off: on a
// fresh launch the fallback read `undefined`, the button drew OFF while
// the backend was ON, and the first click SET IT ON. The document is now
// seeded at shell entry (main.rs enter_shell) and republished after every
// apply (settings_qt::apply_audio), so there is nothing left to shadow.
readonly property bool normalizationOn: settingsDoc.normalization === true
function fmt(secs) {
var m = Math.floor(secs / 60)
var s = Math.floor(secs % 60)
return m + ":" + (s < 10 ? "0" : "") + s
}
// --- Seek clamp (closes PARITY-DEBT #15, QML half) ---------------------
// SeekBar.slint:24-26 / 93-98: while a track is still downloading the seek
// target is LOCKED to the furthest fraction that has arrived, and the
// cursor turns not-allowed over the region that has not. The source is
// NowPlayingState.seekable-max (state.slint:4402), fed by
// playback.rs:5304 `buffer_progress.clamp(0,1)` and published here as
// QbzPlayer.npSeekableMax.
//
// A fully-available track (local, cached, or a finished download) reports
// 1.0, so Math.min() is a no-op and seeking stays completely free — the
// clamp can never fight a local track.
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), which is what the .slint does: the bars fire
// media-action("track", id, "track-info") and AppShell mounts
// TrackInfoModal gated on TrackInfoState.open. Slint gates the trigger on
// NowPlayingState.source == "qobuz" (no DB row / metadata page for local /
// Plex / ephemeral); the Qt port publishes no np_source, so a numeric
// track id stands in for it. TODO(glue): publish np_source.
function openTrackInfo() {
if (!QbzPlayer.npHasTrack)
return
var id = QbzPlayer.npTrackId
if (!/^[0-9]+$/.test(id))
return
// A local/media-server track has a numeric library ROW id (not a Qobuz
// catalog id), so it cannot use the Qobuz Track Info modal — that one
// fed a local id rendered empty. Route it to the Local Media Info modal
// (source/server/format/paths). The (i) button and the song-card title
// both land here, so both are fixed.
var isLocal = false
try {
var q = JSON.parse(QbzQueue.queueJson)
isLocal = !!(q && q.current && q.current.isLocal)
} catch (e) {}
if (isLocal)
QbzLocal.openMediaInfoForTrack(parseInt(id, 10))
else
trackInfo.openFor(id)
}
TrackInfoModal { id: trackInfo }
// --- Cast picker (shell/CastPicker.slint) ------------------------------
// Mounted here for the same reason TrackInfoModal is: it is 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 cast button in the
// right cluster raises it through QbzCast.openPicker(), which is also
// what arms device discovery.
CastPicker { }
// --- Shared bits -------------------------------------------------------
// SongCard / TransportControls / FavToggle were inline components here
// until phase 25; they now live in their own files (shell/SongCard.qml,
// shell/TransportControls.qml, shell/FavToggle.qml) and TransportControls
// is shared with the Small bar.
// ============================ the bar =================================
Column {
anchors.fill: parent
spacing: 0
Item { width: 1; height: 6 }
// Full-width seek bar (times at the ends). In Large the seek arm is
// LEFT-INSET by the docked-cover width so it begins right of the
// dock (AppShell.slint's large-active padding).
Item {
id: seekRow
width: parent.width
height: 32
// FLUIDITY (#660-reporter follow-up): the fill + handle used to
// track the BACKEND-reported npProgress, and a drag called
// QbzPlayer.seek() on every move. So the handle lagged the cursor
// by the seek round-trip and the backend was flooded with seeks —
// "slow and clunky, clicks not exact". Now a press starts a LOCAL
// scrub: the handle/fill follow the cursor instantly and ONE seek
// is committed on release (a plain click commits at the click
// point). `settling` holds the visual at the committed fraction
// until npProgress catches up so it never flashes back. The audio
// path and the seek call itself are unchanged.
property bool scrubbing: false
property bool settling: false
property real scrubFraction: 0
// Live hover position (fraction) for the time tooltip; -1 = none.
property real hoverFraction: -1
readonly property real visualProgress: (scrubbing || settling)
? scrubFraction
: Math.min(Math.max(QbzPlayer.npProgress, 0), 1)
Timer {
id: settleTimeout
interval: 1500
onTriggered: seekRow.settling = false
}
Connections {
target: QbzPlayer
enabled: seekRow.settling
function onNpProgressChanged() {
if (Math.abs(QbzPlayer.npProgress - seekRow.scrubFraction) < 0.02) {
seekRow.settling = false
settleTimeout.stop()
}
}
}
Text {
x: root.dockWidth > 0 ? root.dockWidth + 6 : 6
anchors.verticalCenter: parent.verticalCenter
visible: QbzPlayer.npHasTrack
// While scrubbing, the elapsed readout follows the cursor.
text: (seekRow.scrubbing || seekRow.settling)
? root.fmt(seekRow.scrubFraction * QbzPlayer.npDurationSecs)
: root.fmt(QbzPlayer.npElapsedSecs)
color: theme.textMuted
font.pixelSize: 11
}
Rectangle {
id: seekTrack
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: (root.dockWidth > 0 ? root.dockWidth + 6 : 6) + (QbzPlayer.npHasTrack ? 46 : 0)
anchors.rightMargin: 6 + (QbzPlayer.npHasTrack ? 46 : 0)
height: root.waveformVisible ? 30 : 4
radius: 2
anchors.verticalCenter: parent.verticalCenter
color: root.waveformVisible ? "transparent" : theme.surfaceElevated
SeekWaveformItem {
anchors.fill: parent
visible: root.waveformVisible
values: QbzPlayer.npSeekWaveform
playedProgress: seekRow.visualProgress
cacheProgress: QbzPlayer.npCacheProgress
baseColor: theme.surfaceElevated
cacheColor: Qt.rgba(theme.textMuted.r, theme.textMuted.g,
theme.textMuted.b, 0.35)
playedColor: theme.accent
renderMode: 0
}
// Buffered / cache line — SeekBar.slint:58 paints it
// text-muted @0.35, NOT border-muted: border-muted is an
// alpha-over-surface token (white-based on dark themes,
// black-based on light), so it inverted against the
// surface-elevated rail on light themes. The Small bar
// already used the right pair.
Rectangle {
visible: !root.waveformVisible
width: parent.width * Math.min(Math.max(QbzPlayer.npCacheProgress, 0), 1)
height: parent.height
radius: 2
color: Qt.rgba(theme.textMuted.r, theme.textMuted.g,
theme.textMuted.b, 0.35)
// Alpha in the material, not a container `opacity`: an
// always-on opacity node pins this quad in its own batch,
// and the three seek quads use compatible materials
// (QSGSmoothColorMaterial::compare() returns 0), so folding
// it lets rail + cache + progress merge.
}
Rectangle {
visible: !root.waveformVisible
width: parent.width * seekRow.visualProgress
height: parent.height
radius: 2
color: theme.accent
}
Rectangle {
visible: QbzPlayer.npHasTrack
width: 12
height: 12
radius: 6
color: theme.textPrimary
x: parent.width * seekRow.visualProgress - width / 2
anchors.verticalCenter: parent.verticalCenter
}
// A-B loop: the tinted section + two ticks. Static bindings only
// (no animation), so nothing here touches the repaint pulse.
Rectangle {
visible: QbzPlayer.abState === 2 && QbzPlayer.npDurationSecs > 0
x: parent.width * (QbzPlayer.abStartSecs / Math.max(QbzPlayer.npDurationSecs, 1))
width: Math.max(2, parent.width
* ((QbzPlayer.abEndSecs - QbzPlayer.abStartSecs) / Math.max(QbzPlayer.npDurationSecs, 1)))
height: parent.height
radius: 2
color: Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.28)
}
Repeater {
model: QbzPlayer.abState === 0 ? []
: (QbzPlayer.abState === 1 ? [QbzPlayer.abStartSecs]
: [QbzPlayer.abStartSecs, QbzPlayer.abEndSecs])
Rectangle {
required property int modelData
visible: QbzPlayer.npDurationSecs > 0
width: 2
height: 10
radius: 1
color: theme.accent
x: seekTrack.width * (modelData / Math.max(QbzPlayer.npDurationSecs, 1)) - 1
anchors.verticalCenter: parent.verticalCenter
}
}
}
Text {
anchors.right: parent.right
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
visible: QbzPlayer.npHasTrack
text: "-" + root.fmt(Math.max(0, QbzPlayer.npDurationSecs - QbzPlayer.npElapsedSecs))
color: theme.textMuted
font.pixelSize: 11
}
MouseArea {
anchors.left: seekTrack.left
anchors.right: seekTrack.right
anchors.top: parent.top
anchors.bottom: parent.bottom
// Hover is what keeps mouseX live for the cursor binding
// below (PlayerBar.slint:180 -> SeekBar.slint's TouchArea
// always tracks mouse-x).
hoverEnabled: true
// No-drop cursor over the not-yet-downloaded region
// (SeekBar.slint:93-95). The npHasTrack arm is the Qt bar's
// own idle guard and stays first.
cursorShape: !QbzPlayer.npHasTrack
? Qt.ArrowCursor
: (root.beyondSeekable(mouseX / width) ? Qt.ForbiddenCursor
: Qt.PointingHandCursor)
id: seekArea
// A press starts a LOCAL scrub (the handle follows the cursor
// with zero round-trip lag); the real seek is committed ONCE on
// release — a plain click commits at the click point. seekTarget
// clamps to what has downloaded while streaming (SeekBar.slint:
// 96-99), so a scrub cannot pass the buffered edge.
onPressed: {
if (!QbzPlayer.npHasTrack)
return
seekRow.settling = false
seekRow.scrubbing = true
seekRow.scrubFraction = root.seekTarget(mouseX / width)
}
onPositionChanged: {
if (!QbzPlayer.npHasTrack)
return
if (pressed)
seekRow.scrubFraction = root.seekTarget(mouseX / width)
else
seekRow.hoverFraction = root.clamp01(mouseX / width)
}
onReleased: {
if (!seekRow.scrubbing)
return
seekRow.scrubbing = false
if (QbzPlayer.npHasTrack) {
// Hold the visual at the committed point until the
// backend position catches up, so it never flashes back.
seekRow.settling = true
settleTimeout.restart()
QbzPlayer.seek(seekRow.scrubFraction)
}
}
onCanceled: {
seekRow.scrubbing = false
seekRow.settling = false
}
onExited: seekRow.hoverFraction = -1
}
// Scrub / hover time bubble (best-effort parity with the web
// player). It floats above the rail with a downward caret. It is
// parented to the window OVERLAY, not to the bar: AppShell mounts
// the content frame AFTER the NowPlayingBar, so in ambient mode the
// frame's drop shadow drew OVER a bar-child tooltip (the "noise").
// The overlay is the top-most layer, above that shadow. Opaque fill
// for the same reason (a real blur needs a shader, which draws
// nothing on the software renderer).
Item {
id: seekTip
parent: Overlay.overlay
z: 10000
readonly property real frac: seekRow.scrubbing
? seekRow.scrubFraction
: seekRow.hoverFraction
readonly property color tipFill: theme.surfaceElevated
visible: QbzPlayer.npHasTrack && frac >= 0
&& (seekRow.scrubbing || seekArea.containsMouse)
width: bubble.width
height: bubble.height + caret.height
// Map the handle's top point into overlay coordinates so the
// caret still lands on the rail from the top layer. Re-maps when
// `frac` changes (the bar itself is static at the window foot).
readonly property point anchor: Overlay.overlay
? seekTrack.mapToItem(Overlay.overlay, seekTrack.width * frac, 0)
: Qt.point(0, 0)
x: Math.max(4, Math.min(
(Overlay.overlay ? Overlay.overlay.width : width) - width - 4,
anchor.x - width / 2))
// Caret tip sits right on the rail — no gap (removed per owner).
y: anchor.y - height + 3
Rectangle {
id: bubble
width: tipText.implicitWidth + 14
height: 18
radius: 4
color: seekTip.tipFill
border.width: 1
border.color: theme.borderMuted
Text {
id: tipText
anchors.centerIn: parent
text: root.fmt((seekTip.frac < 0 ? 0 : seekTip.frac) * QbzPlayer.npDurationSecs)
color: theme.textPrimary
font.pixelSize: 11
}
}
// Downward caret: a rotated square whose lower half shows below
// the bubble as the notch. Same fill, no border, so the bubble's
// border reads as one clean edge above it.
Rectangle {
id: caret
width: 9
height: 9
rotation: 45
color: seekTip.tipFill
anchors.horizontalCenter: bubble.horizontalCenter
y: bubble.height - height / 2
}
}
}
Item { width: 1; height: 2 }
// --- Controls: the responsive symmetric zones -----------------------
Item {
id: controlsLayout
width: parent.width
height: parent.height - 40
// The three zones keep PLAY centred and the right cluster pinned,
// but the SongCard is sized against the controls themselves, not
// against an arbitrary zone edge. This lets metadata consume the
// centre column's empty runway without ever crossing transport.
readonly property real transportLeft:
centreZone.x + centreTransport.x
readonly property real classicTransportRight: Math.min(
leftZone.x + leftZone.width,
leftZone.x + classicTransport.x + classicTransport.width)
readonly property real rightControlsLeft:
rightZone.x + Math.max(0, rightControls.x)
// LEFT column.
Item {
id: leftZone
anchors.left: parent.left
anchors.leftMargin: 6
width: (parent.width - 12) * root.colSide
height: parent.height
// New (0) AND Large (3): the song card (Large drops the cover
// — it lives in the dock — and shifts right to clear it). Its
// right edge follows the ACTUAL transport bounds; subtracting
// the 240px dock from this zone was the old Large-mode bug that
// left long titles only ~260px on a 1700px window.
SongCard {
visible: !root.isClassic
x: root.largeActive ? root.dockWidth + 8 : 0
readonly property real availableWidth: Math.max(0,
controlsLayout.transportLeft - 10 - leftZone.x - x)
width: availableWidth * theme.npbSongCardMaxFraction
anchors.verticalCenter: parent.verticalCenter
showArt: !root.largeActive
showBadges: !root.largeActive
tooltip: root.tooltip
onTrackInfoRequested: root.openTrackInfo()
}
// Classic: transport cluster hugging the left edge (plain
// play glyph + inline favorite, the Tauri arrangement).
TransportControls {
id: classicTransport
visible: root.isClassic
compact: root.width < 1100
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
playCircle: false
classicActions: true
favorite: root.npFavorite
ephemeral: root.npEphemeral
tooltip: root.tooltip
onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) }
onTrackInfoRequested: root.openTrackInfo()
}
}
// CENTRE column.
Item {
id: centreZone
x: 6 + (parent.width - 12) * root.colSide
width: (parent.width - 12) * root.colCentre
height: parent.height
clip: true
// New (0) AND Large (3): centred transport, PLAY on the
// window centre.
TransportControls {
id: centreTransport
visible: !root.isClassic
compact: root.width < 1366
anchors.centerIn: parent
playCircle: true
favorite: root.npFavorite
tooltip: root.tooltip
onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) }
onTrackInfoRequested: root.openTrackInfo()
}
}
// RIGHT column — secondary actions + volume, clustered at the
// right wall.
Item {
id: rightZone
anchors.right: parent.right
anchors.rightMargin: 6
width: (parent.width - 12) * root.colSide
height: parent.height
clip: true
Row {
id: rightControls
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 1
// Large: the inline 2-row AudioStamp just LEFT of the icon
// cluster at a fixed 12px gap (the badges move OUT of the
// song card in Large). PlayerBar.slint mounts it with
// max-width: 140px.
AudioStamp {
visible: root.largeActive && QbzPlayer.npHasTrack
maxWidth: 140
tooltipHost: root.tooltip
anchors.verticalCenter: parent.verticalCenter
}
Item { visible: root.largeActive; width: 12; height: 1 }
// Qobuz Connect — the Slint ConnectButton (PlayerBar.
// slint:32-77): monitor-speaker glyph, GOLDEN over a soft
// golden tint whenever a session is on — whether qbz is
// the renderer or controlling a peer (not just is-remote).
// Opens the shared flyout (QconnectFlyout.qml). This is
// NOT a QbzIconButton: the reference's active state is a
// gold tint + 1px border + gold glyph, which the shared
// button's accent-glyph `active` cannot express. The
// "amber" tint IS the reference's gold (#e0b341).
//
// FIRST of the icon cluster, ahead of Cast — the order the
// reference lays out (PlayerBar.slint:395-401 Connect,
// :646-653 Cast) and the one the Small bar already used.
Rectangle {
id: qconnectBtn
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)
: (qcArea.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: qconnectBtn.qcActive ? "amber"
: qcArea.containsMouse ? "textPrimary" : "secondary"
}
MouseArea {
id: qcArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.tooltip)
root.tooltip.hide("qconnect")
qcFlyout.openAboveRight(qconnectBtn)
}
// The ONE bar tooltip the reference binds (always
// shows on hover, even while connected — Slint
// PlayerBar.slint:64-74 says why out loud).
onContainsMouseChanged: {
if (!root.tooltip)
return
if (containsMouse)
root.tooltip.showAbove(qconnectBtn, "qconnect",
QbzQConnect.qconnectConnected
? QbzSession.tr("Qobuz Connect: On", QbzSession.trRev)
: QbzSession.tr("Qobuz Connect: Off", QbzSession.trRev))
else
root.tooltip.hide("qconnect")
}
}
}
// Cast (Chromecast / DLNA) — SECOND, straight after
// Connect. Opens the picker modal and, with it, discovery
// (PlayerBar.slint:646-664: picker-open = true +
// CastActions.open()); lit while a renderer is connected.
QbzIconButton {
name: "cast"
active: QbzPlayer.npCastActive
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzCast.openPicker()
}
// Lyrics.
QbzIconButton {
name: "mic-vocal"
active: QbzShell.lyricsOpen
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzShell.toggleLyrics()
}
// Now-Playing view — the mode flyout (phase 18). Tauri's
// Miniplayer + Full screen buttons live inside it.
QbzIconButton {
id: viewBtn
name: "layout-grid"
active: viewMenu.opened
anchors.verticalCenter: parent.verticalCenter
onClicked: viewMenu.openBelowRight(viewBtn)
}
// Audio settings — opens the Normalization + Gapless
// flyout (PlayerBar.slint:666-706). NOT a toggle.
QbzIconButton {
id: audioBtn
name: "settings-2"
active: root.normalizationOn
anchors.verticalCenter: parent.verticalCenter
onClicked: audioMenu.openBelowRight(audioBtn)
}
Item { width: 6; height: 1 }
// Volume: mute · slider · −/+ steppers (the steppers are
// the Tauri volume-step buttons, gated by the appearance
// preference like the Slint bar). All four controls gate
// on root.volLocked (§11.3 — the local ALSA-hw lock lifts
// under a peer; a volume-disallowing peer locks).
QbzIconButton {
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
btnEnabled: !root.volLocked
active: QbzPlayer.npMuted
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.toggleMute()
}
QbzSlider {
enabled: !root.volLocked
width: 81
anchors.verticalCenter: parent.verticalCenter
minimum: 0
// 0..1000: whole steps on a 0..100 scale quantize the
// volume to 1%; 0.1% steps drag fluidly.
maximum: 1000
value: Math.round(QbzPlayer.npVolume * 1000)
onChanged: function (v) { QbzPlayer.setVolume(v / 1000.0) }
// Persist only the settled value (PlayerBar.slint:864-866).
onReleased: function (v) { QbzPlayer.persistVolume(v / 1000.0) }
}
QbzIconButton {
visible: root.showVolumeSteppers
name: "minus"
iconSize: 15
anchors.verticalCenter: parent.verticalCenter
btnEnabled: !root.volLocked
onClicked: QbzPlayer.setVolume(Math.max(0.0, QbzPlayer.npVolume - 0.05))
}
QbzIconButton {
visible: root.showVolumeSteppers
name: "plus"
iconSize: 15
anchors.verticalCenter: parent.verticalCenter
btnEnabled: !root.volLocked
onClicked: QbzPlayer.setVolume(Math.min(1.0, QbzPlayer.npVolume + 0.05))
}
Item { width: 6; height: 1 }
// Queue panel toggle.
QbzIconButton {
name: "list-ordered"
active: QbzShell.queueOpen
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzShell.toggleQueue()
}
}
}
// Classic lives between the REAL left transport and right action
// cluster. It may use at most 80% of that live gap, centred: the
// remaining 10% on each side keeps both the cover edge and the
// in-card AudioStamp visibly detached from their neighbour controls.
// Unlike the old fixed 560px cap this still scales with volume
// steppers, remote state and the actual window width.
SongCard {
visible: root.isClassic
glass: true
readonly property real availableWidth: Math.max(0,
controlsLayout.rightControlsLeft
- controlsLayout.classicTransportRight)
width: availableWidth * theme.npbSongCardMaxFraction
x: controlsLayout.classicTransportRight
+ (availableWidth - width) / 2
anchors.verticalCenter: parent.verticalCenter
tooltip: root.tooltip
onTrackInfoRequested: root.openTrackInfo()
}
}
Item { width: 1; height: 6 }
}
// The Now-Playing-view mode menu (shared with the Small bar).
ViewModeMenu { id: viewMenu }
// Audio settings flyout — Normalization + Gapless (PlayerBar.slint's
// `audio-menu` PopupWindow). Fed the already-parsed document so it does
// not re-parse settingsJson on every open.
AudioSettingsMenu { id: audioMenu; doc: root.settingsDoc }
// Qobuz Connect device flyout — the ONE shared component both bars mount
// (contract §8; the Slint `qconnect-menu` PopupWindow, PlayerBar.slint:
// 412-642). Opened above-right of the Connect button.
QconnectFlyout { id: qcFlyout }
// "Add to…" flyout behind the transport "+" (TransportControls.slint's
// add-menu), on the shared CardMenu surface. Same seven entries, same
// order, same icons.
// The "+" button that opened the add-menu: the Copy submenu reopens on it.
property Item addAnchor: null
QbzClipboard { id: npClipboard }
CardMenu {
id: npCopyMenu
menuWidth: 232
entries: [
{ "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" },
{ "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" }
]
onPicked: function (a) {
var parts = [QbzPlayer.npTitle || ""]
if (a === "copy-full") {
if ((QbzPlayer.npAlbum || "") !== "") parts.push(QbzPlayer.npAlbum)
if ((QbzPlayer.npArtist || "") !== "") parts.push(QbzPlayer.npArtist)
}
npClipboard.copy(parts.join(" - "))
}
}
CardMenu {
id: addMenu
menuWidth: 232
entries: {
// The three QUEUE actions are the only ones an ephemeral track can
// honour: everything else here writes a reference that outlives the
// session, and there is nothing to reference once the disc comes
// out. They are ABSENT rather than rendered-and-inert, the same
// rule the `npSource` gates below already follow.
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" sits SECOND in TransportControls.slint:143 —
// spliced in there rather than appended, because the flyout's
// order is part of the parity. It rides the SAME `npSource` gate
// as "Add to mixtape" and for the same reason: the picker's Qobuz
// arm takes catalog ids, and an `isLocal` current row is any of
// four different id spaces this bar cannot tell apart (see
// `npSource`). Unknown provenance -> the entry is ABSENT, never
// rendered-and-inert.
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`): a row we
// cannot address is not offered.
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"
})
}
// "Buy on Qobuz" (2026-09-13): catalog tracks only; store_qt
// resolves whether the release is sold and toasts when not.
if (root.npSource === "qobuz") {
m.push({
"label": QbzSession.tr("Buy on Qobuz", QbzSession.trRev),
"icon": "shopping-bag",
"action": "buy"
})
}
// Copy (2026-09-13): a submenu — the track name, or
// "Track - Album - Artist".
m.push({
"label": QbzSession.tr("Copy", QbzSession.trRev),
"icon": "copy",
"action": "copy",
"submenu": true
})
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") {
// The entry only exists 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 === "buy") {
if (id !== "") QbzAlbum.buyTrack(id)
} else if (a === "copy") {
if (root.addAnchor) npCopyMenu.openBelowRight(root.addAnchor)
} 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.
// The source comes from the queue's current ROW (`npSource`),
// never from a literal; the entry is not even in the menu 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
}]))
}
}
}
}