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
1049 lines
51 KiB
QML
1049 lines
51 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.
|
||
// 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
|
||
}]))
|
||
}
|
||
}
|
||
}
|
||
}
|