The Slint reference is 3,885 lines of .slint across ten files plus 5,514 lines
of Rust glue over the shared qbz-mixtape crate. None of it existed in the Qt
port; the nav flyout carried a "GAP:" comment and two disabled entries.
Rust (flat in src/, per QTBUG-93443): three bridges rather than one God-object
— QbzMyQbz (grids + detail + edit + cover + mix), QbzMyQbzAdd (the app-wide
picker, kept apart so TrackRow, PlayerBar and the Local Library bulk bars
cannot couple to detail state) and QbzDisco (artist-scoped, reached from the
artist page, never coexisting with the MyQBZ views). Twelve controllers behind
them, ported function by function from myqbz{,_detail,_play,_edit,_cover,_mix,
_add,_prefs,_view_prefs,_builder}.rs. src/toast_qt.rs finally gives the port a
toast host.
QML in qml/views/myqbz/ plus four global overlays. Reused rather than forked:
TrackRow, TrackListHeader, QualityBadgeFull, QbzContextMenu, CardMenu,
QbzCheckbox, QbzSkeleton, QbzScrollBar, RoundedImage. LocalSegToggle and
LocalMultiSelectBar are PROMOTED to controls/QbzSegToggle and
controls/QbzMultiSelectBar (they were ports of shared Slint primitives that
MyQBZ needs too) — a move plus an import fix, with the Local metrics kept as
the defaults so the four Local Library toggles are byte-unchanged and the MyQBZ
call sites pass ViewToggle's own numbers.
Every list is windowed at write time: GridView/ListView with reuseItems and a
cacheBuffer, and expanded mode is an outer recycled ListView whose delegate is
a Column + Repeater, because a nested ListView inside a recycled delegate
cannot size itself. Single-row updates patch in place; replacing the array
resets contentY and throws the user to the top.
Shared controls gained the arms the new views need, with the numbers taken from
the .slint and not guessed: SettingsButton (btnHeight / minWidth /
trailingIconName), QbzEmptyState's four metrics, QbzSelect's `sm` variant
(30px, r6, no outline, 12px label, 14px chevron, 40px narrower —
QbzSelect.slint:74-148), QbzContextMenu.openBelowLeft, and QbzLineEdit gains a
distinct `accepted` signal fired on ENTER ONLY: a modal that submits on
`committed` submits TWICE, because closing it removes focus and fires
`committed` again — a rename would have run twice and a create made two
collections. clearSearch() no longer assigns root.text either; that JS write
destroyed the call site's binding permanently, so Rust could never re-seed the
field after one click on the X.
SourceIcon gained the plain "qobuz" arm it was missing, which had every Qobuz
row in a collection drawing itself as a local file.
Two icons (turntable, image) are baked from the Slint masters into the seven
full tint dirs and registered in MASTERS; three more (circle-check-big,
circle-alert, triangle-alert) back the toast. A missing tint dir or glyph
renders nothing and logs nothing, so this class never surfaces in a build.
cc
735 lines
35 KiB
QML
735 lines
35 KiB
QML
// TrackRow — THE track list row (primitives/TrackRow.slint, POC arm
|
|
// subset), consolidated in phase 22 from FOUR copies (AlbumView
|
|
// .AlbumTrackRow, PlaylistView.PlTrackRow, LibraryView.TrackListRow,
|
|
// SearchView.SearchTrackRow). 50px, radius 8: number→play cell (pause
|
|
// swap + accent pill when it is the now-playing row — Slint-universal),
|
|
// optional 36px art cell, title+explicit / artist column, optional 220px
|
|
// album link, duration, quality (BARE QualityBadgeFull — the tier label over
|
|
// the exact bit-depth / sample-rate line), optional
|
|
// heart, optional download slot (offline column — glyph stub or reserved
|
|
// spacer until the offline cache lands, POC-NOTE), ⋯ CardMenu.
|
|
//
|
|
// item contract: { id, title, artist, artistId, album, albumId, number?,
|
|
// duration, qualityTier, qualityDetail, explicit, isFavorite, artPath? }
|
|
// (plus playlistTrackId for the remove-from-playlist arm). `qualityDetail`
|
|
// is the bare exact-quality string ("16-bit / 44.1 kHz"); when a producer
|
|
// does not carry it yet the badge degrades to the tier label alone rather
|
|
// than to a blank cell. `item.isFavorite` SEEDS the row's own `favorite`
|
|
// property and is never written back — see the heart block below for why a
|
|
// plain-JS-object field cannot be the live state.
|
|
//
|
|
// Arms: showArtwork / showAlbum / showFavorite / showDownload /
|
|
// downloadGlyph / showMenu / zebra / artistLink / clickPlays /
|
|
// menuShowLater / menuShowGoTo / menuShowFavorite / menuShowRemove.
|
|
// (`qualityStyle` is RETIRED — kept declared, inert, see below.)
|
|
// Signals: playRequested() (per-site play: album-scoped, playlist-scoped
|
|
// or plain), enqueueRequested(mode) ("next"|"later"|"queue"),
|
|
// removeRequested(), bodyDragStarted(index) (fired BEFORE the shared
|
|
// dragStart — the #589 reorder pre-hook), mixtapeRequested() (the host
|
|
// builds the MyQBZ AddItem payload), goToRequested(kind) (only when
|
|
// `routeGoToExternally` is set).
|
|
// Favorite toggling, Go-to-artist/album, Share and Track info are
|
|
// identical on every site — handled internally. The row BODY is the drag
|
|
// source (6px threshold, ghost + sidebar drops in main.rs) and its RIGHT
|
|
// press opens the very same menu the ⋯ button does.
|
|
//
|
|
// --- Menu inventory vs primitives/TrackContextMenu.slint ----------------
|
|
// The .slint menu is FLAT (no separators) in this order: Play now · Play
|
|
// next · Play later · Add to queue · Create QBZ radio · Create Qobuz radio ·
|
|
// Add to library · Add to mixtape · Add to playlist · Remove from
|
|
// playlist(danger) · Share Qobuz link · Share Song.link · Make available
|
|
// offline | Refresh + Remove offline copy(danger) · Go to album · Go to
|
|
// artist · Track info. Everything the Qt bridge has a seam for is here, in
|
|
// that order; the rest is gated OFF by the `has*Seam` constants below
|
|
// rather than rendered as a row that silently does nothing.
|
|
//
|
|
// Deliberately NOT consolidated here (Slint-distinct): QueuePanel.QueueRow
|
|
// (QueueItem data + queue-op menu + press-and-hold reorder) and
|
|
// LibraryView.FeedListRow (mixed-kind feed row — inline in Slint too).
|
|
//
|
|
// --- COLUMN GEOMETRY LIVES IN rows/TrackCols.qml ------------------------
|
|
// Not in this file. rows/TrackListHeader.qml reads the same object, so the
|
|
// header of a list and the rows in it cannot disagree. If you change a
|
|
// column width, change it THERE — a literal typed back into this file is
|
|
// the exact defect the owner reported (the header laid out only the
|
|
// labelled columns, so every label sat right of the column it named).
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../shell"
|
|
import "../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
|
|
property var item: ({})
|
|
property int number: 0
|
|
property bool showArtwork: false
|
|
property bool showAlbum: false
|
|
property bool showFavorite: true
|
|
property bool showDownload: false
|
|
property bool downloadGlyph: false
|
|
property bool showMenu: true
|
|
property bool zebra: false
|
|
property bool artistLink: false
|
|
property bool clickPlays: true
|
|
/// RETIRED and INERT. primitives/TrackRow.slint has exactly ONE quality
|
|
/// form (the bare QualityBadgeFull below) — there was never an icon/text
|
|
/// split to arm. Kept declared ONLY so the two existing call sites that
|
|
/// still assign it (AlbumView.qml `"text"`, local/LocalTrackRow.qml
|
|
/// `"icon"`) keep loading; assigning a non-existent property is a hard
|
|
/// QML instantiation error. Both assignments are listed for removal.
|
|
property string qualityStyle: "icon"
|
|
property bool menuShowLater: true
|
|
property bool menuShowGoTo: true
|
|
property bool menuShowFavorite: true
|
|
property bool menuShowRemove: false
|
|
// Drag source arm (additive; default = the existing behaviour). The
|
|
// Local Library rows set this false: their `item.id` is a local DB row
|
|
// id, and the sidebar drop handler forwards whatever it receives to
|
|
// `playlist add-tracks` as a QOBUZ catalog id — a local row dropped on a
|
|
// playlist would silently add an unrelated catalog track.
|
|
property bool draggable: true
|
|
// Per-row artwork placeholder (see the 36px cell below). The host view
|
|
// owns the phase clock so one timer drives every row.
|
|
property bool artPending: false
|
|
property bool skelPhase: false
|
|
property int artSettleMs: 0
|
|
|
|
// Catalog-backed row? `draggable` is ALREADY the "item.id is a Qobuz
|
|
// catalog track id" predicate (the Local Library / ephemeral rows set it
|
|
// false because their id is a local DB row id — see the note above), so
|
|
// the two entries that hit the Qobuz catalog by id ride it instead of
|
|
// asking every host to pass a new arm.
|
|
readonly property bool catalogRow: root.draggable
|
|
property bool menuShowShare: root.catalogRow
|
|
property bool menuShowTrackInfo: root.catalogRow
|
|
|
|
// --- Seams the Qt bridge does NOT have yet (menu-parity round) -------
|
|
// Each maps to a TrackContextMenu.slint entry. OFF = the row is not
|
|
// built at all, never rendered-and-inert. Flip the constant AND fill the
|
|
// matching `menuAction` branch when the invokable lands; the entry then
|
|
// appears in its .slint-correct slot. Why each is missing:
|
|
// radio no radio invokable on any Qt bridge object
|
|
// playlistAdd no picker modal, no by-id add (only the sidebar drag)
|
|
// songlink needs the ISRC -> Deezer -> Odesli round-trip (backend)
|
|
// offlineCache no per-track cache bridge (the download cell is a stub)
|
|
readonly property bool hasRadioSeam: false
|
|
readonly property bool hasPlaylistAddSeam: false
|
|
readonly property bool hasSonglinkSeam: false
|
|
readonly property bool hasOfflineCacheSeam: false
|
|
// LIVE since the MyQBZ domain landed: QbzMyQbzAdd.open() takes a JSON
|
|
// array of AddItem, so the row only has to ASK — it does not know its own
|
|
// itemType/source, which is why the payload is built by the host (see
|
|
// `mixtapeRequested` below). Not `readonly`: a host whose ids are not
|
|
// Qobuz/local mixtape-addressable can turn it back off.
|
|
property bool hasMixtapeSeam: true
|
|
|
|
signal playRequested()
|
|
signal enqueueRequested(string mode)
|
|
signal removeRequested()
|
|
signal bodyDragStarted(int index)
|
|
/// "Add to mixtape" — the HOST answers, because only the host knows the
|
|
/// row's `itemType` / `source` (a MyQBZ AddItem needs both) and building
|
|
/// the array here would hardcode `("track", "qobuz")` for every surface.
|
|
signal mixtapeRequested()
|
|
|
|
// --- Go-to routing: internal by default, host-routed on request -------
|
|
// The go-to actions (menu AND the artist-column link) navigate through
|
|
// QbzArtist.openArtist / QbzAlbum.openAlbum here, which is right on every
|
|
// catalog surface. It is WRONG where the row is a child of another entity
|
|
// whose host must resolve the target — MyQBZ's expanded detail rows, where
|
|
// Slint routes both paths through the SAME shared handler
|
|
// (`media-action("artist", id, "open")`, TrackRow.slint:530). Flipping
|
|
// `routeGoToExternally` hands both sites to the host as
|
|
// `goToRequested(kind)`; it defaults false, so no existing call site moves.
|
|
property bool routeGoToExternally: false
|
|
signal goToRequested(string kind) // "artist" | "album"
|
|
|
|
// --- THE heart state (a real QML property, never `item.isFavorite`) ---
|
|
// The row used to flip its glyph with `root.item.isFavorite = !…`. That is
|
|
// a write into a plain JS object and it does nothing at all here, twice
|
|
// over — both halves measured under qml6 6.11.1 in this exact delegate
|
|
// shape (`Repeater { model: JSON.parse(...) }` + `required property var
|
|
// modelData` + `item: modelData`):
|
|
//
|
|
// 1. QML does not re-evaluate a binding when a plain JS object's
|
|
// property is mutated — there is no notifier to fire. The glyph
|
|
// binding kept its old value on the same tick AND on the next.
|
|
// 2. `item: modelData` does not even alias the row: assigning a `var`
|
|
// property from `modelData` yields an object that compares `!==` to
|
|
// both `modelData` and the source array element (Qt round-trips it
|
|
// through QVariant). So the write did not reach the model either —
|
|
// not the delegate's `modelData`, not the host's array.
|
|
//
|
|
// A DECLARED property has a notifier, so the optimistic flip repaints
|
|
// immediately. The binding below keeps the document authoritative: it is
|
|
// re-established whenever the host hands over a new row object, so a
|
|
// republished document (or a page re-open) overrides a stale local flip.
|
|
property bool favorite: root.item.isFavorite === true
|
|
onItemChanged: root.favorite = Qt.binding(function () {
|
|
return root.item.isFavorite === true
|
|
})
|
|
|
|
/// The heart click and the menu's "Add to / Remove from Library" row —
|
|
/// ONE implementation, because they used to diverge silently.
|
|
function toggleFavorite() {
|
|
root.favorite = !root.favorite
|
|
QbzLibrary.libraryToggleFavorite("track", root.item.id)
|
|
}
|
|
|
|
// Settle. `libraryFavoriteChanged` (key `{kind}:{id}`, emitted by
|
|
// main.rs::emit_library_favorite) carries the value the WRITE produced:
|
|
// the flipped one when it landed, the UNCHANGED one when it failed. This
|
|
// is the favourite twin of the pin fan-out the cards already have
|
|
// (cards/AlbumCard.qml) and it is what rolls a 404'd un-favorite back —
|
|
// and what keeps the OTHER surfaces showing this same track honest
|
|
// (the album page's row, the queue, a search result) without anybody
|
|
// republishing a document.
|
|
Connections {
|
|
target: QbzLibrary
|
|
function onLibraryFavoriteChanged(key, value) {
|
|
var tid = (root.item && root.item.id !== undefined) ? root.item.id : ""
|
|
if (tid !== "" && key === "track:" + tid)
|
|
root.favorite = value
|
|
}
|
|
}
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
// --- Polarity-baked tokens for the play cell (#638) -------------------
|
|
// `theme.alphaTier(pct)` IS Slint's `Theme.alpha-N`: one ramp per theme,
|
|
// white-based on dark, black-based on light. The `.length` guard covers
|
|
// the pre-publish frame QbzTheme documents (its baked fallback carries no
|
|
// ramp) — without it the disc would render fill-less AND ring-less, which
|
|
// is the exact failure this block exists to prevent. The literals are the
|
|
// Slint dark values and their light-polarity mirrors.
|
|
readonly property color playCellFill: theme.alphaTiers.length > 0
|
|
? theme.alphaTier(10) : (theme.isDark ? "#1affffff" : "#1a000000")
|
|
readonly property color playCellRing: theme.alphaTiers.length > 0
|
|
? theme.alphaTier(15) : (theme.isDark ? "#26ffffff" : "#26000000")
|
|
/// Slint's `Theme.text-primary` icon tint, runtime-tinted (QbzIcon.qml /
|
|
/// src/icon_tint_qt.rs) so it is the live token under all 36 themes. Every
|
|
/// host it lands on is a THEME surface — the play cell's polarity-baked
|
|
/// alpha disc, and `surface-elevated` for the row's trailing controls.
|
|
readonly property string playGlyphTint: "textPrimary"
|
|
/// TrackRow.slint:123-125 — the row hover is `Theme.alpha-8`, not
|
|
/// surface-hover, and the .slint says why: "Hover uses the polarity-baked
|
|
/// alpha ramp so the hover state is visible on light themes too". The
|
|
/// zebra stripe stays the literal, per the same comment ("Zebra kept
|
|
/// as-is to avoid a visible dark-theme stripe shift").
|
|
readonly property color rowHoverBg: theme.alphaTiers.length > 0
|
|
? theme.alphaTier(8) : (theme.isDark ? "#14ffffff" : "#14000000")
|
|
|
|
width: parent ? parent.width : 0
|
|
height: 50
|
|
radius: 8
|
|
color: hovered ? rowHoverBg : (zebra && number % 2 === 0 ? "#07ffffff" : "transparent")
|
|
|
|
// `playArea` is in here for the reason TrackPlayCell.slint:76-79 spells
|
|
// out: the cell owns a hover-enabled area of its own, and "Slint's
|
|
// has-hover does not propagate to ancestor TouchAreas" — Qt's does not
|
|
// either, so without this the row (and the play circle with it) would go
|
|
// un-hovered exactly while the pointer sits on the play button.
|
|
readonly property bool hovered: trArea.containsMouse || favArea.containsMouse
|
|
|| moreArea.containsMouse || playArea.containsMouse
|
|
readonly property bool isActive: QbzPlayer.npTrackId === (item.id || "")
|
|
|
|
// --- Column geometry: rows/TrackCols.qml, NOT literals ---------------
|
|
// Every width, the padding and the gap come from the one file that
|
|
// rows/TrackListHeader.qml also reads, so the header above a list of
|
|
// these rows lines up with them by construction. The title column's
|
|
// arithmetic lives there too (`titleWidth`) — the header asks the same
|
|
// function the same question and gets the same answer.
|
|
TrackCols { id: cols }
|
|
readonly property int titleColWidth: cols.titleWidth(root.width,
|
|
root.showArtwork, root.showAlbum, root.showFavorite,
|
|
root.showDownload, root.showMenu)
|
|
|
|
// Static now-playing mark: 3px accent pill on the left edge.
|
|
Rectangle {
|
|
visible: root.isActive
|
|
x: 2
|
|
y: 7
|
|
width: 3
|
|
height: parent.height - 14
|
|
radius: 1.5
|
|
color: theme.accent
|
|
}
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: cols.padH
|
|
anchors.rightMargin: cols.padH
|
|
spacing: cols.gap
|
|
|
|
// Number cell — the number-variant of primitives/TrackPlayCell.slint
|
|
// (:172-232), the play affordance shared by every track row.
|
|
//
|
|
// --- LIGHT-THEME LEGIBILITY (Slint 2.0.2 / #638, "Track-row play
|
|
// --- glyph … legible on light themes") ---------------------------
|
|
// TrackPlayCell.slint:201-207 states the defect and the fix in its
|
|
// own words: "Circular backing so the play/pause glyph is legible on
|
|
// EVERY theme (the old bare #ffffff glyph vanished on light themes —
|
|
// white-on-white, no scrim like the artwork variant has). Fill/border/
|
|
// shadow use the polarity-baked alpha ramp (black-alpha on light,
|
|
// white-alpha on dark) and the glyph uses text-primary."
|
|
//
|
|
// The port had the pre-fix shape: a hardcoded `#3dffffff` disc, no
|
|
// ring, and the "primary" icon bake — which is a literal #ffffff, not
|
|
// text-primary. Both halves are now theme-correct: theme.alphaTier()
|
|
// is white-based on dark themes and black-based on light (qbz-theme
|
|
// colors.rs:125-129, republished per theme by theme_qt.rs), and
|
|
// `playGlyphTint` is the real text-primary token, runtime-tinted.
|
|
//
|
|
// Numbers off the .slint: 24px disc, 1.5px ring ("a 1px stroked circle
|
|
// aliases badly in femtovg", :215), 16px glyph.
|
|
// The accent-ring arm (:208-229) is the playing row: transparent fill,
|
|
// accent circumference, accent glyph.
|
|
// POC-NOTE: the .slint also drops a 6px `card-shadow` under the disc.
|
|
// Qt's DropShadow is a Qt5Compat graphical effect and renders NOTHING
|
|
// on this port's software path (the finding that killed ColorOverlay
|
|
// in QbzIcon), so the shadow is dropped rather than faked; the ring
|
|
// carries the separation on its own.
|
|
Item {
|
|
id: playCell
|
|
width: cols.colNumber
|
|
height: 40
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
// `show-overlay: row-hovered || is-active` (TrackPlayCell.slint
|
|
// :93) — the playing row keeps the affordance at rest, which is
|
|
// what the accent ring is FOR. The port showed it on hover only,
|
|
// so the ring never appeared.
|
|
readonly property bool showOverlay: root.hovered || root.isActive
|
|
// `accent-ring` (:108): the playing row, static form.
|
|
readonly property bool accentRing: root.isActive && QbzPlayer.npPlaying
|
|
Text {
|
|
visible: !playCell.showOverlay
|
|
anchors.centerIn: parent
|
|
text: root.number
|
|
color: theme.textMuted
|
|
font.pixelSize: 13
|
|
}
|
|
Rectangle {
|
|
visible: playCell.showOverlay
|
|
anchors.centerIn: parent
|
|
width: 24
|
|
height: 24
|
|
radius: 12
|
|
color: playCell.accentRing ? "transparent" : root.playCellFill
|
|
border.width: 1.5
|
|
border.color: playCell.accentRing ? theme.accent : root.playCellRing
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
// `show-pause` (:101-104): PAUSE only while the playing
|
|
// row is hovered — at rest the playing row shows PLAY,
|
|
// "an affordance, not a state badge: the accent ring +
|
|
// the row's edge mark carry the state".
|
|
name: playCell.accentRing && root.hovered ? "pause" : "play-fill"
|
|
width: 16
|
|
height: 16
|
|
tintName: playCell.accentRing ? "accent" : root.playGlyphTint
|
|
}
|
|
}
|
|
// TrackPlayCell.slint:234-243 — the cell is its OWN click target:
|
|
// a non-current cell plays the track, the current cell toggles
|
|
// play/pause. Without it the circle was a control that rendered
|
|
// and did nothing on the album view (clickPlays:false there, so
|
|
// the press fell through to the row body, which only reacts to a
|
|
// double-click). Declared LAST so it sits ABOVE the glyph, and it
|
|
// is above the row-body area regardless (that one is pinned z:-1).
|
|
MouseArea {
|
|
id: playArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (root.isActive) QbzPlayer.togglePlay()
|
|
else root.playRequested()
|
|
}
|
|
}
|
|
}
|
|
// 36px artwork cell (showArtwork arm).
|
|
Rectangle {
|
|
visible: root.showArtwork
|
|
width: cols.colArt
|
|
height: cols.colArt
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
radius: 4
|
|
color: theme.surfaceElevated
|
|
clip: true
|
|
RoundedImage {
|
|
anchors.fill: parent
|
|
source: root.item.artPath || ""
|
|
radius: 4
|
|
}
|
|
// Per-item cover placeholder — clears when THIS row's cover lands,
|
|
// which is what makes a long list read as progressive instead of
|
|
// filling in one lump. Host views drive the three properties;
|
|
// default-off, so no existing call site changes.
|
|
QbzSkeleton {
|
|
variant: "art"
|
|
anchors.fill: parent
|
|
blockRadius: 4
|
|
visible: root.artPending
|
|
phase: root.skelPhase
|
|
settleMs: root.artSettleMs
|
|
}
|
|
}
|
|
// Title (+ explicit) / artist.
|
|
Column {
|
|
width: root.titleColWidth
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 2
|
|
Row {
|
|
spacing: 6
|
|
Text {
|
|
text: root.item.title || ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: 14
|
|
font.weight: theme.weightMedium
|
|
elide: Text.ElideRight
|
|
width: Math.min(implicitWidth, parent.parent.width - (root.item.explicit ? 22 : 0))
|
|
}
|
|
Rectangle {
|
|
visible: root.item.explicit === true
|
|
width: 16
|
|
height: 16
|
|
radius: 3
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: theme.surfaceElevated
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "E"
|
|
color: theme.textMuted
|
|
font.pixelSize: 10
|
|
font.weight: theme.weightSemibold
|
|
}
|
|
}
|
|
}
|
|
Text {
|
|
width: parent.width
|
|
visible: (root.item.artist || "") !== ""
|
|
text: root.item.artist || ""
|
|
color: root.artistLink && root.item.artistId && artistLinkArea.containsMouse
|
|
? theme.textPrimary : theme.textMuted
|
|
font.pixelSize: 12
|
|
elide: Text.ElideRight
|
|
MouseArea {
|
|
id: artistLinkArea
|
|
anchors.fill: parent
|
|
enabled: root.artistLink && !!root.item.artistId
|
|
hoverEnabled: true
|
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
// Same routing as the menu's "Go to artist" — Slint drives
|
|
// both through one handler, so gating only the menu would
|
|
// leave the LINK navigating to the track's own artist.
|
|
onClicked: {
|
|
if (root.routeGoToExternally) root.goToRequested("artist")
|
|
else QbzArtist.openArtist(root.item.artistId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Album (link) column (showAlbum arm).
|
|
Text {
|
|
visible: root.showAlbum
|
|
width: cols.colAlbum
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.item.album || ""
|
|
color: albumArea.containsMouse ? theme.accent : theme.textMuted
|
|
font.pixelSize: 12
|
|
elide: Text.ElideRight
|
|
MouseArea {
|
|
id: albumArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: root.item.albumId ? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
onClicked: if (root.item.albumId) QbzAlbum.openAlbum(root.item.albumId)
|
|
}
|
|
}
|
|
// Duration.
|
|
Text {
|
|
width: cols.colDuration
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.item.duration || ""
|
|
color: theme.textMuted
|
|
font.pixelSize: 12
|
|
horizontalAlignment: Text.AlignHCenter
|
|
}
|
|
// Quality (92px) — the BARE badge, 1:1 with primitives/TrackRow.slint
|
|
// 578-592: a 92px cell, `alignment: center` on both axes, holding a
|
|
// QualityBadgeFull with `show-icon: false` + `bare: true`. That is the
|
|
// tier label ("CD"/"HI-RES"/"MP3"/"LOSSLESS") stacked over the exact
|
|
// bit-depth / sample-rate line, with no chip background or border, so
|
|
// it blends into the row instead of reading as a contained badge.
|
|
// The .slint has ONE form here — no icon variant, no bare-text
|
|
// variant — which is why `qualityStyle` above is inert.
|
|
//
|
|
// The cell keeps its FIXED 92px. That number is a term of
|
|
// `cellsRight`, which is what sizes the title column, so a wider badge
|
|
// MUST NOT widen the cell or the title would be pushed into the
|
|
// trailing controls. The badge is centred and the cell clips; at 8/9px
|
|
// the longest real detail ("24-bit / 352.8 kHz") measures ~80px, so
|
|
// the clip is a guard, never the normal path.
|
|
//
|
|
// Cheaper than what it replaces, too: QualityMini resolves to
|
|
// QualityBadge, which carries a ToolTip popup and a hover MouseArea
|
|
// per row; this one is two Texts.
|
|
Item {
|
|
width: cols.colQuality
|
|
height: parent.height
|
|
clip: true
|
|
QualityBadgeFull {
|
|
anchors.centerIn: parent
|
|
tier: root.item.qualityTier || ""
|
|
detail: root.item.qualityDetail || ""
|
|
showIcon: false
|
|
bare: true
|
|
}
|
|
}
|
|
// Favorite (showFavorite arm).
|
|
Rectangle {
|
|
visible: root.showFavorite
|
|
width: cols.colFavorite
|
|
height: cols.colFavorite
|
|
radius: theme.radiusSm
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: favArea.containsMouse ? theme.surfaceElevated : "transparent"
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
name: root.favorite ? "heart-filled" : "heart"
|
|
width: 16
|
|
height: 16
|
|
// TrackRow.slint:619 — hover raises the glyph to
|
|
// Theme.text-primary, NOT to white (same #638 hole).
|
|
tintName: root.favorite
|
|
? "favorite"
|
|
: (favArea.containsMouse ? root.playGlyphTint : "muted")
|
|
}
|
|
MouseArea {
|
|
id: favArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.toggleFavorite()
|
|
}
|
|
}
|
|
// Download slot (showDownload arm): inert glyph stub or reserved
|
|
// spacer (the offline-cache column — not ported, POC-NOTE; the
|
|
// Slint reserves the slot so the grid stays aligned).
|
|
Item {
|
|
visible: root.showDownload
|
|
width: cols.colDownload
|
|
height: cols.colDownload
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
QbzIcon {
|
|
visible: root.downloadGlyph
|
|
anchors.centerIn: parent
|
|
name: "cloud-download"
|
|
width: 16
|
|
height: 16
|
|
tintName: "muted"
|
|
}
|
|
}
|
|
// ⋯ menu (showMenu arm).
|
|
Rectangle {
|
|
visible: root.showMenu
|
|
width: cols.colMenu
|
|
height: cols.colMenu
|
|
radius: theme.radiusSm
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: moreArea.containsMouse ? theme.surfaceElevated : "transparent"
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
name: "ellipsis"
|
|
width: 16
|
|
height: 16
|
|
// TrackRow.slint:750 — Theme.text-primary on hover.
|
|
tintName: moreArea.containsMouse ? root.playGlyphTint : "muted"
|
|
}
|
|
MouseArea {
|
|
id: moreArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: function (mouse) { rowMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
|
|
}
|
|
}
|
|
}
|
|
CardMenu {
|
|
id: rowMenu
|
|
menuWidth: 224
|
|
entries: root.menuModel()
|
|
onPicked: function (a) { root.menuAction(a) }
|
|
}
|
|
|
|
// TrackContextMenu.slint, in its order. Every row here reaches a live
|
|
// seam; the seamless ones are gated off above.
|
|
function menuModel() {
|
|
var t = QbzSession.tr
|
|
var r = QbzSession.trRev
|
|
var m = [
|
|
{ "label": t("Play now", r), "icon": "play-fill", "action": "play" },
|
|
{ "label": t("Play next", r), "icon": "list-start", "action": "next" },
|
|
]
|
|
// #442 "Play later" — the end of the MANUAL block (after everything
|
|
// already queued by hand, before the source resumes).
|
|
if (root.menuShowLater)
|
|
m.push({ "label": t("Play later", r), "icon": "list-plus", "action": "later" })
|
|
m.push({ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" })
|
|
if (root.hasRadioSeam) {
|
|
m.push({ "label": t("Create QBZ radio", r), "icon": "radio", "action": "radio-qbz" })
|
|
m.push({ "label": t("Create Qobuz radio", r), "icon": "radio", "action": "radio-qobuz" })
|
|
}
|
|
if (root.menuShowFavorite)
|
|
m.push({ "label": root.favorite ? t("Remove from Library", r) : t("Add to Library", r),
|
|
"icon": root.favorite ? "heart-filled" : "heart", "action": "favorite" })
|
|
if (root.hasMixtapeSeam)
|
|
m.push({ "label": t("Add to mixtape", r), "icon": "cassette-tape", "action": "mixtape" })
|
|
if (root.hasPlaylistAddSeam)
|
|
m.push({ "label": t("Add to playlist", r), "icon": "list-music", "action": "add-to-playlist" })
|
|
if (root.menuShowRemove)
|
|
m.push({ "label": t("Remove from playlist", r), "icon": "trash-2",
|
|
"action": "remove", "danger": true })
|
|
if (root.menuShowShare)
|
|
m.push({ "label": t("Share Qobuz link", r), "icon": "link", "action": "share-qobuz" })
|
|
if (root.hasSonglinkSeam)
|
|
m.push({ "label": t("Share Song.link", r), "icon": "link", "action": "share-songlink" })
|
|
if (root.hasOfflineCacheSeam)
|
|
m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" })
|
|
if (root.menuShowGoTo && root.item.albumId)
|
|
m.push({ "label": t("Go to album", r), "icon": "disc-3", "action": "go-album" })
|
|
if (root.menuShowGoTo && root.item.artistId)
|
|
m.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist" })
|
|
if (root.menuShowTrackInfo)
|
|
m.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" })
|
|
return m
|
|
}
|
|
|
|
function menuAction(a) {
|
|
if (a === "play") root.playRequested()
|
|
else if (a === "next") root.enqueueRequested("next")
|
|
else if (a === "later") root.enqueueRequested("later")
|
|
else if (a === "queue") root.enqueueRequested("queue")
|
|
else if (a === "go-artist") {
|
|
if (root.routeGoToExternally) root.goToRequested("artist")
|
|
else QbzArtist.openArtist(root.item.artistId)
|
|
}
|
|
else if (a === "go-album") {
|
|
if (root.routeGoToExternally) root.goToRequested("album")
|
|
else QbzAlbum.openAlbum(root.item.albumId)
|
|
}
|
|
else if (a === "favorite") root.toggleFavorite()
|
|
else if (a === "mixtape") root.mixtapeRequested()
|
|
else if (a === "remove") root.removeRequested()
|
|
else if (a === "share-qobuz") root.copyToClipboard(
|
|
"https://open.qobuz.com/track/" + (root.item.id || ""))
|
|
else if (a === "track-info") root.openTrackInfo()
|
|
}
|
|
|
|
// --- Share (share.rs::qobuz_track_url + copy_to_clipboard) -----------
|
|
// The .slint arm copies the link and raises a toast; the Qt port has no
|
|
// toast seam yet (GLUE NEEDED), so the copy is silent — but it IS a real
|
|
// clipboard write. QtQuick exposes no Clipboard type; TextEdit.copy() is
|
|
// the supported route, kept in an INACTIVE Loader so a 16K-row list does
|
|
// not carry one TextEdit per row.
|
|
Loader {
|
|
id: clipLoader
|
|
active: false
|
|
sourceComponent: TextEdit { visible: false }
|
|
}
|
|
function copyToClipboard(text) {
|
|
if (!text || text === "")
|
|
return
|
|
clipLoader.active = true
|
|
clipLoader.item.text = text
|
|
clipLoader.item.selectAll()
|
|
clipLoader.item.copy()
|
|
clipLoader.active = false
|
|
}
|
|
|
|
// --- Track info (QbzAlbum.openTrackInfo + shell/TrackInfoModal) ------
|
|
// Same lazy-Loader reason: the modal is a full Popup tree and a list row
|
|
// must not instantiate one per delegate. Activated on demand, torn down
|
|
// on close (Qt.callLater so the Popup is not destroyed mid-signal).
|
|
Loader {
|
|
id: trackInfoLoader
|
|
active: false
|
|
sourceComponent: TrackInfoModal { }
|
|
}
|
|
Connections {
|
|
target: trackInfoLoader.item
|
|
ignoreUnknownSignals: true
|
|
function onClosed() { Qt.callLater(function () { trackInfoLoader.active = false }) }
|
|
}
|
|
function openTrackInfo() {
|
|
if (!root.item.id)
|
|
return
|
|
trackInfoLoader.active = true
|
|
trackInfoLoader.item.openFor(root.item.id)
|
|
}
|
|
|
|
// Shared drag (the row BODY is the source — TrackRow.slint): press-drag
|
|
// >6px starts it (bodyDragStarted fires FIRST, the #589 pre-hook);
|
|
// release plays (clickPlays) or ignores (album view: double-click).
|
|
property bool dragging: false
|
|
property point downPos: Qt.point(0, 0)
|
|
MouseArea {
|
|
id: trArea
|
|
anchors.fill: parent
|
|
// BEHIND the row's own controls. Declaration order is z-order in QML,
|
|
// and this fills the whole row from the LAST declaration — so it sat on
|
|
// top of the heart / cloud / menu buttons and swallowed their presses.
|
|
// propagateComposedEvents does not save them: `pressed` is not a
|
|
// composed event, so only the row's own double-click still worked.
|
|
z: -1
|
|
hoverEnabled: true
|
|
propagateComposedEvents: true
|
|
// Right press opens the SAME menu as ⋯ (rowMenu), at the pointer.
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
cursorShape: root.clickPlays ? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
onPressed: function (mouse) {
|
|
if (mouse.button === Qt.RightButton) {
|
|
if (root.showMenu)
|
|
rowMenu.openAtCursor(trArea, mouse.x, mouse.y)
|
|
mouse.accepted = true
|
|
return
|
|
}
|
|
root.downPos = Qt.point(mouse.x, mouse.y)
|
|
}
|
|
onPositionChanged: function (mouse) {
|
|
// Only a LEFT press drags — a right press is the context gesture.
|
|
if (!pressed || !(pressedButtons & Qt.LeftButton) || !root.draggable) return
|
|
const g = mapToItem(null, mouse.x, mouse.y)
|
|
if (!root.dragging
|
|
&& (Math.abs(mouse.x - root.downPos.x) > 6
|
|
|| Math.abs(mouse.y - root.downPos.y) > 6)) {
|
|
root.dragging = true
|
|
root.bodyDragStarted(root.number)
|
|
QbzShell.dragStart(root.item.id, root.item.title || "",
|
|
(root.item.artist || "") + " · " + (root.item.album || ""), g.x, g.y)
|
|
}
|
|
if (root.dragging) QbzShell.dragMove(g.x, g.y)
|
|
}
|
|
onReleased: function (mouse) {
|
|
if (mouse.button === Qt.RightButton)
|
|
return
|
|
if (root.dragging) {
|
|
QbzShell.dragEnd()
|
|
root.dragging = false
|
|
mouse.accepted = true
|
|
} else if (root.clickPlays) {
|
|
root.playRequested()
|
|
} else {
|
|
mouse.accepted = false
|
|
}
|
|
}
|
|
onDoubleClicked: function (mouse) {
|
|
if (mouse.button === Qt.LeftButton)
|
|
root.playRequested()
|
|
}
|
|
}
|
|
}
|