perf(qt): optimize catalog views and gapless playback
cx
This commit is contained in:
22 files changed
+1614
-346
No files matched your search
@@ -16,14 +16,17 @@
|
||||
// a trap the Rust seeding avoids anyway, but there is no reason to reproduce
|
||||
// a blank page here.
|
||||
//
|
||||
// WINDOWING (flat grid/list, opt-in via `flick`): only rows within ~one
|
||||
// viewport of the visible band exist. The collection keeps the FULL footprint
|
||||
// WINDOWING (flat grid/list, opt-in via `flick`): only rows within a bounded
|
||||
// runway around the visible band exist. The collection keeps the FULL footprint
|
||||
// as one cheap Item, so its height — and therefore the scroll geometry — never
|
||||
// changes, but there is no longer one Loader shell per off-screen result.
|
||||
// The band is sampled from scroll events rather than bound to `contentY`: a
|
||||
// direct binding re-evaluates every delegate on every frame, while a forever
|
||||
// 80ms Timer spends CPU at rest. Grouped sections remain eager — the same call
|
||||
// the .slint makes (:265-277).
|
||||
// Scroll events only run a constant-time COVERAGE GUARD. It rebuilds the slice
|
||||
// synchronously when the viewport consumes its inner runway; that matters
|
||||
// because a delayed sampler can leave the viewport outside the mounted slice
|
||||
// for one or more completely blank frames during a fast wheel/touchpad jump.
|
||||
// The wider outer runway means the slice still changes roughly once per
|
||||
// viewport, not once per scroll frame. Grouped sections remain eager — the
|
||||
// same call the .slint makes (:265-277).
|
||||
//
|
||||
// TAIL FADE (opt-in via armTailFade(), see the block below): the arrival half
|
||||
// of the Load-more round. OFF unless a host arms it, so the two hosts that do
|
||||
@@ -67,9 +70,12 @@ Column {
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 0
|
||||
|
||||
// --- Windowing band (row indices), sampled ---------------------------
|
||||
// --- Windowing band (row indices) ------------------------------------
|
||||
property int bandFirst: 0
|
||||
property int bandLast: 9999
|
||||
// Start bounded too: the first model publish may precede
|
||||
// Component.onCompleted, and an eager sentinel would briefly instantiate
|
||||
// the entire result set before the first sample replaces it.
|
||||
property int bandLast: 0
|
||||
readonly property bool windowed: flick !== null && !root.isGrouped
|
||||
|
||||
function sampleBand() {
|
||||
@@ -83,40 +89,58 @@ Column {
|
||||
var rowsTop = root.contentOffset + (listMode ? 32 : 0)
|
||||
var top = root.flick.contentY - rowsTop
|
||||
var h = root.flick.height
|
||||
// One viewport of prefetch on each side.
|
||||
root.bandFirst = Math.max(0, Math.floor((top - h) / pitch))
|
||||
root.bandLast = Math.max(0, Math.ceil((top + 2 * h) / pitch))
|
||||
// Two viewports on each side. ensureBandCoverage() refreshes when the
|
||||
// viewport enters the inner half, leaving a full viewport mounted even
|
||||
// after the refresh edge and enough lead for image incubation.
|
||||
root.bandFirst = Math.max(0, Math.floor((top - 2 * h) / pitch))
|
||||
root.bandLast = Math.max(0, Math.ceil((top + 3 * h) / pitch))
|
||||
}
|
||||
|
||||
function scheduleBandSample() {
|
||||
if (root.windowed && root.visible && !bandSampler.running)
|
||||
bandSampler.start()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: bandSampler
|
||||
interval: 40
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
function refreshBand() {
|
||||
if (root.windowed && root.visible) {
|
||||
root.sampleBand()
|
||||
root.reportArtWindow()
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBandCoverage() {
|
||||
if (!root.windowed || !root.visible)
|
||||
return
|
||||
var listMode = root.viewMode === "list"
|
||||
var pitch = listMode ? 64 + root.listRowGap
|
||||
: root.cardHeight + root.cardGap
|
||||
var rowsTop = root.contentOffset + (listMode ? 32 : 0)
|
||||
var top = root.flick.contentY - rowsTop
|
||||
var h = Math.max(1, root.flick.height)
|
||||
var cols = listMode ? 1 : Math.max(1, Math.floor(
|
||||
(root.width + root.cardGap) / (root.cardWidth + root.cardGap)))
|
||||
var totalRows = Math.ceil(root.albums.length / cols)
|
||||
if (totalRows <= 0 || top + h <= 0 || top >= totalRows * pitch)
|
||||
return
|
||||
var visibleFirst = Math.max(0, Math.floor(top / pitch))
|
||||
var visibleLast = Math.max(0, Math.ceil((top + h) / pitch))
|
||||
var innerRunway = Math.max(1, Math.ceil(h / pitch))
|
||||
// A scrollbar seek may jump across the whole slice in one signal;
|
||||
// normal wheel motion reaches an inner edge about once per viewport.
|
||||
if (visibleFirst < root.bandFirst || visibleLast > root.bandLast
|
||||
|| (visibleFirst > innerRunway
|
||||
&& visibleFirst - root.bandFirst < innerRunway)
|
||||
|| (visibleLast < totalRows - innerRunway
|
||||
&& root.bandLast - visibleLast < innerRunway))
|
||||
root.refreshBand()
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root.flick
|
||||
ignoreUnknownSignals: true
|
||||
function onContentYChanged() { root.scheduleBandSample() }
|
||||
function onHeightChanged() { root.scheduleBandSample() }
|
||||
function onContentYChanged() { root.ensureBandCoverage() }
|
||||
function onHeightChanged() { root.refreshBand() }
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
root.sampleBand()
|
||||
root.reportArtWindow()
|
||||
}
|
||||
onWidthChanged: root.scheduleBandSample()
|
||||
onViewModeChanged: root.scheduleBandSample()
|
||||
onVisibleChanged: root.scheduleBandSample()
|
||||
Component.onCompleted: root.refreshBand()
|
||||
onWidthChanged: root.refreshBand()
|
||||
onViewModeChanged: root.refreshBand()
|
||||
onVisibleChanged: root.refreshBand()
|
||||
|
||||
// --- Windowed artwork -------------------------------------------------
|
||||
//
|
||||
@@ -299,8 +323,7 @@ Column {
|
||||
// A new page appended -> recompute the exact mounted slice before
|
||||
// asking for its covers. ONE handler per signal: declaring a second
|
||||
// `onAlbumsChanged` is a hard qmlcachegen error.
|
||||
root.sampleBand()
|
||||
root.reportArtWindow()
|
||||
root.refreshBand()
|
||||
}
|
||||
onGroupedChanged: root._maybeStartTailFade()
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ Rectangle {
|
||||
/// mode only). Default off, so the two catalog call sites are untouched.
|
||||
property bool selectMode: false
|
||||
property bool checked: false
|
||||
readonly property bool pulled: root.item.qobuzUnavailable === true
|
||||
readonly property int cacheStatus: root.item.cacheStatus !== undefined
|
||||
? root.item.cacheStatus : 0
|
||||
readonly property bool pulledDead: root.pulled && root.cacheStatus !== 3
|
||||
/// `modifiers` rides straight off the mouse event: Shift is what turns
|
||||
/// a click into a range (controls/SelectionModel.qml).
|
||||
signal toggleSelect(int modifiers)
|
||||
@@ -61,7 +65,7 @@ Rectangle {
|
||||
width: parent ? parent.width : 0
|
||||
height: 64
|
||||
radius: theme.radiusSm
|
||||
color: rowHovered ? theme.surfaceHover
|
||||
color: rowHovered && !root.pulledDead ? theme.surfaceHover
|
||||
: (rowIndex % 2 === 1 ? theme.alphaTier(4) : "transparent")
|
||||
|
||||
MouseArea {
|
||||
@@ -69,66 +73,78 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
cursorShape: root.pulledDead && !root.selectMode
|
||||
? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
onClicked: function (mouse) {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
// In select mode the row is a selection target, not a menu
|
||||
// host (Tauri/Slint parity: a right-click toggles nothing
|
||||
// there either).
|
||||
if (!root.selectMode)
|
||||
rowMenu.openAtCursor(rowArea, mouse.x, mouse.y)
|
||||
root.openMenu(rowArea, mouse.x, mouse.y)
|
||||
return
|
||||
}
|
||||
if (root.selectMode) root.toggleSelect(mouse.modifiers)
|
||||
else QbzAlbum.openAlbum(root.item.id || "")
|
||||
else if (!root.pulledDead) QbzAlbum.openAlbum(root.item.id || "")
|
||||
}
|
||||
}
|
||||
|
||||
CardMenu {
|
||||
id: rowMenu
|
||||
menuWidth: 196
|
||||
// A JS expression, not a literal: the block row is CONDITIONAL. The
|
||||
// function form also re-evaluates on `trRev`, which the literal
|
||||
// already did through each `tr(…, trRev)` call.
|
||||
entries: {
|
||||
var t = QbzSession.tr
|
||||
var r = QbzSession.trRev
|
||||
var m = [
|
||||
{ "label": t("Open album", r), "icon": "library-big", "action": "open" },
|
||||
{ "label": t("Play", r), "icon": "play-fill", "action": "play" },
|
||||
{ "label": t("Play next", r), "icon": "list-start", "action": "next" },
|
||||
{ "label": t("Play later", r), "icon": "list-plus", "action": "later" },
|
||||
{ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" },
|
||||
]
|
||||
// AlbumListRow.slint:434-441 gates the row on a non-local/plex
|
||||
// source. `home_qt::HomeCard` carries NO `source` field, so this
|
||||
// reads "" and the row shows — correct for these two catalog-only
|
||||
// surfaces (Discover Browse, Label Releases). Written defensively
|
||||
// so a producer that starts emitting `source` is honoured without
|
||||
// a second edit here.
|
||||
var src = root.item.source || ""
|
||||
if (src !== "local" && src !== "plex")
|
||||
m.push({ "label": t("Block this album", r), "icon": "blind-eye", "action": "block" })
|
||||
return m
|
||||
function menuEntries() {
|
||||
var t = QbzSession.tr
|
||||
var r = QbzSession.trRev
|
||||
var m = []
|
||||
if (!root.pulledDead) {
|
||||
m.push({ "label": t("Open album", r), "icon": "library-big", "action": "open" })
|
||||
m.push({ "label": t("Play", r), "icon": "play-fill", "action": "play" })
|
||||
m.push({ "label": t("Play next", r), "icon": "list-start", "action": "next" })
|
||||
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" })
|
||||
}
|
||||
onPicked: function (a) {
|
||||
var id = root.item.id || ""
|
||||
if (id === "") return
|
||||
if (a === "open") QbzAlbum.openAlbum(id)
|
||||
else if (a === "play") QbzPlayer.playAlbum(id)
|
||||
// `artUrl`, never `artPath`: the store keeps a denormalized cover
|
||||
// url and a file:// cache path is dead on any other machine.
|
||||
else if (a === "block") QbzBlacklist.blockAlbum(id, root.item.title || "",
|
||||
root.item.artist || "", root.item.artUrl || "")
|
||||
else QbzPlayer.enqueueAlbum(id, a)
|
||||
var src = root.item.source || ""
|
||||
if (!root.pulledDead && src !== "local" && src !== "plex")
|
||||
m.push({ "label": t("Block this album", r), "icon": "blind-eye", "action": "block" })
|
||||
return m
|
||||
}
|
||||
function menuAction(a) {
|
||||
var id = root.item.id || ""
|
||||
if (id === "") return
|
||||
if (root.pulledDead) return
|
||||
if (a === "open") QbzAlbum.openAlbum(id)
|
||||
else if (a === "play") QbzPlayer.playAlbum(id)
|
||||
// `artUrl`, never `artPath`: the store keeps a denormalized cover url
|
||||
// and a file:// cache path is dead on any other machine.
|
||||
else if (a === "block") QbzBlacklist.blockAlbum(id, root.item.title || "",
|
||||
root.item.artist || "", root.item.artUrl || "")
|
||||
else QbzPlayer.enqueueAlbum(id, a)
|
||||
}
|
||||
Loader {
|
||||
id: rowMenuLoader
|
||||
active: false
|
||||
sourceComponent: CardMenu {
|
||||
menuWidth: 196
|
||||
entries: root.menuEntries()
|
||||
onPicked: function (a) { root.menuAction(a) }
|
||||
}
|
||||
}
|
||||
function openMenu(anchor, x, y) {
|
||||
if (root.menuEntries().length === 0)
|
||||
return
|
||||
rowMenuLoader.active = true
|
||||
rowMenuLoader.item.openAtCursor(anchor, x, y)
|
||||
}
|
||||
function releaseForReuse() {
|
||||
if (rowMenuLoader.item)
|
||||
rowMenuLoader.item.close()
|
||||
rowMenuLoader.active = false
|
||||
}
|
||||
ListView.onPooled: root.releaseForReuse()
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: root.colGap
|
||||
opacity: root.pulledDead ? 0.5 : 1.0
|
||||
|
||||
// Selection checkbox — takes the leading slot in select mode
|
||||
// (MultiSelectBar's companion; the art cell keeps its own width so
|
||||
@@ -162,6 +178,19 @@ Rectangle {
|
||||
source: root.artSource !== "" ? root.artSource : (root.item.artPath || "")
|
||||
radius: 4
|
||||
}
|
||||
Rectangle {
|
||||
visible: root.pulledDead
|
||||
anchors.fill: parent
|
||||
radius: 4
|
||||
color: theme.alphaTier(60)
|
||||
QbzIcon {
|
||||
name: "circle-alert"
|
||||
width: 18
|
||||
height: 18
|
||||
anchors.centerIn: parent
|
||||
tintName: "favorite"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,10 +238,19 @@ Rectangle {
|
||||
width: root.colQuality
|
||||
height: parent.height
|
||||
QualityBadgeFull {
|
||||
visible: !root.pulledDead
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tier: root.item.qualityTier || ""
|
||||
detail: root.item.qualityDetail || ""
|
||||
}
|
||||
Text {
|
||||
visible: root.pulledDead
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Unavailable", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
@@ -228,6 +266,7 @@ Rectangle {
|
||||
Item {
|
||||
width: root.colOverflow
|
||||
height: parent.height
|
||||
visible: !root.pulledDead
|
||||
Rectangle {
|
||||
width: 32
|
||||
height: 32
|
||||
@@ -246,7 +285,7 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: function (mouse) { rowMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
|
||||
onClicked: function (mouse) { root.openMenu(moreArea, mouse.x, mouse.y) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,53 @@ Rectangle {
|
||||
readonly property int atmoReach: 1 + 8 + 26
|
||||
|
||||
// The view's album + url-keyed cover map (artwork pipeline).
|
||||
readonly property var album: JSON.parse(QbzAlbum.albumJson)
|
||||
//
|
||||
// Applied imperatively so a SAME-ALBUM deferred rail publish can preserve
|
||||
// the ListView offset. Binding `model` to a freshly parsed JS array makes
|
||||
// QQuickItemView reset contentY whenever Similar albums / Suggestions / More
|
||||
// from this artist lands — the forced jump the owner caught while reading.
|
||||
property var album: ({})
|
||||
property string documentAlbumId: ""
|
||||
property int albumDocumentEpoch: 0
|
||||
property real albumRestoreY: 0
|
||||
|
||||
function parseAlbumDocument() {
|
||||
try {
|
||||
return JSON.parse(QbzAlbum.albumJson)
|
||||
} catch (e) {
|
||||
return ({})
|
||||
}
|
||||
}
|
||||
|
||||
function applyAlbumDocument() {
|
||||
var next = root.parseAlbumDocument()
|
||||
var nextHeader = next.header || ({})
|
||||
var nextId = nextHeader.id !== undefined && nextHeader.id !== null
|
||||
? String(nextHeader.id) : ""
|
||||
var sameAlbum = nextId !== "" && nextId === root.documentAlbumId
|
||||
var savedY = sameAlbum ? pageFlick.contentY : 0
|
||||
root.albumDocumentEpoch += 1
|
||||
var epoch = root.albumDocumentEpoch
|
||||
root.album = next
|
||||
if (nextId !== "")
|
||||
root.documentAlbumId = nextId
|
||||
if (!sameAlbum || savedY <= pageFlick.originY)
|
||||
return
|
||||
root.albumRestoreY = savedY
|
||||
// The model reset and its content-height polish happen after the
|
||||
// document assignment. One deferred restore coalesces a publish burst
|
||||
// and clamps if the replacement document is genuinely shorter.
|
||||
Qt.callLater(function () {
|
||||
if (epoch !== root.albumDocumentEpoch
|
||||
|| nextId !== root.documentAlbumId)
|
||||
return
|
||||
pageFlick.forceLayout()
|
||||
var minY = pageFlick.originY
|
||||
var maxY = minY + Math.max(0, pageFlick.contentHeight - pageFlick.height)
|
||||
pageFlick.contentY = Math.max(minY, Math.min(root.albumRestoreY, maxY))
|
||||
})
|
||||
}
|
||||
|
||||
readonly property var albumHeader: album.header || ({})
|
||||
readonly property var tracks: album.tracks || []
|
||||
// `header.awards`, NOT `album.awards`. It is a field of AlbumHeader
|
||||
@@ -252,7 +298,15 @@ Rectangle {
|
||||
root.setToggleState("blocked", value)
|
||||
}
|
||||
}
|
||||
Component.onCompleted: { syncAlbumState(); dispatchCovers() }
|
||||
Component.onCompleted: {
|
||||
root.applyAlbumDocument()
|
||||
syncAlbumState()
|
||||
dispatchCovers()
|
||||
}
|
||||
Connections {
|
||||
target: QbzAlbum
|
||||
function onAlbumJsonChanged() { root.applyAlbumDocument() }
|
||||
}
|
||||
onAlbumChanged: { syncAlbumState(); dispatchCovers() }
|
||||
// The derived binding settles AFTER onAlbumChanged fires (stale race) —
|
||||
// redispatch when the header itself updates.
|
||||
|
||||
@@ -11,18 +11,19 @@
|
||||
// :36-103 fixed 56px header — artist kicker + bucket title LEFT, the sort
|
||||
// select RIGHT, both vertically centred on y=25
|
||||
// :131-138 body Column padding 32 / 32, top 8, bottom 100, spacing 0
|
||||
// :121-129 infinite scroll: within 600px of the bottom, has-more, and
|
||||
// neither a first page nor a tail page already in flight
|
||||
// :121-129 originally used infinite scroll. Qt uses the shared explicit
|
||||
// Load-more control so a tail publish never changes the Flickable
|
||||
// extent from inside its momentum handler.
|
||||
// :140-203 THREE mutually exclusive body states — loading, error+Retry,
|
||||
// empty — then the grid, then the tail spinner
|
||||
// :205-231 200x266 cards, gap 24, view-mode hard-wired "grid"
|
||||
// :242-256 scrollbar below the header
|
||||
//
|
||||
// WHAT THIS PAGE DELIBERATELY DOES NOT HAVE (checked against the .slint, not
|
||||
// against its cousins): no search field, no group-by, no Hi-Res filter, no
|
||||
// grid/list toggle, and no "Load more" button. LabelReleasesView has all five;
|
||||
// this is a sibling of DiscoverBrowseView, not of that page. One sort select is
|
||||
// the entire toolbar.
|
||||
// against its cousins): no search field, no group-by, no Hi-Res filter and no
|
||||
// grid/list toggle. The explicit Load-more affordance is a Qt performance
|
||||
// policy shared by every paginated View-all; one sort select remains the
|
||||
// entire toolbar.
|
||||
//
|
||||
// NAV BUTTONS: the .slint draws a NavButtons pair at x=32 and starts its text
|
||||
// stack 16px after it. In this port back/forward live in the shell header
|
||||
@@ -205,22 +206,6 @@ Rectangle {
|
||||
contentHeight: page.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
// Infinite scroll, guarded exactly as the .slint is (:123-126).
|
||||
// There is NO "Load more" button on this page.
|
||||
onContentYChanged: {
|
||||
if (contentY + height >= contentHeight - 600
|
||||
&& root.doc.hasMore === true
|
||||
&& root.doc.loadMoreLoading !== true
|
||||
&& root.doc.loading !== true) {
|
||||
// Snapshot the ids already on screen so ONLY the page
|
||||
// that lands fades in. BEFORE the bridge call, which
|
||||
// may republish synchronously
|
||||
// (LabelReleasesView.qml:327's rule).
|
||||
collection.armTailFade()
|
||||
QbzArtist.releasesLoadMore()
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: page
|
||||
width: parent.width
|
||||
@@ -328,12 +313,26 @@ Rectangle {
|
||||
contentOffset: 8
|
||||
}
|
||||
|
||||
// --- Tail spinner (.slint:233-237) ---------------------
|
||||
Item { visible: root.doc.loadMoreLoading === true; width: 1; height: 24 }
|
||||
QbzSpinner {
|
||||
visible: root.doc.loadMoreLoading === true
|
||||
size: 28
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
Item {
|
||||
visible: root.albums.length > 0 && root.doc.hasMore === true
|
||||
width: parent.width - 64
|
||||
height: visible ? loadMore.height : 0
|
||||
|
||||
QbzLoadMore {
|
||||
id: loadMore
|
||||
width: parent.width
|
||||
buttonHeight: 32
|
||||
busy: root.doc.loadMoreLoading === true
|
||||
skeleton: "cards"
|
||||
cellW: 224
|
||||
cellH: 290
|
||||
onClicked: {
|
||||
// Arm before the bridge call: a cached page
|
||||
// may republish synchronously.
|
||||
collection.armTailFade()
|
||||
QbzArtist.releasesLoadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// "awardalbums".
|
||||
//
|
||||
// A search box over the LOADED set, the award name as the page title, and an
|
||||
// infinite-scroll grid that asks for the next `/award/getAlbums` page as the
|
||||
// tail comes into view. Same four exclusive body branches as the landing
|
||||
// explicitly paginated grid over `/award/getAlbums`. Same four exclusive body
|
||||
// branches as the landing
|
||||
// page, in the reference's order: loading · error+retry · no-results (a search
|
||||
// that matched nothing) · the grid.
|
||||
//
|
||||
@@ -49,18 +49,6 @@ Rectangle {
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
clip: true
|
||||
|
||||
// Infinite scroll. Gated on the SAME four conditions the reference
|
||||
// gates on — not loading, not already loading more, there IS more, and
|
||||
// no active query — so a filtered view never pages in behind it.
|
||||
onContentYChanged: {
|
||||
if (contentHeight - (contentY + height) < 600
|
||||
&& root.doc.hasMore === true
|
||||
&& root.doc.loadingMore !== true
|
||||
&& !root.loading
|
||||
&& root.query === "")
|
||||
QbzHome.awardAlbumsLoadMore()
|
||||
}
|
||||
|
||||
Column {
|
||||
id: col
|
||||
width: page.width - 64
|
||||
@@ -194,14 +182,26 @@ Rectangle {
|
||||
contentOffset: collection.y
|
||||
}
|
||||
|
||||
// Tail spinner for the next page.
|
||||
Item {
|
||||
visible: root.doc.loadingMore === true
|
||||
visible: !root.loading && !root.loadError
|
||||
&& root.albums.length > 0
|
||||
&& root.doc.hasMore === true
|
||||
&& root.query === ""
|
||||
width: parent.width
|
||||
height: visible ? 72 : 0
|
||||
QbzSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 28
|
||||
height: visible ? loadMore.height : 0
|
||||
|
||||
QbzLoadMore {
|
||||
id: loadMore
|
||||
width: parent.width
|
||||
buttonHeight: 32
|
||||
busy: root.doc.loadingMore === true
|
||||
skeleton: "cards"
|
||||
cellW: 224
|
||||
cellH: 290
|
||||
onClicked: {
|
||||
collection.armTailFade()
|
||||
QbzHome.awardAlbumsLoadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
// :44 fixed 56px header — title LEFT, search / genre / grid-list RIGHT
|
||||
// :139 body padding 32 / 32, top 8, bottom 100
|
||||
// :26 200x266 cards, gap 24; :29 64px list rows, gap 4
|
||||
// :126 infinite scroll: within 600px of the bottom, has-more, not already
|
||||
// loading, and NO active search (the filter is client-side, so paging
|
||||
// under one would bring back unfiltered rows)
|
||||
// :126 used to be infinite scroll. Qt deliberately promotes that trigger to
|
||||
// the shared explicit Load-more control: republishing a larger model
|
||||
// from inside `onContentYChanged` changes the Flickable extent while it
|
||||
// still carries momentum, which is the source of the tail rubber-band.
|
||||
// :205 scrollbar below the header; :233 genre popup anchored y=56
|
||||
//
|
||||
// NAV BUTTONS: the .slint draws a NavButtons pair at x=32 and starts the
|
||||
@@ -153,16 +154,6 @@ Rectangle {
|
||||
contentHeight: page.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
// Infinite scroll, guarded exactly as the .slint is.
|
||||
onContentYChanged: {
|
||||
if (contentY + height >= contentHeight - 600
|
||||
&& root.doc.hasMore === true
|
||||
&& !QbzHome.discoverBrowseLoadingMore
|
||||
&& !QbzHome.discoverBrowseLoading
|
||||
&& root.query === "")
|
||||
QbzHome.discoverBrowseLoadMore()
|
||||
}
|
||||
|
||||
Column {
|
||||
id: page
|
||||
width: parent.width
|
||||
@@ -189,8 +180,10 @@ Rectangle {
|
||||
}
|
||||
|
||||
AlbumCollection {
|
||||
id: collection
|
||||
visible: !QbzHome.discoverBrowseLoading
|
||||
width: parent.width - 64
|
||||
collectionKey: root.doc.endpoint || ""
|
||||
albums: root.items
|
||||
viewMode: root.viewMode
|
||||
cardWidth: 200
|
||||
@@ -201,11 +194,32 @@ Rectangle {
|
||||
contentOffset: 8
|
||||
}
|
||||
|
||||
Item { visible: QbzHome.discoverBrowseLoadingMore; width: 1; height: 24 }
|
||||
QbzSpinner {
|
||||
visible: QbzHome.discoverBrowseLoadingMore
|
||||
size: 28
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
Item {
|
||||
visible: !QbzHome.discoverBrowseLoading
|
||||
&& root.items.length > 0
|
||||
&& root.doc.hasMore === true
|
||||
&& root.query === ""
|
||||
width: parent.width - 64
|
||||
height: visible ? loadMore.height : 0
|
||||
|
||||
QbzLoadMore {
|
||||
id: loadMore
|
||||
width: parent.width
|
||||
buttonHeight: 32
|
||||
busy: QbzHome.discoverBrowseLoadingMore
|
||||
skeleton: root.viewMode === "list" ? "rows" : "cards"
|
||||
// AlbumCollection pitch: 200x266 + 24px gutter.
|
||||
cellW: 224
|
||||
cellH: 290
|
||||
rowH: 64
|
||||
rowGap: 4
|
||||
rowCount: 2
|
||||
rowArtSize: 44
|
||||
onClicked: {
|
||||
collection.armTailFade()
|
||||
QbzHome.discoverBrowseLoadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,31 @@ Rectangle {
|
||||
function parseFeed(json) {
|
||||
var t = Date.now()
|
||||
var f = JSON.parse(json)
|
||||
var totals = { "tracks": 0, "albums": 0, "artists": 0,
|
||||
"playlists": 0, "labels": 0 }
|
||||
// Normalize the strings once per Rust publish. Library > All can hold
|
||||
// the complete local library, so repeating toLowerCase() in every
|
||||
// search pass and every sort comparison turns one key press into a
|
||||
// large allocation storm. These private fields stay on the parsed JS
|
||||
// rows; they do not enlarge the Rust JSON transport.
|
||||
for (var i = 0; i < f.length; i++) {
|
||||
var it = f[i]
|
||||
it._titleLc = String(it.title || "").toLowerCase()
|
||||
it._artistLc = String(it.artist || "").toLowerCase()
|
||||
it._albumLc = String(it.album || "").toLowerCase()
|
||||
it._genreLc = String(it.genre || "").toLowerCase()
|
||||
if (it.kind === "track") {
|
||||
if (it.group === "favorites") totals.tracks++
|
||||
} else if (it.kind === "album") {
|
||||
if (it.group === "favorites") totals.albums++
|
||||
} else if (it.kind === "artist") totals.artists++
|
||||
else if (it.kind === "playlist") totals.playlists++
|
||||
else if (it.kind === "label") totals.labels++
|
||||
}
|
||||
// The toolbar emptiness gate used to make a second full pass over the
|
||||
// same feed. Carry the result on the Array itself (outside `length`),
|
||||
// so all consumers keep the exact same row array and model identity.
|
||||
f._tabTotals = totals
|
||||
// Was a bare console.log, i.e. printed on every publish for every user
|
||||
// forever. It belongs to the same investigation as the derive timing
|
||||
// right below it, so it now rides the same category and is silent
|
||||
@@ -99,6 +124,25 @@ Rectangle {
|
||||
|
||||
// Decoded-cover map {artKey: file://path}, fed by the signal.
|
||||
property var artMap: ({})
|
||||
// Artwork completions commonly arrive in a burst. Rebinding artMap for
|
||||
// every file made every mounted card re-evaluate once per neighbour — an
|
||||
// O(window²) cold paint on Library > All. Flush one merged map per frame,
|
||||
// and ignore global library-art signals that are not in this body's keep
|
||||
// band (the sidepanel has its own URL-keyed inbox).
|
||||
property var _artInbox: ({})
|
||||
property var _artKeep: ({})
|
||||
property var _artRequested: ({})
|
||||
Timer {
|
||||
id: artFlush
|
||||
interval: 16
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (Object.keys(root._artInbox).length === 0)
|
||||
return
|
||||
root.artMap = Object.assign({}, root.artMap, root._artInbox)
|
||||
root._artInbox = ({})
|
||||
}
|
||||
}
|
||||
|
||||
// --- skeleton pulse ---------------------------------------------------
|
||||
// ONE 900ms Timer drives EVERY placeholder in this view (the loading
|
||||
@@ -290,16 +334,8 @@ Rectangle {
|
||||
// following the reference gate wants (counts.playlists is favorites
|
||||
// only, library_qt.rs:479).
|
||||
readonly property var tabTotals: {
|
||||
var c = { "tracks": 0, "albums": 0, "artists": 0, "playlists": 0, "labels": 0 }
|
||||
for (var i = 0; i < feed.length; i++) {
|
||||
var it = feed[i]
|
||||
if (it.kind === "track") { if (it.group === "favorites") c.tracks++ }
|
||||
else if (it.kind === "album") { if (it.group === "favorites") c.albums++ }
|
||||
else if (it.kind === "artist") c.artists++
|
||||
else if (it.kind === "label") c.labels++
|
||||
else if (it.kind === "playlist") c.playlists++
|
||||
}
|
||||
return c
|
||||
return feed._tabTotals || { "tracks": 0, "albums": 0, "artists": 0,
|
||||
"playlists": 0, "labels": 0 }
|
||||
}
|
||||
readonly property bool tabHasItems:
|
||||
activeTab === "all" || (tabTotals[activeTab] || 0) > 0
|
||||
@@ -422,6 +458,13 @@ Rectangle {
|
||||
// otherwise the item's genre must contain one of the selected
|
||||
// names, so genre-less kinds are excluded.
|
||||
var genres = root.genreNames
|
||||
// The dominant path: All really means all, newest first. Rust has
|
||||
// already merged every source in that order, so do not copy and
|
||||
// rescan a potentially 10k-row array merely to return it intact.
|
||||
if (needle === "" && genres.length === 0
|
||||
&& showPurchases && showFavorites && showFollowing && showLocal
|
||||
&& sortBy === "date" && !sortAsc)
|
||||
return feed
|
||||
for (i = 0; i < feed.length; i++) {
|
||||
var it = feed[i]
|
||||
var isLocal = it.source === "local" || it.source === "plex"
|
||||
@@ -435,10 +478,10 @@ Rectangle {
|
||||
if (!ok) continue
|
||||
}
|
||||
if (needle !== ""
|
||||
&& it.title.toLowerCase().indexOf(needle) < 0
|
||||
&& it.artist.toLowerCase().indexOf(needle) < 0) continue
|
||||
&& it._titleLc.indexOf(needle) < 0
|
||||
&& it._artistLc.indexOf(needle) < 0) continue
|
||||
if (genres.length > 0) {
|
||||
var itemGenre = (it.genre || "").toLowerCase()
|
||||
var itemGenre = it._genreLc
|
||||
if (itemGenre === "") continue
|
||||
var genreHit = false
|
||||
for (var gi = 0; gi < genres.length; gi++) {
|
||||
@@ -452,10 +495,10 @@ Rectangle {
|
||||
// other direction; "date" keeps model order (newest-first),
|
||||
// reversed for oldest (library_all.rs derive).
|
||||
if (sortBy === "title") {
|
||||
items.sort(function (a, b) { return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : 1 })
|
||||
items.sort(function (a, b) { return a._titleLc < b._titleLc ? -1 : 1 })
|
||||
if (!sortAsc) items.reverse()
|
||||
} else if (sortBy === "artist") {
|
||||
items.sort(function (a, b) { return a.artist.toLowerCase() < b.artist.toLowerCase() ? -1 : 1 })
|
||||
items.sort(function (a, b) { return a._artistLc < b._artistLc ? -1 : 1 })
|
||||
if (!sortAsc) items.reverse()
|
||||
} else if (sortAsc) {
|
||||
items = items.slice().reverse()
|
||||
@@ -465,8 +508,8 @@ Rectangle {
|
||||
var tabNeedle = tabSearch.toLowerCase()
|
||||
function hit(it) {
|
||||
return tabNeedle === ""
|
||||
|| it.title.toLowerCase().indexOf(tabNeedle) >= 0
|
||||
|| it.artist.toLowerCase().indexOf(tabNeedle) >= 0
|
||||
|| it._titleLc.indexOf(tabNeedle) >= 0
|
||||
|| it._artistLc.indexOf(tabNeedle) >= 0
|
||||
}
|
||||
var tabGenres = (activeTab === "tracks" || activeTab === "albums")
|
||||
? root.genreNames : []
|
||||
@@ -480,7 +523,7 @@ Rectangle {
|
||||
else if (activeTab === "playlists") keep = x.kind === "playlist"
|
||||
&& (playlistsSubTab === "following" ? x.group === "following" : x.group === "favorites")
|
||||
if (keep && tabGenres.length > 0) {
|
||||
var tg = (x.genre || "").toLowerCase()
|
||||
var tg = x._genreLc
|
||||
keep = false
|
||||
for (var tgi = 0; tgi < tabGenres.length; tgi++) {
|
||||
if (tg !== "" && tg.indexOf(tabGenres[tgi]) >= 0) { keep = true; break }
|
||||
@@ -491,8 +534,8 @@ Rectangle {
|
||||
if (activeTab === "albums" && albumsSort !== "default") {
|
||||
var field = albumsSort.indexOf("artist") === 0 ? "artist" : "title"
|
||||
items.sort(function (a, b) {
|
||||
var av = field === "artist" ? a.artist.toLowerCase() : a.title.toLowerCase()
|
||||
var bv = field === "artist" ? b.artist.toLowerCase() : b.title.toLowerCase()
|
||||
var av = field === "artist" ? a._artistLc : a._titleLc
|
||||
var bv = field === "artist" ? b._artistLc : b._titleLc
|
||||
return av < bv ? -1 : 1
|
||||
})
|
||||
if (albumsSort.slice(-4) === "desc") items.reverse()
|
||||
@@ -504,11 +547,16 @@ Rectangle {
|
||||
// room for a full-width header, which is exactly why the reference
|
||||
// scrolls those two PROPORTIONALLY from the A-Z strip instead of
|
||||
// landing on a separator (FavoritesView.slint:1996, :2008).
|
||||
var lc = function (s) { return (s || "").toLowerCase() }
|
||||
var cachedKey = function (item, key) {
|
||||
if (key === "title") return item._titleLc
|
||||
if (key === "artist") return item._artistLc
|
||||
if (key === "album") return item._albumLc
|
||||
return String(item[key] || "").toLowerCase()
|
||||
}
|
||||
var by = function (keys) {
|
||||
items.sort(function (a, b) {
|
||||
for (var k = 0; k < keys.length; k++) {
|
||||
var av = lc(a[keys[k]]), bv = lc(b[keys[k]])
|
||||
var av = cachedKey(a, keys[k]), bv = cachedKey(b, keys[k])
|
||||
if (av !== bv) return av < bv ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
@@ -692,11 +740,20 @@ Rectangle {
|
||||
// --------------------- windowed artwork -----------------------------
|
||||
// Report the mounted window as artKeys; prune far-away covers.
|
||||
function reportWindow(visibleArray, first, last) {
|
||||
if (visibleArray.length === 0) return
|
||||
if (visibleArray.length === 0) {
|
||||
root._artKeep = ({})
|
||||
root._artRequested = ({})
|
||||
root._artInbox = ({})
|
||||
root.artPending = 0
|
||||
if (Object.keys(root.artMap).length > 0)
|
||||
root.artMap = ({})
|
||||
return
|
||||
}
|
||||
last = Math.min(last, visibleArray.length - 1)
|
||||
if (first > last) return
|
||||
var keys = []
|
||||
var keep = {}
|
||||
var requested = {}
|
||||
var span = last - first + 1
|
||||
var keepLo = Math.max(0, first - span)
|
||||
var keepHi = Math.min(visibleArray.length - 1, last + span)
|
||||
@@ -705,15 +762,19 @@ Rectangle {
|
||||
for (var i = keepLo; i <= keepHi; i++)
|
||||
if (visibleArray[i].kind !== "group-header") keep[visibleArray[i].artKey] = true
|
||||
var m = artMap
|
||||
var inbox = root._artInbox
|
||||
var pending = 0
|
||||
for (i = first; i <= last; i++) {
|
||||
if (visibleArray[i].kind === "group-header") continue
|
||||
var k = visibleArray[i].artKey
|
||||
if (visibleArray[i].imageUrl !== "") {
|
||||
keys.push(k)
|
||||
if (!m[k]) pending++
|
||||
requested[k] = true
|
||||
if (!m[k] && !inbox[k]) pending++
|
||||
}
|
||||
}
|
||||
root._artKeep = keep
|
||||
root._artRequested = requested
|
||||
// Drives the skeleton Timer + the per-item cap (see the pulse block).
|
||||
artWindowFirst = first
|
||||
artPending = pending
|
||||
@@ -721,6 +782,12 @@ Rectangle {
|
||||
for (var key in m) {
|
||||
if (!keep[key]) { delete m[key]; changed = true }
|
||||
}
|
||||
// A late completion for the previous window may already be queued but
|
||||
// not published. Drop it here so the frame flush cannot resurrect a
|
||||
// cover that this report just evicted.
|
||||
for (key in inbox) {
|
||||
if (!keep[key]) delete inbox[key]
|
||||
}
|
||||
if (changed) artMap = Object.assign({}, m)
|
||||
QbzLibrary.libraryArtworkWindow(JSON.stringify(keys))
|
||||
}
|
||||
@@ -728,14 +795,20 @@ Rectangle {
|
||||
Connections {
|
||||
target: QbzLibrary
|
||||
function onLibraryArtworkReady(key, path) {
|
||||
var m = root.artMap
|
||||
// One fewer shimmering tile — when the count hits 0 the pulse
|
||||
// Timer stops on its own.
|
||||
if (!m[key] && root.artPending > 0) root.artPending--
|
||||
m[key] = path
|
||||
// Rebind requires a NEW object reference (same-ref assignment
|
||||
// is not a change in QML).
|
||||
root.artMap = Object.assign({}, m)
|
||||
// This is a global signal. The selected-artist pane, Home and
|
||||
// several detail routes publish URL keys through it too; accepting
|
||||
// those here wakes the whole Library grid for an image it cannot
|
||||
// display. Only the current keep band belongs in this map.
|
||||
if (root._artKeep[key] !== true)
|
||||
return
|
||||
var known = root.artMap[key] || root._artInbox[key] || ""
|
||||
if (known === path)
|
||||
return
|
||||
if (known === "" && root._artRequested[key] === true
|
||||
&& root.artPending > 0)
|
||||
root.artPending--
|
||||
root._artInbox[key] = path
|
||||
if (!artFlush.running) artFlush.start()
|
||||
}
|
||||
// Favourite state settled in the store. Patched IN PLACE, and
|
||||
// `feedChanged()` is deliberately NOT called — that is the WHOLE fix
|
||||
@@ -849,23 +922,28 @@ Rectangle {
|
||||
// `favorite` is passed in rather than read off `item`: the row owns the
|
||||
// live state (see FeedListRow.favorite), `item.isFavorite` is only its
|
||||
// seed.
|
||||
function trackMenuModel(item, favorite) {
|
||||
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" },
|
||||
{ "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" },
|
||||
]
|
||||
function trackMenuModel(item, favorite, pulledDead) {
|
||||
var m = []
|
||||
if (!pulledDead) {
|
||||
m.push({ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" })
|
||||
m.push({ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" })
|
||||
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 (item.artistId !== "") m.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" })
|
||||
if (item.albumId !== "") m.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc", "action": "go-album" })
|
||||
m.push({ "label": favorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev),
|
||||
"icon": favorite ? "heart-filled" : "heart", "action": "favorite" })
|
||||
if (!pulledDead)
|
||||
m.push({ "label": favorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev),
|
||||
"icon": favorite ? "heart-filled" : "heart", "action": "favorite" })
|
||||
return m
|
||||
}
|
||||
// Takes the ROW, not the item — "favorite" has to go through the row's
|
||||
// own property (a write to `item.isFavorite` notifies nothing).
|
||||
function trackAction(row, a) {
|
||||
var item = row.item
|
||||
if ((a === "play" || a === "next" || a === "later" || a === "queue")
|
||||
&& row.pulledDead)
|
||||
return
|
||||
if (a === "play") root.playTrackInContext(item.id)
|
||||
else if (a === "next") QbzPlayer.enqueueTrack(item.id, "next")
|
||||
else if (a === "later") QbzPlayer.enqueueTrack(item.id, "later")
|
||||
@@ -1073,6 +1151,10 @@ Rectangle {
|
||||
cellWidth: 220
|
||||
cellHeight: 266
|
||||
cacheBuffer: 266 * 2
|
||||
// Mixed cards are expensive to construct. FeedGridCell resets
|
||||
// optimistic heart/pin bindings and closes transient popups at
|
||||
// the reuse edge, so native pooling is safe here.
|
||||
reuseItems: true
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.visibleRows
|
||||
@@ -1128,7 +1210,9 @@ Rectangle {
|
||||
anchors.topMargin: 10 + content.barInset
|
||||
height: list.visible ? parent.height - (10 + content.barInset) : 0
|
||||
visible: content.showList
|
||||
cacheBuffer: 44 * 10
|
||||
spacing: root.activeTab === "all" ? 2 : 0
|
||||
cacheBuffer: 50 * 10
|
||||
reuseItems: true
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.visibleRows
|
||||
@@ -1146,7 +1230,7 @@ Rectangle {
|
||||
width: list.width
|
||||
height: modelData && modelData.kind === "group-header"
|
||||
? 34
|
||||
: (root.activeTab === "tracks" ? 50 : 44)
|
||||
: 50
|
||||
Component {
|
||||
id: listHeaderComp
|
||||
Item {
|
||||
@@ -1209,11 +1293,27 @@ Rectangle {
|
||||
view: root
|
||||
item: modelData
|
||||
rowIndex: Math.max(0, index - root.artWindowFirst)
|
||||
displayIndex: index
|
||||
}
|
||||
}
|
||||
sourceComponent: (modelData && modelData.kind === "group-header")
|
||||
? listHeaderComp
|
||||
: (root.activeTab === "tracks" ? trackRowComp : feedRowComp)
|
||||
function releaseLoadedRow() {
|
||||
if (!item)
|
||||
return
|
||||
if (item.recycleActive !== undefined)
|
||||
item.recycleActive = false
|
||||
if (typeof item.releaseForReuse === "function")
|
||||
item.releaseForReuse()
|
||||
}
|
||||
function activateLoadedRow() {
|
||||
if (item && item.recycleActive !== undefined)
|
||||
item.recycleActive = true
|
||||
}
|
||||
onLoaded: activateLoadedRow()
|
||||
ListView.onPooled: releaseLoadedRow()
|
||||
ListView.onReused: activateLoadedRow()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1242,6 +1342,7 @@ Rectangle {
|
||||
clip: true
|
||||
spacing: 2
|
||||
cacheBuffer: 60 * 8
|
||||
reuseItems: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: content.showPlaylistsList ? root.visibleRows : []
|
||||
|
||||
@@ -1398,8 +1499,17 @@ Rectangle {
|
||||
}
|
||||
function listWindowReport() {
|
||||
if (!list.visible) return
|
||||
var first = Math.max(0, Math.floor(list.contentY / 44) - 4)
|
||||
var last = Math.ceil((list.contentY + list.height) / 44) + 4
|
||||
// Variable-height group headers and the All tab's 2px zebra gutter
|
||||
// make arithmetic row pitches approximate. Ask the native ListView
|
||||
// for the real visible indices and keep a four-row artwork runway.
|
||||
var first = list.indexAt(4, list.contentY + 1)
|
||||
var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1)
|
||||
if (first < 0)
|
||||
first = Math.max(0, Math.floor(list.contentY / 50))
|
||||
if (last < 0)
|
||||
last = Math.ceil((list.contentY + list.height) / 50)
|
||||
first = Math.max(0, first - 4)
|
||||
last += 4
|
||||
queueWindowReport(first, Math.min(root.visibleRows.length - 1, last))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Qobuz Playlists "View all" page — QML port of
|
||||
// discover/PlaylistBrowseView.slint: the playlist twin of DiscoverBrowseView
|
||||
// (same fixed 56px header with the shared browse tools, genre overlay,
|
||||
// infinite scroll and scrollbar), plus a single-select category tag bar
|
||||
// explicit pagination and scrollbar), plus a single-select category tag bar
|
||||
// under the header.
|
||||
//
|
||||
// ADR-008: the categories are radio DOTS, never pills (FilterRadio,
|
||||
@@ -54,6 +54,61 @@ Rectangle {
|
||||
readonly property string viewMode: doc.viewMode || "grid"
|
||||
readonly property int headerH: tags.length > 0 ? 92 : 56
|
||||
|
||||
// The playlist collection is custom (mixed own-art / collage cards), so
|
||||
// it owns the same append-only fade AlbumCollection provides to album
|
||||
// pages. The ids present when Load more is clicked remain opaque; only
|
||||
// rows from the page that lands ride `_tailReveal`.
|
||||
property var _tailSeen: null
|
||||
property real _tailReveal: 1.0
|
||||
function clearTailFade() {
|
||||
tailFade.stop()
|
||||
root._tailSeen = null
|
||||
root._tailReveal = 1.0
|
||||
}
|
||||
function armTailFade() {
|
||||
var seen = {}
|
||||
for (var i = 0; i < root.items.length; i++) {
|
||||
var id = root.items[i] ? (root.items[i].id || "") : ""
|
||||
if (id !== "") seen[id] = true
|
||||
}
|
||||
root._tailSeen = seen
|
||||
root._tailReveal = 0.0
|
||||
}
|
||||
function tailOpacity(item) {
|
||||
if (!root._tailSeen || !item)
|
||||
return 1.0
|
||||
var id = item.id || ""
|
||||
return id !== "" && root._tailSeen[id] !== true
|
||||
? root._tailReveal : 1.0
|
||||
}
|
||||
onItemsChanged: {
|
||||
if (!root._tailSeen)
|
||||
return
|
||||
var hasNew = false
|
||||
for (var i = 0; i < root.items.length; i++) {
|
||||
var id = root.items[i] ? (root.items[i].id || "") : ""
|
||||
if (id !== "" && root._tailSeen[id] !== true) {
|
||||
hasNew = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (hasNew) tailFade.restart()
|
||||
else root.clearTailFade()
|
||||
}
|
||||
onSelectedTagChanged: root.clearTailFade()
|
||||
onQueryChanged: root.clearTailFade()
|
||||
|
||||
NumberAnimation {
|
||||
id: tailFade
|
||||
target: root
|
||||
property: "_tailReveal"
|
||||
from: 0.0
|
||||
to: 1.0
|
||||
duration: 220
|
||||
easing.type: Easing.OutCubic
|
||||
onFinished: root.clearTailFade()
|
||||
}
|
||||
|
||||
readonly property string genreSig: {
|
||||
try {
|
||||
var d = JSON.parse(QbzBridge.genreFilterJson)
|
||||
@@ -64,8 +119,10 @@ Rectangle {
|
||||
}
|
||||
property string lastGenreSig: ""
|
||||
onGenreSigChanged: {
|
||||
if (root.lastGenreSig !== "" && root.lastGenreSig !== root.genreSig)
|
||||
if (root.lastGenreSig !== "" && root.lastGenreSig !== root.genreSig) {
|
||||
root.clearTailFade()
|
||||
QbzHome.playlistBrowseGenreChanged()
|
||||
}
|
||||
root.lastGenreSig = root.genreSig
|
||||
}
|
||||
Component.onCompleted: root.lastGenreSig = root.genreSig
|
||||
@@ -240,15 +297,6 @@ Rectangle {
|
||||
contentHeight: page.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
onContentYChanged: {
|
||||
if (contentY + height >= contentHeight - 600
|
||||
&& root.doc.hasMore === true
|
||||
&& !QbzHome.playlistBrowseLoadingMore
|
||||
&& !QbzHome.playlistBrowseLoading
|
||||
&& root.query === "")
|
||||
QbzHome.playlistBrowseLoadMore()
|
||||
}
|
||||
|
||||
Column {
|
||||
id: page
|
||||
width: parent.width
|
||||
@@ -293,16 +341,19 @@ Rectangle {
|
||||
// solved this for the album pages and this one never
|
||||
// got it.
|
||||
//
|
||||
// The band is sampled through a one-shot timer fed by
|
||||
// scroll events rather than bound to `contentY`: this
|
||||
// avoids O(n) work per frame and wakes nothing at rest.
|
||||
// Scroll events run a constant-time coverage guard.
|
||||
// The slice itself changes only after roughly one
|
||||
// viewport of travel, but it changes synchronously:
|
||||
// a fast wheel/scrollbar jump can never outrun the old
|
||||
// slice and paint a completely blank viewport while a
|
||||
// delayed timer catches up.
|
||||
//
|
||||
// The FULL footprint stays on this one Item, while the
|
||||
// Repeater model is only the sampled slice. This avoids
|
||||
// both the old card cost and one Loader shell per
|
||||
// off-screen result. The list arm uses the same band.
|
||||
property int bandFirst: 0
|
||||
property int bandLast: 9999
|
||||
property int bandLast: 0
|
||||
function sampleBand() {
|
||||
if (plGrid.columns <= 0 || !plGrid.visible)
|
||||
return
|
||||
@@ -311,13 +362,8 @@ Rectangle {
|
||||
: plGrid.cardH + plGrid.gap
|
||||
var top = flick.contentY - plGrid.y
|
||||
var h = flick.height
|
||||
plGrid.bandFirst = Math.max(0, Math.floor((top - h) / pitch))
|
||||
plGrid.bandLast = Math.max(0, Math.ceil((top + 2 * h) / pitch))
|
||||
}
|
||||
|
||||
function scheduleBandSample() {
|
||||
if (plGrid.visible && !plBandSampler.running)
|
||||
plBandSampler.start()
|
||||
plGrid.bandFirst = Math.max(0, Math.floor((top - 2 * h) / pitch))
|
||||
plGrid.bandLast = Math.max(0, Math.ceil((top + 3 * h) / pitch))
|
||||
}
|
||||
|
||||
function refreshBand() {
|
||||
@@ -325,6 +371,31 @@ Rectangle {
|
||||
plGrid.reportArtWindow()
|
||||
}
|
||||
|
||||
function ensureBandCoverage() {
|
||||
if (!plGrid.visible || plGrid.columns <= 0)
|
||||
return
|
||||
var listMode = root.viewMode === "list"
|
||||
var pitch = listMode ? plGrid.listH + plGrid.listGap
|
||||
: plGrid.cardH + plGrid.gap
|
||||
var top = flick.contentY - plGrid.y
|
||||
var h = Math.max(1, flick.height)
|
||||
var totalRows = listMode ? root.items.length
|
||||
: Math.ceil(root.items.length / plGrid.columns)
|
||||
if (totalRows <= 0 || top + h <= 0
|
||||
|| top >= totalRows * pitch)
|
||||
return
|
||||
var visibleFirst = Math.max(0, Math.floor(top / pitch))
|
||||
var visibleLast = Math.max(0, Math.ceil((top + h) / pitch))
|
||||
var innerRunway = Math.max(1, Math.ceil(h / pitch))
|
||||
if (visibleFirst < plGrid.bandFirst
|
||||
|| visibleLast > plGrid.bandLast
|
||||
|| (visibleFirst > innerRunway
|
||||
&& visibleFirst - plGrid.bandFirst < innerRunway)
|
||||
|| (visibleLast < totalRows - innerRunway
|
||||
&& plGrid.bandLast - visibleLast < innerRunway))
|
||||
plGrid.refreshBand()
|
||||
}
|
||||
|
||||
// Covers, one key at a time and only near the viewport
|
||||
// — the Library > All shape. Rust used to download
|
||||
// every missing cover of the page in one batch and then
|
||||
@@ -364,16 +435,10 @@ Rectangle {
|
||||
if (pending.length > 0)
|
||||
QbzShell.sidebarArtworkWindow(JSON.stringify(pending))
|
||||
}
|
||||
Timer {
|
||||
id: plBandSampler
|
||||
interval: 40
|
||||
repeat: false
|
||||
onTriggered: plGrid.refreshBand()
|
||||
}
|
||||
Connections {
|
||||
target: flick
|
||||
function onContentYChanged() { plGrid.scheduleBandSample() }
|
||||
function onHeightChanged() { plGrid.scheduleBandSample() }
|
||||
function onContentYChanged() { plGrid.ensureBandCoverage() }
|
||||
function onHeightChanged() { plGrid.refreshBand() }
|
||||
}
|
||||
Connections {
|
||||
target: root
|
||||
@@ -431,6 +496,7 @@ Rectangle {
|
||||
y: Math.floor(globalIndex / plGrid.columns) * (plGrid.cardH + plGrid.gap)
|
||||
width: plGrid.cardW
|
||||
height: plGrid.cardH
|
||||
opacity: root.tailOpacity(plCell.cardData)
|
||||
|
||||
onCardDataChanged: {
|
||||
if (!browseCard)
|
||||
@@ -473,15 +539,37 @@ Rectangle {
|
||||
item: rowData
|
||||
artSource: plGrid.artOf(rowData)
|
||||
rowIndex: globalIndex
|
||||
opacity: root.tailOpacity(rowData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { visible: QbzHome.playlistBrowseLoadingMore; width: 1; height: 24 }
|
||||
QbzSpinner {
|
||||
visible: QbzHome.playlistBrowseLoadingMore
|
||||
size: 28
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
Item {
|
||||
visible: !QbzHome.playlistBrowseLoading
|
||||
&& root.items.length > 0
|
||||
&& root.doc.hasMore === true
|
||||
&& root.query === ""
|
||||
width: parent.width - 64
|
||||
height: visible ? loadMore.height : 0
|
||||
|
||||
QbzLoadMore {
|
||||
id: loadMore
|
||||
width: parent.width
|
||||
buttonHeight: 32
|
||||
busy: QbzHome.playlistBrowseLoadingMore
|
||||
skeleton: root.viewMode === "list" ? "rows" : "cards"
|
||||
// Playlist grid pitch: 200x246 + 24px gutter.
|
||||
cellW: 224
|
||||
cellH: 270
|
||||
rowH: 60
|
||||
rowGap: 2
|
||||
rowCount: 2
|
||||
rowArtSize: 44
|
||||
onClicked: {
|
||||
root.armTailFade()
|
||||
QbzHome.playlistBrowseLoadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,28 +58,41 @@ Rectangle {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: function (mouse) {
|
||||
if (mouse.button === Qt.RightButton)
|
||||
rowMenu.openAtCursor(rowArea, mouse.x, mouse.y)
|
||||
root.openMenu(rowArea, mouse.x, mouse.y)
|
||||
else
|
||||
QbzBridge.openPlaylist(root.item.id || "")
|
||||
}
|
||||
}
|
||||
|
||||
CardMenu {
|
||||
id: rowMenu
|
||||
menuWidth: 196
|
||||
entries: [
|
||||
{ "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" },
|
||||
]
|
||||
onPicked: function (a) {
|
||||
var id = root.item.id || ""
|
||||
if (id === "") return
|
||||
if (a === "play") QbzPlayer.playPlaylistById(id)
|
||||
else QbzPlayer.enqueuePlaylistById(id, a)
|
||||
Loader {
|
||||
id: rowMenuLoader
|
||||
active: false
|
||||
sourceComponent: CardMenu {
|
||||
menuWidth: 196
|
||||
entries: [
|
||||
{ "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" },
|
||||
]
|
||||
onPicked: function (a) {
|
||||
var id = root.item.id || ""
|
||||
if (id === "") return
|
||||
if (a === "play") QbzPlayer.playPlaylistById(id)
|
||||
else QbzPlayer.enqueuePlaylistById(id, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
function openMenu(anchor, x, y) {
|
||||
rowMenuLoader.active = true
|
||||
rowMenuLoader.item.openAtCursor(anchor, x, y)
|
||||
}
|
||||
function releaseForReuse() {
|
||||
if (rowMenuLoader.item)
|
||||
rowMenuLoader.item.close()
|
||||
rowMenuLoader.active = false
|
||||
}
|
||||
ListView.onPooled: root.releaseForReuse()
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -27,6 +27,47 @@ Item {
|
||||
/// animation cap counts from the viewport, never from a 10k-row model.
|
||||
property int cellIndex: 0
|
||||
|
||||
readonly property string identity: (cell.item.kind || "") + ":" + (cell.item.id || "")
|
||||
function releaseLoadedCard() {
|
||||
if (cardLoader.item
|
||||
&& typeof cardLoader.item.releaseForReuse === "function")
|
||||
cardLoader.item.releaseForReuse()
|
||||
}
|
||||
function restoreMutableBindings() {
|
||||
var loaded = cardLoader.item
|
||||
if (!loaded)
|
||||
return
|
||||
if (cell.item.kind === "album") {
|
||||
loaded.isFavorite = Qt.binding(function () {
|
||||
return cell.item.isFavorite === true
|
||||
})
|
||||
loaded.isPinned = Qt.binding(function () {
|
||||
return cell.item.isPinned === true
|
||||
})
|
||||
} else if (cell.item.kind === "artist"
|
||||
|| cell.item.kind === "playlist") {
|
||||
loaded.isPinned = Qt.binding(function () {
|
||||
return cell.item.isPinned === true
|
||||
})
|
||||
}
|
||||
}
|
||||
function scheduleMutableRestore() {
|
||||
var expected = cell.identity
|
||||
Qt.callLater(function () {
|
||||
if (cell.identity === expected)
|
||||
cell.restoreMutableBindings()
|
||||
})
|
||||
}
|
||||
onItemChanged: {
|
||||
// A popup is a window-overlay child; close it while the old loaded
|
||||
// card still exists, then restore optimistic scalar bindings after
|
||||
// Loader has selected the new kind.
|
||||
cell.releaseLoadedCard()
|
||||
cell.scheduleMutableRestore()
|
||||
}
|
||||
GridView.onPooled: cell.releaseLoadedCard()
|
||||
GridView.onReused: cell.scheduleMutableRestore()
|
||||
|
||||
width: 200
|
||||
height: 246
|
||||
|
||||
@@ -43,6 +84,9 @@ Item {
|
||||
artSource: cell.view.artMap[cell.item.artKey] || ""
|
||||
isFavorite: cell.item.isFavorite
|
||||
isPinned: cell.item.isPinned
|
||||
qobuzUnavailable: cell.item.qobuzUnavailable === true
|
||||
cacheStatus: cell.item.cacheStatus !== undefined
|
||||
? cell.item.cacheStatus : 0
|
||||
// The pin payload's display snapshot: the REMOTE url, never
|
||||
// `artMap` (a local file:// cache path that means nothing after a
|
||||
// cache wipe). Without it every album pinned from the Library grid
|
||||
@@ -122,6 +166,7 @@ Item {
|
||||
}
|
||||
}
|
||||
Loader {
|
||||
id: cardLoader
|
||||
anchors.fill: parent
|
||||
sourceComponent: cell.item.kind === "group-header" ? groupHeaderComp
|
||||
: cell.item.kind === "album" ? albumCardComp
|
||||
@@ -129,6 +174,7 @@ Item {
|
||||
: cell.item.kind === "artist" ? artistCardComp
|
||||
: cell.item.kind === "playlist" ? playlistCardComp
|
||||
: labelCardComp
|
||||
onLoaded: cell.restoreMutableBindings()
|
||||
}
|
||||
// THE fix for "many grey squares that fill in unevenly": each pending
|
||||
// cover shimmers on its own and stops the moment ITS file:// path lands in
|
||||
@@ -146,6 +192,10 @@ Item {
|
||||
|| cell.item.kind === "playlist")
|
||||
&& cell.item.imageUrl !== ""
|
||||
&& (cell.view.artMap[cell.item.artKey] || "") === ""
|
||||
// TrackCard's unavailable scrim is semantic content. A pending
|
||||
// artwork skeleton must never paint over it and turn the honest
|
||||
// state back into an ambiguous grey tile.
|
||||
&& !(cardLoader.item && cardLoader.item.pulledDead === true)
|
||||
phase: cell.view.skelPhase
|
||||
cellIndex: cell.cellIndex
|
||||
}
|
||||
|
||||
@@ -23,12 +23,26 @@ Rectangle {
|
||||
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: 44
|
||||
height: 50
|
||||
radius: 6
|
||||
color: rowArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
color: rowArea.containsMouse && !feedRow.pulledDead
|
||||
? theme.surfaceHover
|
||||
: (feedRow.displayIndex % 2 === 1 ? theme.alphaTier(4) : "transparent")
|
||||
|
||||
// Catalog-withdrawal contract shared with TrackRow/TrackCard. A complete
|
||||
// offline copy keeps the row live; the bridge signal updates that answer
|
||||
// without waiting for a full Library document republish.
|
||||
readonly property bool pulled: (feedRow.item.kind === "track"
|
||||
|| feedRow.item.kind === "album")
|
||||
&& feedRow.item.qobuzUnavailable === true
|
||||
property int cacheStatus: feedRow.item.cacheStatus !== undefined
|
||||
? feedRow.item.cacheStatus : 0
|
||||
readonly property bool pulledDead: 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)
|
||||
@@ -36,13 +50,41 @@ Rectangle {
|
||||
// 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
|
||||
})
|
||||
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" })
|
||||
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()
|
||||
}
|
||||
// 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
|
||||
@@ -54,6 +96,16 @@ Rectangle {
|
||||
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.
|
||||
@@ -61,10 +113,16 @@ Rectangle {
|
||||
id: rowArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
cursorShape: feedRow.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (feedRow.item.kind === "track") feedRow.view.playTrackInContext(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "album") QbzAlbum.openAlbum(feedRow.item.id)
|
||||
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)
|
||||
@@ -76,19 +134,20 @@ Rectangle {
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: 12
|
||||
opacity: feedRow.pulledDead ? 0.5 : 1.0
|
||||
// Col — artwork (round for artists) + hover play.
|
||||
Rectangle {
|
||||
width: 44
|
||||
height: 44
|
||||
width: 36
|
||||
height: 36
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: feedRow.item.kind === "artist" ? 22 : 6
|
||||
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" ? 22 : 6
|
||||
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.
|
||||
@@ -124,14 +183,16 @@ Rectangle {
|
||||
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"
|
||||
visible: (feedRow.item.kind === "track" || feedRow.item.kind === "album"
|
||||
|| feedRow.item.kind === "playlist")
|
||||
&& !feedRow.pulledDead
|
||||
anchors.fill: parent
|
||||
radius: feedRow.item.kind === "artist" ? 22 : 6
|
||||
radius: feedRow.item.kind === "artist" ? 18 : 5
|
||||
color: "#a6000000"
|
||||
opacity: lrPlayArea.containsMouse ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
@@ -143,8 +204,14 @@ Rectangle {
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (feedRow.item.kind === "track") feedRow.view.playTrackInContext(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "album") QbzPlayer.playAlbum(feedRow.item.id)
|
||||
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).
|
||||
@@ -152,10 +219,21 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 - 44 - 116 - (srcCol.visible ? 44 : 0)
|
||||
width: parent.width - 36 - 116 - (srcCol.visible ? 44 : 0)
|
||||
- 150 - 18 - 30 - 6 * 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
@@ -163,7 +241,8 @@ Rectangle {
|
||||
width: parent.width
|
||||
height: 18
|
||||
text: feedRow.item.title
|
||||
color: lrTitleArea.containsMouse ? theme.accent : theme.textPrimary
|
||||
color: lrTitleArea.containsMouse && !feedRow.pulledDead
|
||||
? theme.accent : theme.textPrimary
|
||||
font.pixelSize: 14
|
||||
font.weight: theme.weightMedium
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
@@ -172,10 +251,16 @@ Rectangle {
|
||||
id: lrTitleArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
cursorShape: feedRow.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (feedRow.item.kind === "track") feedRow.view.playTrackInContext(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "album") QbzAlbum.openAlbum(feedRow.item.id)
|
||||
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)
|
||||
@@ -278,10 +363,23 @@ Rectangle {
|
||||
color: "transparent"
|
||||
QualityMini {
|
||||
visible: (feedRow.item.kind === "album" || feedRow.item.kind === "track")
|
||||
&& feedRow.item.qualityTier !== ""
|
||||
&& 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 {
|
||||
@@ -321,13 +419,9 @@ Rectangle {
|
||||
sourceComponent: CardMenu {
|
||||
menuWidth: 196
|
||||
entries: feedRow.item.kind === "track"
|
||||
? feedRow.view.trackMenuModel(feedRow.item, feedRow.favorite)
|
||||
: feedRow.item.kind === "album" ? [
|
||||
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
|
||||
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
||||
{ "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" },
|
||||
]
|
||||
? 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" },
|
||||
]
|
||||
@@ -338,12 +432,10 @@ Rectangle {
|
||||
]
|
||||
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 === "album") QbzAlbum.openAlbum(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "playlist") QbzBridge.openPlaylist(feedRow.item.id)
|
||||
if (feedRow.item.kind === "playlist") QbzBridge.openPlaylist(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "label") QbzHome.openLabel(feedRow.item.id)
|
||||
} else if (a === "play") {
|
||||
if (feedRow.item.kind === "album") QbzPlayer.playAlbum(feedRow.item.id)
|
||||
} else if (a === "go-artist") {
|
||||
QbzArtist.openArtist(feedRow.item.id)
|
||||
} else if (a === "favorite") {
|
||||
@@ -354,7 +446,18 @@ Rectangle {
|
||||
}
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ Item {
|
||||
clip: true
|
||||
spacing: root.rowGap
|
||||
cacheBuffer: 64 * 8
|
||||
reuseItems: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.rows
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ Item {
|
||||
anchors.rightMargin: 22
|
||||
clip: true
|
||||
cacheBuffer: 56 * 8
|
||||
reuseItems: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.entries
|
||||
|
||||
|
||||
Reference in new issue
Block a user