Files
qbz/crates/qbz-qt/qml/rows/TrackRow.qml
T
vicrodh ade156a72a refactor(qbz-qt): split QbzAlbum and QbzArtist out of the God-object
Two singletons rather than one merged object, per the owner: each maps to the
view it backs. Registering them and deleting the duplicates from bridge.rs is
one change, because wiring one without removing the other compiles fine and
leaves two independent states with the view frozen and nothing in the log.

view_param_id goes with them. It had no consumer in QML, verified, and none in
Rust once the two opens stopped writing it — so it is deleted rather than
carried into a new bridge for symmetry's sake.

bridge.rs is down to three domains: settings, search and playlist.

cc
2026-07-28 15:24:38 -06:00

376 lines
15 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 (icon QualityMini | bare text), 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, explicit, isFavorite, artPath? } (plus
// playlistTrackId for the remove-from-playlist arm).
//
// Arms: showArtwork / showAlbum / showFavorite / showDownload /
// downloadGlyph / showMenu / zebra / artistLink / clickPlays /
// qualityStyle ("icon"|"text") / menuShowLater / menuShowGoTo /
// menuShowFavorite / menuShowRemove.
// 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).
// Favorite toggling and Go-to-artist/album are identical on every site —
// handled internally. The row BODY is the drag source (6px threshold,
// ghost + sidebar drops in main.rs).
//
// 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).
import QtQuick
import com.blitzfc.qbz
import "../controls"
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
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
signal playRequested()
signal enqueueRequested(string mode)
signal removeRequested()
signal bodyDragStarted(int index)
QbzTheme { id: theme }
width: parent ? parent.width : 0
height: 50
radius: 8
color: hovered ? theme.surfaceHover : (zebra && number % 2 === 0 ? "#07ffffff" : "transparent")
readonly property bool hovered: trArea.containsMouse || favArea.containsMouse || moreArea.containsMouse
readonly property bool isActive: QbzPlayer.npTrackId === (item.id || "")
readonly property int cellsRight: 70 + 92 + (showFavorite ? 28 : 0) + (showDownload ? 28 : 0) + (showMenu ? 32 : 0)
readonly property int cellsLeft: 32 + (showArtwork ? 36 : 0) + (showAlbum ? 220 : 0)
readonly property int gaps: (3 + (showArtwork ? 1 : 0) + (showAlbum ? 1 : 0)
+ (showFavorite ? 1 : 0) + (showDownload ? 1 : 0) + (showMenu ? 1 : 0)) * 14
// 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: 12
anchors.rightMargin: 12
spacing: 14
// Number cell — swaps to play on hover (pause when active+playing).
Item {
width: 32
height: 40
anchors.verticalCenter: parent.verticalCenter
Text {
visible: !trArea.containsMouse
anchors.centerIn: parent
text: root.number
color: theme.textMuted
font.pixelSize: 13
}
Rectangle {
visible: trArea.containsMouse
anchors.centerIn: parent
width: 28
height: 28
radius: 14
color: root.isActive && QbzPlayer.npPlaying ? "transparent" : "#3dffffff"
border.width: root.isActive && QbzPlayer.npPlaying ? 1.5 : 0
border.color: theme.accent
QbzIcon {
anchors.centerIn: parent
name: root.isActive && QbzPlayer.npPlaying ? "pause" : "play-fill"
width: 14
height: 14
tintName: root.isActive && QbzPlayer.npPlaying ? "accent" : "primary"
}
}
}
// 36px artwork cell (showArtwork arm).
Rectangle {
visible: root.showArtwork
width: 36
height: 36
anchors.verticalCenter: parent.verticalCenter
radius: 4
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: root.item.artPath || ""
radius: 4
}
}
// Title (+ explicit) / artist.
Column {
width: parent.width - root.cellsLeft - root.cellsRight - root.gaps
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
onClicked: QbzArtist.openArtist(root.item.artistId)
}
}
}
// Album (link) column (showAlbum arm).
Text {
visible: root.showAlbum
width: 220
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: 70
anchors.verticalCenter: parent.verticalCenter
text: root.item.duration || ""
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
}
// Quality (92px): icon (QualityMini) or bare text.
Rectangle {
width: 92
height: parent.height
color: "transparent"
QualityMini {
visible: root.qualityStyle === "icon"
tier: root.item.qualityTier || ""
anchors.centerIn: parent
}
Text {
visible: root.qualityStyle === "text"
anchors.centerIn: parent
text: root.item.qualityTier === "hires" ? "HI-RES" : (root.item.qualityTier === "cd" ? "CD" : "")
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightBold
horizontalAlignment: Text.AlignHCenter
}
}
// Favorite (showFavorite arm).
Rectangle {
visible: root.showFavorite
width: 28
height: 28
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: favArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
anchors.centerIn: parent
name: root.item.isFavorite ? "heart-filled" : "heart"
width: 16
height: 16
tintName: root.item.isFavorite ? "favorite" : (favArea.containsMouse ? "primary" : "muted")
}
MouseArea {
id: favArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.item.isFavorite = !root.item.isFavorite
QbzLibrary.libraryToggleFavorite("track", root.item.id)
}
}
}
// 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: 28
height: 28
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: 32
height: 32
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: moreArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
anchors.centerIn: parent
name: "ellipsis"
width: 16
height: 16
tintName: moreArea.containsMouse ? "primary" : "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: 220
entries: {
var m = [
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
]
if (root.menuShowLater) m.push({ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" })
m.push({ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" })
if (root.menuShowGoTo && root.item.artistId) m.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" })
if (root.menuShowGoTo && root.item.albumId) m.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc", "action": "go-album" })
if (root.menuShowFavorite) m.push({
"label": root.item.isFavorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev),
"icon": root.item.isFavorite ? "heart-filled" : "heart", "action": "favorite" })
if (root.menuShowRemove) m.push({ "label": QbzSession.tr("Remove from playlist", QbzSession.trRev), "icon": "trash-2", "action": "remove" })
return m
}
onPicked: function (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") QbzArtist.openArtist(root.item.artistId)
else if (a === "go-album") QbzAlbum.openAlbum(root.item.albumId)
else if (a === "favorite") {
root.item.isFavorite = !root.item.isFavorite
QbzLibrary.libraryToggleFavorite("track", root.item.id)
} else if (a === "remove") root.removeRequested()
}
}
// 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
hoverEnabled: true
propagateComposedEvents: true
cursorShape: root.clickPlays ? Qt.PointingHandCursor : Qt.ArrowCursor
onPressed: function (mouse) { root.downPos = Qt.point(mouse.x, mouse.y) }
onPositionChanged: function (mouse) {
if (!pressed || !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 (root.dragging) {
QbzShell.dragEnd()
root.dragging = false
mouse.accepted = true
} else if (root.clickPlays) {
root.playRequested()
} else {
mouse.accepted = false
}
}
onDoubleClicked: root.playRequested()
}
}