Recover missing offline library entries, recognize cached-only libraries, and refresh availability after cache changes. Unify per-tab filters, compact the toolbar, and add reversible sort criteria with complete available purchase metadata. cx
480 lines
22 KiB
QML
480 lines
22 KiB
QML
// FeedListRow — the Library "All" feed's LIST row (FavoritesView.slint:1212+,
|
|
// the windowed mixed row: cover · title/subtitle · TYPE · source glyph ·
|
|
// quality · favourite indicator · ⋯).
|
|
//
|
|
// EXTRACTED from views/LibraryView.qml, where it was an inline `component`
|
|
// ~300 lines long inside a 1,880-line file (track rule 2). Behaviour is
|
|
// unchanged; the only edit is that the six things it used to read off `root`
|
|
// now come through `view` — the LibraryView instance — which is the same seam
|
|
// views/local/*.qml uses for LocalLibraryView.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../../cards"
|
|
import "../../controls"
|
|
import "../../theme"
|
|
|
|
Rectangle {
|
|
id: feedRow
|
|
|
|
/// The LibraryView root: artMap, skelPhase, showLocal and the three
|
|
/// shared play/menu helpers live there (never duplicated per row).
|
|
property var view: null
|
|
property var item: ({})
|
|
/// Viewport-relative position, for the skeleton's animated cap.
|
|
property int rowIndex: 0
|
|
/// Absolute model position, for the stable zebra independent of pooling.
|
|
property int displayIndex: 0
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
height: 50
|
|
radius: 6
|
|
color: rowArea.containsMouse && !feedRow.pulledDead
|
|
? theme.surfaceHover
|
|
: (feedRow.displayIndex % 2 === 1 ? theme.alphaTier(4) : "transparent")
|
|
|
|
// Catalog-withdrawal contract shared with TrackRow/TrackCard, plus a
|
|
// persisted local favorite whose source album has disappeared. A complete
|
|
// offline copy only revives the Qobuz arm; a missing local source remains
|
|
// a non-navigable tombstone.
|
|
readonly property bool sourceMissing:
|
|
feedRow.item.sourceUnavailable === true
|
|
readonly property bool pulled: (feedRow.item.kind === "track"
|
|
|| feedRow.item.kind === "album")
|
|
&& (feedRow.item.qobuzUnavailable === true || feedRow.sourceMissing)
|
|
property int cacheStatus: feedRow.item.cacheStatus !== undefined
|
|
? feedRow.item.cacheStatus : 0
|
|
readonly property bool pulledDead: feedRow.sourceMissing
|
|
|| (feedRow.pulled && feedRow.cacheStatus !== 3)
|
|
|
|
// Live heart, as a real property — `item.isFavorite = !…` notified
|
|
// nothing (plain JS object; see rows/TrackRow.qml for the measurement)
|
|
// so this row's glyph and its menu label never moved on click. The
|
|
// binding is re-established on every new row object, so a republished
|
|
// feed still wins.
|
|
property bool favorite: feedRow.item.isFavorite === true
|
|
onItemChanged: {
|
|
feedRow.favorite = Qt.binding(function () {
|
|
return feedRow.item.isFavorite === true
|
|
})
|
|
feedRow.cacheStatus = Qt.binding(function () {
|
|
return feedRow.item.cacheStatus !== undefined
|
|
? feedRow.item.cacheStatus : 0
|
|
})
|
|
feedRow.releaseForReuse()
|
|
}
|
|
function toggleFavorite() {
|
|
feedRow.favorite = !feedRow.favorite
|
|
QbzLibrary.libraryToggleFavorite(feedRow.item.kind, feedRow.item.id)
|
|
}
|
|
function albumMenuModel() {
|
|
var t = QbzSession.tr
|
|
var r = QbzSession.trRev
|
|
var m = []
|
|
if (!feedRow.pulledDead) {
|
|
m.push({ "label": t("Open album", r), "icon": "library-big", "action": "open" })
|
|
m.push({ "label": t("Play", r), "icon": "play-fill", "action": "play" })
|
|
}
|
|
// The old id may be dead, but the user must still be able to remove
|
|
// its favorite entry instead of being left with a permanent tombstone.
|
|
m.push({ "label": feedRow.favorite ? t("Remove from Library", r) : t("Add to Library", r),
|
|
"icon": feedRow.favorite ? "heart-filled" : "heart", "action": "favorite" })
|
|
if (feedRow.item.source === "qobuz"
|
|
&& feedRow.item.qobuzUnavailable === true) {
|
|
m.push({ "label": t("Look for better replacement", r),
|
|
"icon": "search", "action": "find-release" })
|
|
}
|
|
return m
|
|
}
|
|
function albumAction(action) {
|
|
if ((action === "open" || action === "play") && feedRow.pulledDead)
|
|
return
|
|
if (action === "open") QbzAlbum.openAlbum(feedRow.item.id)
|
|
else if (action === "play") QbzPlayer.playAlbum(feedRow.item.id)
|
|
else if (action === "favorite") feedRow.toggleFavorite()
|
|
else if (action === "find-release") QbzTrackReplace.openRelease(JSON.stringify({
|
|
"targetKind": "album",
|
|
"albumId": feedRow.item.id || "",
|
|
"albumTitle": feedRow.item.title || "",
|
|
"artist": feedRow.item.artist || "",
|
|
"albumArtist": feedRow.item.artist || ""
|
|
}))
|
|
}
|
|
// Settle + rollback + cross-surface walk. `artKey` IS
|
|
// `library_qt::feed_key(kind, id)`, the very key the signal carries —
|
|
// which is also why LibraryView's own handler can patch the backing row by
|
|
// comparing against it.
|
|
Connections {
|
|
target: QbzLibrary
|
|
function onLibraryFavoriteChanged(key, value) {
|
|
if ((feedRow.item.artKey || "") !== "" && key === feedRow.item.artKey)
|
|
feedRow.favorite = value
|
|
}
|
|
}
|
|
Connections {
|
|
target: QbzShell
|
|
function onTrackCacheStatusChanged(trackId, status, progress) {
|
|
// Numeric Qobuz ids overlap across entity types. This signal is
|
|
// track-scoped and must not revive an album with the same number.
|
|
if (feedRow.item.kind === "track"
|
|
&& trackId === (feedRow.item.id || ""))
|
|
feedRow.cacheStatus = status
|
|
}
|
|
}
|
|
|
|
// Row body — click plays/opens by kind. Declared BEFORE the cells so
|
|
// the ⋯ button and art-play win their clicks.
|
|
MouseArea {
|
|
id: rowArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: feedRow.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (feedRow.item.kind === "track") {
|
|
if (!feedRow.pulledDead)
|
|
feedRow.view.playTrackInContext(feedRow.item.id)
|
|
}
|
|
else if (feedRow.item.kind === "album") {
|
|
if (!feedRow.pulledDead)
|
|
QbzAlbum.openAlbum(feedRow.item.id)
|
|
}
|
|
else if (feedRow.item.kind === "artist") QbzArtist.openArtist(feedRow.item.id)
|
|
else if (feedRow.item.kind === "playlist") QbzBridge.openPlaylist(feedRow.item.id)
|
|
else if (feedRow.item.kind === "label") QbzHome.openLabel(feedRow.item.id)
|
|
}
|
|
}
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 12
|
|
anchors.rightMargin: 12
|
|
spacing: 12
|
|
opacity: feedRow.pulledDead ? 0.5 : 1.0
|
|
// Col — artwork (round for artists) + hover play.
|
|
Rectangle {
|
|
width: 36
|
|
height: 36
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
radius: feedRow.item.kind === "artist" ? 18 : 5
|
|
color: theme.surfaceElevated
|
|
clip: true
|
|
RoundedImage {
|
|
anchors.fill: parent
|
|
visible: !lrCollage.visible
|
|
source: feedRow.view.artMap[feedRow.item.artKey] || ""
|
|
radius: feedRow.item.kind === "artist" ? 18 : 5
|
|
// A label logo is contain-fitted (cropping cuts the
|
|
// wordmark) on a FLAT surface — those are transparent
|
|
// PNGs whose edges carry no colour to derive from.
|
|
// A playlist takes "auto": its own graphic is the 800x380
|
|
// `image_rectangle` and pads with an image-derived
|
|
// gradient, while a square member cover still crops. The
|
|
// flag is not required for that any more, but it stays
|
|
// authoritative where it IS published (this feed).
|
|
fit: feedRow.item.kind === "label" ? "contain"
|
|
: feedRow.item.kind === "playlist"
|
|
? (feedRow.item.playlistOwnImage === true ? "pad" : "auto")
|
|
: "crop"
|
|
}
|
|
// User playlists have no graphic of their own, so they show the
|
|
// member-cover mosaic instead of a blank tile.
|
|
PlaylistCollage {
|
|
id: lrCollage
|
|
anchors.fill: parent
|
|
visible: feedRow.item.kind === "playlist"
|
|
&& feedRow.item.playlistOwnImage !== true
|
|
&& (feedRow.view.artMap[feedRow.item.artKey] || "") === ""
|
|
urls: feedRow.item.covers || []
|
|
radius: 6
|
|
}
|
|
// Per-row cover placeholder — same progressive rule as the
|
|
// grid: it clears when THIS row's cover lands. Skipped for
|
|
// the collage arm (a playlist mosaic is real content) and
|
|
// for artists/labels (round designed placeholders).
|
|
QbzSkeleton {
|
|
variant: "art"
|
|
anchors.fill: parent
|
|
blockRadius: 6
|
|
visible: (feedRow.item.kind === "track" || feedRow.item.kind === "album")
|
|
&& feedRow.item.imageUrl !== ""
|
|
&& (feedRow.view.artMap[feedRow.item.artKey] || "") === ""
|
|
&& !feedRow.pulledDead
|
|
phase: feedRow.view.skelPhase
|
|
cellIndex: feedRow.rowIndex
|
|
}
|
|
Rectangle {
|
|
visible: (feedRow.item.kind === "track" || feedRow.item.kind === "album"
|
|
|| feedRow.item.kind === "playlist")
|
|
&& !feedRow.pulledDead
|
|
anchors.fill: parent
|
|
radius: feedRow.item.kind === "artist" ? 18 : 5
|
|
color: "#a6000000"
|
|
opacity: lrPlayArea.containsMouse ? 1.0 : 0.0
|
|
Behavior on opacity { NumberAnimation { duration: 150 } }
|
|
// On the #a6000000 artwork scrim — dark under every theme.
|
|
QbzIcon { name: "play-fill"; width: 16; height: 16; anchors.centerIn: parent; tintName: "white" }
|
|
MouseArea {
|
|
id: lrPlayArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (feedRow.item.kind === "track") {
|
|
if (!feedRow.pulledDead)
|
|
feedRow.view.playTrackInContext(feedRow.item.id)
|
|
}
|
|
else if (feedRow.item.kind === "album") {
|
|
if (!feedRow.pulledDead)
|
|
QbzPlayer.playAlbum(feedRow.item.id)
|
|
}
|
|
// `playPlaylistById`, NOT `playPlaylist` — the latter
|
|
// plays the OPEN playlist page and takes no id
|
|
// (player_bridge.rs:189 is the by-id entry).
|
|
else if (feedRow.item.kind === "playlist") QbzPlayer.playPlaylistById(feedRow.item.id)
|
|
}
|
|
}
|
|
}
|
|
// On a 36px thumbnail a text badge would be illegible. The alert
|
|
// glyph is the same semantic mark TrackRow uses in its number
|
|
// cell; the explicit translated label lives in the quality slot.
|
|
QbzIcon {
|
|
visible: feedRow.pulledDead
|
|
name: "circle-alert"
|
|
width: 16
|
|
height: 16
|
|
anchors.centerIn: parent
|
|
tintName: "favorite"
|
|
}
|
|
}
|
|
// Col — title + subtitle (artist link for track/album).
|
|
Column {
|
|
width: parent.width - 36 - 116 - (srcCol.visible ? 44 : 0)
|
|
- 150 - 18 - 30 - 6 * 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 2
|
|
Text {
|
|
width: parent.width
|
|
height: 18
|
|
text: feedRow.item.title
|
|
color: lrTitleArea.containsMouse && !feedRow.pulledDead
|
|
? theme.accent : theme.textPrimary
|
|
font.pixelSize: 14
|
|
font.weight: theme.weightMedium
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
MouseArea {
|
|
id: lrTitleArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: feedRow.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (feedRow.item.kind === "track") {
|
|
if (!feedRow.pulledDead)
|
|
feedRow.view.playTrackInContext(feedRow.item.id)
|
|
}
|
|
else if (feedRow.item.kind === "album") {
|
|
if (!feedRow.pulledDead)
|
|
QbzAlbum.openAlbum(feedRow.item.id)
|
|
}
|
|
else if (feedRow.item.kind === "artist") QbzArtist.openArtist(feedRow.item.id)
|
|
else if (feedRow.item.kind === "playlist") QbzBridge.openPlaylist(feedRow.item.id)
|
|
else if (feedRow.item.kind === "label") QbzHome.openLabel(feedRow.item.id)
|
|
}
|
|
}
|
|
}
|
|
Text {
|
|
visible: feedRow.item.subtitle !== ""
|
|
width: parent.width
|
|
height: 16
|
|
text: feedRow.item.subtitle
|
|
color: (feedRow.item.artistId !== ""
|
|
&& (feedRow.item.kind === "track" || feedRow.item.kind === "album")
|
|
&& lrSubArea.containsMouse) ? theme.accent : theme.textMuted
|
|
font.pixelSize: 12
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
MouseArea {
|
|
id: lrSubArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: (feedRow.item.artistId !== ""
|
|
&& (feedRow.item.kind === "track" || feedRow.item.kind === "album"))
|
|
? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
onClicked: if (feedRow.item.artistId !== "") QbzArtist.openArtist(feedRow.item.artistId)
|
|
}
|
|
}
|
|
}
|
|
// Col — type (icon + caps label).
|
|
Rectangle {
|
|
width: 116
|
|
height: parent.height
|
|
color: "transparent"
|
|
Row {
|
|
spacing: 6
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
QbzIcon {
|
|
name: feedRow.item.kind === "track" ? "music"
|
|
: feedRow.item.kind === "playlist" ? "list-music"
|
|
: feedRow.item.kind === "artist" ? "user"
|
|
: feedRow.item.kind === "label" ? "disc-3" : "disc"
|
|
width: 13
|
|
height: 13
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
tintName: "muted"
|
|
}
|
|
Text {
|
|
text: feedRow.item.kind === "track" ? QbzSession.tr("Track", QbzSession.trRev)
|
|
: feedRow.item.kind === "album" ? QbzSession.tr("Album", QbzSession.trRev)
|
|
: feedRow.item.kind === "artist" ? QbzSession.tr("Artist", QbzSession.trRev)
|
|
: feedRow.item.kind === "playlist" ? QbzSession.tr("Playlist", QbzSession.trRev)
|
|
: QbzSession.tr("Label", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 10
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 1.2
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
}
|
|
// Col — source glyph (only when local/Plex can appear).
|
|
Rectangle {
|
|
id: srcCol
|
|
visible: feedRow.view.showSourceBadges
|
|
width: 44
|
|
height: parent.height
|
|
color: "transparent"
|
|
// Through controls/SourceIcon.qml, never QbzIcon: the Plex and
|
|
// Qobuz marks are MULTI-COLOUR and a tint flattens them to a
|
|
// silhouette. This column used to draw an accent-tinted
|
|
// `hard-drive` for Plex — a blue hard drive.
|
|
//
|
|
// DEVIATION, stated on purpose: the .slint has NO source column in
|
|
// this list at all (FavoritesView.slint:1767/2154 pass
|
|
// `show-source: false` to AlbumCollectionView). The numbers below
|
|
// are therefore its siblings' — AlbumListRow.slint:319, the row
|
|
// glyph, not the card badge.
|
|
SourceIcon {
|
|
visible: (feedRow.item.source || "") !== ""
|
|
&& feedRow.item.source !== "qobuz"
|
|
kind: feedRow.item.source || ""
|
|
// A dense ROW: the media marks draw monochrome and tinted, like the
|
|
// hard-drive beside them. Colour logos are for cards — a list of
|
|
// them fights the text it labels.
|
|
mono: true
|
|
glyphSize: 15
|
|
plexSize: 16
|
|
qobuzSize: 16
|
|
// Host is a transparent column over the theme row (NOT an
|
|
// artwork scrim), so this matches its siblings
|
|
// local/LocalAlbumRow.qml and local/LocalTrackRow.qml.
|
|
localTint: "muted"
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
// Col — quality (albums + tracks).
|
|
Rectangle {
|
|
width: 150
|
|
height: parent.height
|
|
color: "transparent"
|
|
QualityMini {
|
|
visible: (feedRow.item.kind === "album" || feedRow.item.kind === "track")
|
|
&& feedRow.item.qualityTier !== "" && !feedRow.pulledDead
|
|
tier: feedRow.item.qualityTier
|
|
// The CD/MP3 chip is 30px wide while the Hi-Res mark is 42px.
|
|
// Left-aligning both shifts Hi-Res' visual centre 6px right.
|
|
// Keep one 30px mark axis without centring the whole 150px
|
|
// quality column (whose content is intentionally leading).
|
|
x: Math.round((30 - width) / 2)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
Text {
|
|
visible: feedRow.pulledDead
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("Unavailable", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
font.weight: theme.weightSemibold
|
|
}
|
|
}
|
|
// Col — favorite / follow indicator.
|
|
QbzIcon {
|
|
name: feedRow.item.kind === "artist" ? "user-plus"
|
|
: (feedRow.favorite ? "heart-filled" : "heart")
|
|
width: 18
|
|
height: 18
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
tintName: "accent"
|
|
}
|
|
// Col — context-menu button.
|
|
Rectangle {
|
|
width: 30
|
|
height: 30
|
|
radius: 6
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: lrMenuArea.containsMouse ? theme.surfaceElevated : "transparent"
|
|
QbzIcon { name: "ellipsis"; width: 16; height: 16; anchors.centerIn: parent; tintName: "secondary" }
|
|
MouseArea {
|
|
id: lrMenuArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: function (mouse) { feedRow.openLrMenu(lrMenuArea, mouse.x, mouse.y) }
|
|
}
|
|
}
|
|
}
|
|
// LAZY. This is a QtQuick.Controls Popup with a Repeater over its
|
|
// entries, and it was built EAGERLY for every row — so a long list
|
|
// constructed one whole popup per visible row, and rebuilt them all
|
|
// on every scroll step, to show a menu only ever opened on click.
|
|
// Same lazy-Loader idiom this file already uses for the clipboard
|
|
// helper and the info modal.
|
|
Loader {
|
|
id: lrMenuLoader
|
|
active: false
|
|
sourceComponent: CardMenu {
|
|
menuWidth: 196
|
|
entries: feedRow.item.kind === "track"
|
|
? feedRow.view.trackMenuModel(feedRow.item, feedRow.favorite,
|
|
feedRow.pulledDead)
|
|
: feedRow.item.kind === "album" ? feedRow.albumMenuModel()
|
|
: feedRow.item.kind === "artist" ? [
|
|
{ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" },
|
|
]
|
|
: [
|
|
{ "label": QbzSession.tr("Open", QbzSession.trRev), "icon": "list-music", "action": "open" },
|
|
{ "label": feedRow.favorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev),
|
|
"icon": feedRow.favorite ? "heart-filled" : "heart", "action": "favorite" },
|
|
]
|
|
onPicked: function (a) {
|
|
if (feedRow.item.kind === "track") { feedRow.view.trackAction(feedRow, a); return }
|
|
if (feedRow.item.kind === "album") { feedRow.albumAction(a); return }
|
|
if (a === "open") {
|
|
if (feedRow.item.kind === "playlist") QbzBridge.openPlaylist(feedRow.item.id)
|
|
else if (feedRow.item.kind === "label") QbzHome.openLabel(feedRow.item.id)
|
|
} else if (a === "go-artist") {
|
|
QbzArtist.openArtist(feedRow.item.id)
|
|
} else if (a === "favorite") {
|
|
feedRow.toggleFavorite()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/// Build the popup on first use, then open it.
|
|
function openLrMenu(anchor, x, y) {
|
|
var entries = feedRow.item.kind === "track"
|
|
? feedRow.view.trackMenuModel(feedRow.item, feedRow.favorite,
|
|
feedRow.pulledDead)
|
|
: feedRow.item.kind === "album" ? feedRow.albumMenuModel() : [1]
|
|
if (entries.length === 0)
|
|
return
|
|
lrMenuLoader.active = true
|
|
lrMenuLoader.item.openAtCursor(anchor, x, y)
|
|
}
|
|
function releaseForReuse() {
|
|
if (lrMenuLoader.item)
|
|
lrMenuLoader.item.close()
|
|
lrMenuLoader.active = false
|
|
}
|
|
}
|