Add the chained genre, artist, and album browser with persistent layout and tab ordering. Reuse the shared album semantics for grouped versions and disc-aware details, enforce active source filters, and bound foreground detail work so chained searches stay responsive. cx
427 lines
15 KiB
QML
427 lines
15 KiB
QML
// Compact album row for the LIST arm of the Albums / Folders-flat / Artists
|
|
// surfaces (AlbumCollectionView's list mode, mounted by
|
|
// LocalLibraryView.slint:1255 with show-source / show-source-badge on).
|
|
//
|
|
// 56px: cover, title + artist, year, track count, quality mark, the source
|
|
// column and the trailing ⋯ overflow. Multi-select puts the shared checkbox
|
|
// in front of the cover, as the collection view does.
|
|
//
|
|
// The ⋯ menu carries the same local/Plex favorite as the grid overlay. Remote-
|
|
// only rows omit it because the shared LocalFavoritesService deliberately
|
|
// accepts genuine local and Plex snapshots only.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../../controls"
|
|
import "../../rows"
|
|
import "../../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
|
|
property var item: ({})
|
|
property string artSource: ""
|
|
property bool isFavorite: root.item.isFavorite === true
|
|
/// The LocalLibraryView root — the shared skeleton pulse, the per-item
|
|
/// artwork gate and the settle bound all live there (never duplicated
|
|
/// per row, so the rule cannot drift between surfaces).
|
|
property var view: null
|
|
property bool showSource: true
|
|
property bool selectMode: false
|
|
property bool checked: false
|
|
/// Details surfaces keep albums open by default but must not replace the
|
|
/// album row with bespoke chrome. The optional caret is the one extra
|
|
/// affordance; clicking the rest of the row still opens AlbumView.
|
|
property bool expandable: false
|
|
property bool expanded: true
|
|
/// Genres Details uses the same album semantics/menu but a taller table
|
|
/// header whose trailing columns line up with LocalTrackRow.
|
|
property bool detailsMode: false
|
|
property var versions: []
|
|
property int versionIndex: 0
|
|
signal opened()
|
|
signal playRequested()
|
|
signal shuffleRequested()
|
|
signal enqueueRequested(string mode)
|
|
signal favoriteRequested()
|
|
signal toggleExpanded()
|
|
signal versionPicked(int index)
|
|
/// `modifiers` rides straight off the mouse event: Shift is what turns
|
|
/// a click into a range (controls/SelectionModel.qml).
|
|
signal toggleSelect(int modifiers)
|
|
|
|
QbzTheme { id: theme }
|
|
TrackCols { id: cols }
|
|
|
|
height: detailsMode ? 72 : 56
|
|
radius: 6
|
|
color: rowArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
|
|
function openRowMenuAt(anchor, x, y) {
|
|
rowMenuLoader.active = true
|
|
rowMenuLoader.item.openAtCursor(anchor, x, y)
|
|
}
|
|
function openRowMenuBelow(anchor) {
|
|
rowMenuLoader.active = true
|
|
rowMenuLoader.item.openBelowRight(anchor)
|
|
}
|
|
|
|
MouseArea {
|
|
id: rowArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: function (mouse) {
|
|
if (mouse.button === Qt.RightButton) {
|
|
root.openRowMenuAt(rowArea, mouse.x, mouse.y)
|
|
return
|
|
}
|
|
if (root.selectMode) root.toggleSelect(mouse.modifiers)
|
|
else root.opened()
|
|
}
|
|
onDoubleClicked: if (!root.selectMode) root.playRequested()
|
|
}
|
|
|
|
// Same cold-menu contract as LocalTrackRow: a list/detail viewport should
|
|
// construct album rows, not five invisible popup delegates per row.
|
|
Loader {
|
|
id: rowMenuLoader
|
|
active: false
|
|
sourceComponent: CardMenu {
|
|
menuWidth: 196
|
|
entries: {
|
|
var rows = [
|
|
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
|
|
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
|
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
|
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
|
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" }
|
|
]
|
|
if (root.item.favoriteable === true) {
|
|
rows.push({
|
|
"label": root.isFavorite
|
|
? QbzSession.tr("Remove from Library", QbzSession.trRev)
|
|
: QbzSession.tr("Add to Library", QbzSession.trRev),
|
|
"icon": root.isFavorite ? "heart-filled" : "heart",
|
|
"action": "favorite"
|
|
})
|
|
}
|
|
return rows
|
|
}
|
|
onPicked: function (a) {
|
|
if (a === "open") root.opened()
|
|
else if (a === "play") root.playRequested()
|
|
else if (a === "favorite") root.favoriteRequested()
|
|
else root.enqueueRequested(a)
|
|
}
|
|
}
|
|
}
|
|
|
|
Row {
|
|
visible: !root.detailsMode
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 10
|
|
anchors.rightMargin: 12
|
|
spacing: 12
|
|
|
|
Item {
|
|
visible: root.selectMode
|
|
width: visible ? 16 : 0
|
|
height: parent.height
|
|
SelectCheck {
|
|
anchors.centerIn: parent
|
|
on: root.checked
|
|
onToggled: function (mods) { root.toggleSelect(mods) }
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: 40
|
|
height: 40
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
radius: 4
|
|
color: theme.surfaceElevated
|
|
clip: true
|
|
RoundedImage {
|
|
id: rowArt
|
|
anchors.fill: parent
|
|
source: root.artSource
|
|
radius: 4
|
|
}
|
|
// Per-item: hands over the instant the art is actually ON the
|
|
// canvas (`rowArt.ready`, not "the path landed"); settles out
|
|
// when the album simply has none (local artwork drops such keys).
|
|
QbzSkeleton {
|
|
variant: "art"
|
|
anchors.fill: parent
|
|
blockRadius: 4
|
|
pending: root.view ? root.view.artWanted(root.item.artKey) : false
|
|
coverReady: rowArt.ready
|
|
phase: root.view ? root.view.skelPhase : false
|
|
settleMs: root.view ? root.view.artSettleMs : 0
|
|
settleHold: root.view ? root.view.artPulse : false
|
|
}
|
|
}
|
|
Column {
|
|
width: parent.width - 40 - 70 - 90 - 92 - 32
|
|
- (root.showSource ? 34 : 0)
|
|
- (root.selectMode ? 28 : 0) - 6 * 12
|
|
- (root.expandable ? 28 + 12 : 0)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 2
|
|
Text {
|
|
width: parent.width
|
|
text: root.item.title || ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: 14
|
|
font.weight: theme.weightMedium
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
width: parent.width
|
|
text: root.item.artist || ""
|
|
color: theme.textMuted
|
|
font.pixelSize: 12
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
Text {
|
|
width: 70
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.item.year || ""
|
|
color: theme.textMuted
|
|
font.pixelSize: 12
|
|
}
|
|
Text {
|
|
width: 90
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: (root.item.trackCount || 0) + " "
|
|
+ QbzSession.tr("tracks", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 12
|
|
}
|
|
Item {
|
|
width: 92
|
|
height: parent.height
|
|
QualityMini {
|
|
tier: root.item.qualityTier || ""
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
Item {
|
|
visible: root.showSource
|
|
readonly property var sourceValues: root.item.sources
|
|
&& root.item.sources.length > 0
|
|
? root.item.sources
|
|
: ((root.item.sourceRaw || root.item.source || "") !== ""
|
|
? [root.item.sourceRaw || root.item.source] : [])
|
|
width: visible ? Math.max(34, sourceIcons.implicitWidth) : 0
|
|
height: parent.height
|
|
// AlbumListRow.slint:308-322 — through controls/SourceIcon.qml,
|
|
// never QbzIcon: the Plex and Qobuz marks are MULTI-COLOUR and a
|
|
// tint flattens them to a silhouette. This row used to draw an
|
|
// accent-tinted `hard-drive` for Plex (a blue hard drive) and
|
|
// `cloud-download` for an offline copy.
|
|
Row {
|
|
id: sourceIcons
|
|
spacing: 3
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
Repeater {
|
|
model: sourceIcons.parent.sourceValues
|
|
delegate: SourceIcon {
|
|
required property string modelData
|
|
kind: modelData
|
|
mono: true
|
|
glyphSize: 15
|
|
plexSize: 16
|
|
qobuzSize: 16
|
|
localTint: "muted"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Rectangle {
|
|
visible: root.expandable
|
|
width: visible ? 28 : 0
|
|
height: 28
|
|
radius: 6
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: expandArea.containsMouse ? theme.surfaceElevated : "transparent"
|
|
QbzIcon {
|
|
name: root.expanded ? "chevron-down" : "chevron-right"
|
|
width: 14
|
|
height: 14
|
|
anchors.centerIn: parent
|
|
tintName: expandArea.containsMouse ? "textPrimary" : "muted"
|
|
}
|
|
MouseArea {
|
|
id: expandArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.toggleExpanded()
|
|
}
|
|
}
|
|
// Trailing ⋯ overflow (AlbumListRow.slint:360).
|
|
Rectangle {
|
|
width: 32
|
|
height: 32
|
|
radius: 6
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: moreArea.containsMouse ? theme.surfaceElevated : "transparent"
|
|
QbzIcon {
|
|
name: "ellipsis"
|
|
width: 18
|
|
height: 18
|
|
anchors.centerIn: parent
|
|
tintName: moreArea.containsMouse ? "textPrimary" : "muted"
|
|
}
|
|
MouseArea {
|
|
id: moreArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: function (mouse) {
|
|
root.openRowMenuAt(moreArea, mouse.x, mouse.y)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Genres Details header. LocalTrackRow holds back 26px for its source
|
|
// indicator and 46px for its local menu; the formulas below use those
|
|
// same gutters so quality, menu and version/source share exact centres.
|
|
Item {
|
|
id: detailsLayout
|
|
visible: root.detailsMode
|
|
anchors.fill: parent
|
|
|
|
readonly property int qualityX: width - 26 - (cols.colMenu + cols.gap)
|
|
- cols.padH - cols.colQuality
|
|
readonly property int menuX: width - 70
|
|
|
|
QbzIconButton {
|
|
id: detailsFold
|
|
visible: root.expandable
|
|
x: 8
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
btnSize: 26
|
|
iconSize: 12
|
|
name: root.expanded ? "minus" : "plus"
|
|
onClicked: root.toggleExpanded()
|
|
}
|
|
|
|
Rectangle {
|
|
x: 46
|
|
width: 48
|
|
height: 48
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
radius: 5
|
|
color: theme.surfaceElevated
|
|
clip: true
|
|
RoundedImage {
|
|
id: detailsArt
|
|
anchors.fill: parent
|
|
source: root.artSource
|
|
radius: 5
|
|
}
|
|
QbzSkeleton {
|
|
variant: "art"
|
|
anchors.fill: parent
|
|
blockRadius: 5
|
|
pending: root.view ? root.view.artWanted(root.item.artKey) : false
|
|
coverReady: detailsArt.ready
|
|
phase: root.view ? root.view.skelPhase : false
|
|
settleMs: root.view ? root.view.artSettleMs : 0
|
|
settleHold: root.view ? root.view.artPulse : false
|
|
}
|
|
}
|
|
|
|
Column {
|
|
x: 106
|
|
y: 9
|
|
width: Math.max(0, albumActions.x - x - 14)
|
|
spacing: 1
|
|
Text {
|
|
width: parent.width
|
|
text: root.item.title || ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: 14
|
|
font.weight: theme.weightMedium
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
width: parent.width
|
|
text: root.item.artist || ""
|
|
color: theme.textSecondary
|
|
font.pixelSize: 12
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
width: parent.width
|
|
text: {
|
|
var parts = []
|
|
if ((root.item.year || "") !== "") parts.push(root.item.year)
|
|
parts.push((root.item.trackCount || 0) + " "
|
|
+ QbzSession.tr("tracks", QbzSession.trRev))
|
|
return parts.join(" · ")
|
|
}
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
|
|
// Compact and deliberately not centred: it floats at the right edge
|
|
// of the metadata region, before the table's quality/actions columns.
|
|
Row {
|
|
id: albumActions
|
|
x: detailsLayout.qualityX - width - 16
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 6
|
|
QbzIconButton {
|
|
btnSize: 26
|
|
iconSize: 13
|
|
name: "play-fill"
|
|
onClicked: root.playRequested()
|
|
}
|
|
QbzIconButton {
|
|
btnSize: 26
|
|
iconSize: 13
|
|
name: "shuffle"
|
|
onClicked: root.shuffleRequested()
|
|
}
|
|
}
|
|
|
|
Item {
|
|
x: detailsLayout.qualityX
|
|
width: cols.colQuality
|
|
height: parent.height
|
|
QualityMini {
|
|
tier: root.item.qualityTier || ""
|
|
anchors.centerIn: parent
|
|
}
|
|
}
|
|
|
|
QbzIconButton {
|
|
id: detailsMenuButton
|
|
x: detailsLayout.menuX
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
btnSize: cols.colMenu
|
|
iconSize: 16
|
|
name: "ellipsis"
|
|
onClicked: root.openRowMenuBelow(detailsMenuButton)
|
|
}
|
|
|
|
VersionPicker {
|
|
visible: root.showSource && root.versions.length > 0
|
|
x: parent.width - width
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
compact: true
|
|
versions: root.versions
|
|
current: root.versionIndex
|
|
onPicked: function (index) { root.versionPicked(index) }
|
|
}
|
|
}
|
|
}
|