Analyze audible tracks into a progressive 512-bin RMS document, cache complete results, and preserve gapless track boundaries. Render the waveform through a scenegraph item in both player bars behind a default-off preference.\n\ncx
804 lines
40 KiB
QML
804 lines
40 KiB
QML
// 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.
|
||
// Inert (TODO comments at the call sites): add-to-playlist. add-to-mixtape
|
||
// is LIVE since the MyQBZ domain landed (QbzMyQbzAdd.open).
|
||
//
|
||
// 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
|
||
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
||
readonly property bool ambientOn: theme.ambientOn
|
||
readonly property bool largeActive: QbzShell.npbMode === 3 && QbzShell.sidebarState === 0
|
||
readonly property bool isClassic: QbzShell.npbMode === 1
|
||
readonly property bool waveformVisible: QbzShell.seekbarWaveform
|
||
&& QbzPlayer.npSeekWaveformAnalyzed > 0
|
||
&& QbzPlayer.npSeekWaveform.length > 0
|
||
|
||
// The shell's shared hover-tooltip overlay (controls/QbzTooltip.qml),
|
||
// fed in through NowPlayingBar.qml's Binding. Consumed by the Qobuz
|
||
// Connect button's "Qobuz Connect: On/Off" bubble — the ONE tooltip the
|
||
// reference binds on this bar (PlayerBar.slint:405-407; the small bar's
|
||
// button has none, contract §8).
|
||
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: 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
|
||
|
||
// 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
|
||
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 {
|
||
width: parent.width
|
||
height: 20
|
||
Text {
|
||
x: root.dockWidth > 0 ? root.dockWidth + 6 : 6
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
visible: QbzPlayer.npHasTrack
|
||
text: 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 ? 14 : 4
|
||
radius: 2
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
color: root.waveformVisible ? "transparent" : theme.surfaceElevated
|
||
SeekWaveformItem {
|
||
anchors.fill: parent
|
||
visible: root.waveformVisible
|
||
values: QbzPlayer.npSeekWaveform
|
||
playedProgress: QbzPlayer.npProgress
|
||
cacheProgress: QbzPlayer.npCacheProgress
|
||
baseColor: theme.surfaceElevated
|
||
cacheColor: Qt.rgba(theme.textMuted.r, theme.textMuted.g,
|
||
theme.textMuted.b, 0.35)
|
||
playedColor: theme.accent
|
||
}
|
||
// 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 * Math.min(Math.max(QbzPlayer.npProgress, 0), 1)
|
||
height: parent.height
|
||
radius: 2
|
||
color: theme.accent
|
||
}
|
||
Rectangle {
|
||
visible: QbzPlayer.npHasTrack
|
||
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
|
||
}
|
||
}
|
||
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)
|
||
// Lock the seek target to what has downloaded while streaming
|
||
// (SeekBar.slint:96-99).
|
||
onPressed: if (QbzPlayer.npHasTrack) QbzPlayer.seek(root.seekTarget(mouseX / width))
|
||
onPositionChanged: if (pressed && QbzPlayer.npHasTrack) QbzPlayer.seek(root.seekTarget(mouseX / width))
|
||
}
|
||
}
|
||
|
||
Item { width: 1; height: 6 }
|
||
|
||
// --- Controls: the responsive symmetric zones -----------------------
|
||
Item {
|
||
id: controlsLayout
|
||
width: parent.width
|
||
height: parent.height - 38
|
||
|
||
// 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
|
||
onTrackInfoRequested: root.openTrackInfo()
|
||
}
|
||
// Classic: transport cluster hugging the left edge (plain
|
||
// play glyph + inline favorite, the Tauri arrangement).
|
||
TransportControls {
|
||
id: classicTransport
|
||
visible: root.isClassic
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
playCircle: false
|
||
classicActions: true
|
||
favorite: root.npFavorite
|
||
ephemeral: root.npEphemeral
|
||
onAddRequested: function (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
|
||
anchors.centerIn: parent
|
||
playCircle: true
|
||
favorite: root.npFavorite
|
||
onAddRequested: function (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
|
||
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.openBelowRight(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
|
||
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 below-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.
|
||
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
|
||
// "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"
|
||
})
|
||
}
|
||
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") {
|
||
// 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 === "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
|
||
}]))
|
||
}
|
||
}
|
||
}
|
||
}
|