Files
qbz/crates/qbz-qt/qml/LibraryView.qml
T
vicrodh 1b81b289d1 feat(qbz-qt): phase 5 — Library view 1:1 with All grid/list and perf marks
Six live tabs (All/Tracks/Albums/Artists/Playlists/Labels) with real
count badges, paged favorites/playlists/purchases loaders, library_all
merge semantics, heart routing (Qobuz API vs local store —
LocalFavoritesService now initialized at session entry), sort popup
with direction carets, grid/list toggle with natively windowed
GridView/ListView delegates, id-keyed artwork via signal with hand-
rolled eviction. Perf marks on the owner's account: 344 items, fetch
wall 2.7-3.4s (97% network, sequential), map+merge ~11ms, JSON
serialize 10ms, QML parse 7ms (~20us/item => ~140ms at the 2200-item
reference scale), RSS +29MB after publish.

km
2026-07-27 14:57:29 -06:00

1068 lines
44 KiB
QML

// Library view — QML port of crates/qbz-ui/ui/favorites/FavoritesView.slint
// + the Library "All" mixed feed (library_all.rs semantics).
//
// Data: QbzBridge.libraryJson (ONE JSON document — the full merged feed;
// tabs/search/sort/source-filters derive HERE in JS, measured per the
// phase brief) + libraryCountsJson (tab badges). Artwork is id-keyed
// through the libraryArtworkReady signal into `artMap` (never a
// wrong-cover race); windows are reported as artKeys via
// libraryArtworkWindow, and artMap entries far outside the viewport are
// pruned (the Slint eviction policy, QML-side).
//
// POC-NOTEs:
// - Genre filter, multi-select, group modes, alpha jumps, play-all /
// shuffle bulk actions, playlists-random, artists sidepanel, albums LIST
// mode, per-tab persist of view/sort state: out of scope (stubs or
// omitted; the All grid+list is the measured 1:1 focus).
// - Offline: the generic OfflinePlaceholder replica mounts (the Slint
// offline RAIL of playable cached favorites needs the offline cache —
// not wired).
// - Sort/search/source state is session-local (no ui_prefs persistence).
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
Rectangle {
id: root
color: theme.surfaceMain
QbzTheme { id: theme }
// ============================ state ==================================
property string activeTab: "all"
readonly property var counts: JSON.parse(QbzBridge.libraryCountsJson)
// Full merged feed (parsed once per publish — timed for the report).
readonly property var feed: parseFeed(QbzBridge.libraryJson)
function parseFeed(json) {
var t = Date.now()
var f = JSON.parse(json)
console.log("[qbz-qt][perf] QML JSON.parse feed: " + (Date.now() - t)
+ "ms (" + f.length + " items, " + json.length + " bytes)")
return f
}
// Decoded-cover map {artKey: file://path}, fed by the signal.
property var artMap: ({})
// All-tab derive state (LibraryAllState semantics).
property string search: ""
property string sortBy: "date" // "date" | "title" | "artist"
property bool sortAsc: false // date: false = newest first
property bool showPurchases: true
property bool showFavorites: true
property bool showFollowing: true
property bool showLocal: true
property string viewMode: "grid" // "grid" | "list"
// Other-tab state.
property string tabSearch: ""
property string albumsSort: "default" // default|title-asc|title-desc|artist-asc
property string playlistsSubTab: "favorites"
// ------------------------- derive (JS) ------------------------------
function visibleItems() {
var items = []
var i
if (activeTab === "all") {
var needle = search.toLowerCase()
var anyGroup = showPurchases || showFavorites || showFollowing
for (i = 0; i < feed.length; i++) {
var it = feed[i]
var isLocal = it.source === "local" || it.source === "plex"
if (isLocal) {
if (!showLocal) continue
} else if (anyGroup) {
var g = it.group
var ok = (g === "purchases" && showPurchases)
|| (g === "favorites" && showFavorites)
|| (g === "following" && showFollowing)
if (!ok) continue
}
if (needle !== ""
&& it.title.toLowerCase().indexOf(needle) < 0
&& it.artist.toLowerCase().indexOf(needle) < 0) continue
items.push(it)
}
// Canonical ascending order per field, then reverse for the
// 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 })
if (!sortAsc) items.reverse()
} else if (sortBy === "artist") {
items.sort(function (a, b) { return a.artist.toLowerCase() < b.artist.toLowerCase() ? -1 : 1 })
if (!sortAsc) items.reverse()
} else if (sortAsc) {
items = items.slice().reverse()
}
return items
}
var tabNeedle = tabSearch.toLowerCase()
function hit(it) {
return tabNeedle === ""
|| it.title.toLowerCase().indexOf(tabNeedle) >= 0
|| it.artist.toLowerCase().indexOf(tabNeedle) >= 0
}
for (i = 0; i < feed.length; i++) {
var x = feed[i]
var keep = false
if (activeTab === "tracks") keep = x.kind === "track" && x.group === "favorites"
else if (activeTab === "albums") keep = x.kind === "album" && x.group === "favorites"
else if (activeTab === "artists") keep = x.kind === "artist"
else if (activeTab === "labels") keep = x.kind === "label"
else if (activeTab === "playlists") keep = x.kind === "playlist"
&& (playlistsSubTab === "following" ? x.group === "following" : x.group === "favorites")
if (keep && hit(x)) items.push(x)
}
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()
return av < bv ? -1 : 1
})
if (albumsSort.slice(-4) === "desc") items.reverse()
}
return items
}
// --------------------- windowed artwork -----------------------------
// Report the mounted window as artKeys; prune far-away covers.
function reportWindow(visibleArray, first, last) {
if (visibleArray.length === 0) return
last = Math.min(last, visibleArray.length - 1)
if (first > last) return
var keys = []
var keep = {}
var span = last - first + 1
var keepLo = Math.max(0, first - span)
var keepHi = Math.min(visibleArray.length - 1, last + span)
for (var i = keepLo; i <= keepHi; i++) keep[visibleArray[i].artKey] = true
for (i = first; i <= last; i++) {
var k = visibleArray[i].artKey
if (visibleArray[i].imageUrl !== "") keys.push(k)
}
var m = artMap
var changed = false
for (var key in m) {
if (!keep[key]) { delete m[key]; changed = true }
}
if (changed) artMap = Object.assign({}, m)
QbzBridge.libraryArtworkWindow(JSON.stringify(keys))
}
Connections {
target: QbzBridge
function onLibraryArtworkReady(key, path) {
var m = root.artMap
m[key] = path
// Rebind requires a NEW object reference (same-ref assignment
// is not a change in QML).
root.artMap = Object.assign({}, m)
}
function onLibraryFavoriteChanged(key, value) {
var f = root.feed
for (var i = 0; i < f.length; i++) {
if (f[i].artKey === key) { f[i].isFavorite = value; break }
}
root.feedChanged()
}
}
// Debounced window reporting (180ms, library_all.rs throttle).
Timer {
id: windowDebounce
interval: 180
onTriggered: root.reportWindow(root.visibleItems(), pendingFirst, pendingLast)
property int pendingFirst: 0
property int pendingLast: 0
}
function queueWindowReport(first, last) {
windowDebounce.pendingFirst = first
windowDebounce.pendingLast = last
windowDebounce.restart()
}
// ============================ components =============================
// Segmented tab (SegmentedTabBar's Segment) with count badge.
component SegTab: Rectangle {
id: segTabRoot
property string label: ""
property int count: 0
property bool active: false
signal clicked()
width: segRow.implicitWidth
height: segRow.implicitHeight
radius: 4
color: active ? theme.surfaceMain
: segArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
id: segRow
leftPadding: 12
rightPadding: parent && parent.count > 0 ? 8 : 12
topPadding: 6
bottomPadding: 6
spacing: 7
Text {
text: parent.parent.label
color: parent.parent.active ? theme.textPrimary : theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
anchors.verticalCenter: parent.verticalCenter
}
Rectangle {
visible: parent.parent.count > 0
width: Math.max(18, countText.implicitWidth + 10)
height: 16
radius: 8
anchors.verticalCenter: parent.verticalCenter
color: parent.parent.active ? "#26ffffff" : "#14ffffff"
Text {
id: countText
anchors.centerIn: parent
text: segTabRoot.count
color: segTabRoot.active ? theme.textPrimary : theme.textSecondary
font.pixelSize: 11
font.weight: theme.weightMedium
}
}
}
// Active underline (redundant shape cue).
Rectangle {
visible: active
x: 4
width: parent.width - 8
height: 2
y: parent.height - 2
radius: 1
color: theme.accent
}
MouseArea {
id: segArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.clicked()
}
}
// Small toolbar toggle button (ToggleButton sm): 30px, active = accent.
component ToolToggle: Rectangle {
property string name: ""
property bool active: false
signal clicked()
width: 30
height: 30
radius: 6
color: active ? theme.surfaceElevated
: ttArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: parent.name
width: 16
height: 16
anchors.centerIn: parent
tintName: parent.active ? "accent"
: ttArea.containsMouse ? "primary" : "secondary"
}
MouseArea {
id: ttArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.clicked()
}
}
// Toolbar search box (ExpandableSearch sm replica, fixed width).
component SearchBox: Rectangle {
property string placeholder: ""
property alias text: edit.text
signal searchEdited()
width: 220
height: 30
radius: 6
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.fill: parent
anchors.leftMargin: 9
anchors.rightMargin: 9
spacing: 6
QbzIcon {
name: "search"
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
TextInput {
id: edit
width: parent.width - 19
height: parent.height
color: theme.textPrimary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: searchEdited()
Text {
visible: edit.text === ""
anchors.fill: parent
text: placeholder
color: theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
}
}
// Card heart (live favorite toggle).
component HeartBtn: Rectangle {
property bool fav: false
property string itemKind: ""
property string itemId: ""
width: 28
height: 28
radius: 14
color: hbArea.containsMouse ? "#3dffffff" : "#24ffffff"
border.width: 1.5
border.color: "#ccffffff"
QbzIcon {
name: parent.fav ? "heart-filled" : "heart"
width: 14
height: 14
anchors.centerIn: parent
tintName: parent.fav ? "favorite" : "primary"
}
MouseArea {
id: hbArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
// Optimistic flip; the signal confirms or rolls back.
parent.fav = !parent.fav
QbzBridge.libraryToggleFavorite(itemKind, itemId)
}
}
}
// Quality mini-badge (hi-res image / cd box).
component QualityMini: Item {
property string tier: ""
visible: tier !== ""
width: tier === "hires" ? 42 : 30
height: 30
Image {
visible: tier === "hires"
source: "assets/hi-res.svg"
width: 42
height: 28
anchors.centerIn: parent
sourceSize: Qt.size(84, 56)
fillMode: Image.PreserveAspectFit
}
Rectangle {
visible: tier === "cd"
width: 30
height: 30
radius: 3
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
QbzIcon { name: "cd"; width: 16; height: 16; anchors.centerIn: parent; tintName: "muted" }
}
}
// ===================== feed cards (inline components) ================
// GRID card — one per kind, 200x246 (AlbumCard metrics).
component FeedGridCard: Rectangle {
property var item: ({})
color: "transparent"
readonly property bool overlayOn: artArea.containsMouse
Column {
spacing: 0
// --- Artwork -------------------------------------------------
Rectangle {
width: 200
height: 200
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
Image {
anchors.fill: parent
source: root.artMap[item.artKey] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
// Artist cards are circular in Slint (ArtistGridCard).
Rectangle {
visible: item.kind === "artist"
anchors.fill: parent
radius: 100
color: "transparent"
border.width: 0
}
// Hover scrim + play overlay (albums + tracks).
Rectangle {
anchors.fill: parent
color: "#000000"
opacity: overlayOn && (item.kind === "album" || item.kind === "track") ? 0.5 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
}
Rectangle {
visible: item.kind === "album" || item.kind === "track"
anchors.centerIn: parent
width: 44
height: 44
radius: 22
opacity: overlayOn ? 1.0 : 0.0
color: playArea.containsMouse ? "#d6ffffff" : "#ffffff"
QbzIcon { name: "play-fill"; width: 18; height: 18; anchors.centerIn: parent; tintName: "black" }
MouseArea {
id: playArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: item.kind === "album" ? QbzBridge.playAlbum(item.id) : QbzBridge.playTrack(item.id)
}
}
// Heart (live).
HeartBtn {
x: parent.width - width - 8
y: 8
fav: item.isFavorite
itemKind: item.kind
itemId: item.id
opacity: (overlayOn || item.isFavorite) ? 1.0 : 0.0
}
// Source badge (show-local): bottom-right hard-drive glyph.
Rectangle {
visible: root.showLocal && (item.source === "local" || item.source === "plex")
x: parent.width - width - 6
y: parent.height - height - 6
width: 24
height: 24
radius: 4
color: "#b3000000"
QbzIcon {
name: "hard-drive"
width: 14
height: 14
anchors.centerIn: parent
tintName: item.source === "plex" ? "accent" : "primary"
}
}
MouseArea {
id: artArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (item.kind === "album") QbzBridge.playAlbum(item.id)
else if (item.kind === "track") QbzBridge.playTrack(item.id)
// artist/playlist/label pages: out of scope (POC-NOTE).
}
}
}
Item { width: 1; height: 6 }
// --- Meta ----------------------------------------------------
Row {
width: 200
spacing: theme.spacingSm
Column {
width: parent.width - (qBadge.visible ? qBadge.width + theme.spacingSm : 0)
spacing: 2
Text {
width: parent.width
height: 20
text: item.title
color: theme.textPrimary
font.pixelSize: theme.fontBody - 2
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Text {
width: parent.width
height: 18
text: item.kind === "track" ? item.artist
: item.subtitle !== "" ? item.subtitle : item.artist
color: theme.textMuted
font.pixelSize: theme.fontLink - 1
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
QualityMini { tier: item.qualityTier; id: qBadge; anchors.verticalCenter: parent.verticalCenter }
}
}
}
// LIST row — 44px (QueueRow-like; FeedListRow approximation).
component FeedListRow: Rectangle {
property var item: ({})
height: 44
radius: theme.radiusSm
color: rowArea.containsMouse ? theme.surfaceHover : "transparent"
// Declared BEFORE the content so the heart button keeps its clicks.
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (item.kind === "album") QbzBridge.playAlbum(item.id)
else if (item.kind === "track") QbzBridge.playTrack(item.id)
}
}
Row {
anchors.fill: parent
anchors.leftMargin: theme.spacingSm
anchors.rightMargin: theme.spacingXs
anchors.topMargin: 4
anchors.bottomMargin: 4
spacing: 9
Rectangle {
width: 34
height: 34
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
clip: true
Image {
anchors.fill: parent
source: root.artMap[item.artKey] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
Column {
width: parent.width - 34 - heartBtn.width
- (qMini.visible ? qMini.width + 9 : 0)
- (durText.visible ? durText.implicitWidth + 9 : 0) - 9 * 2
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: item.title
color: theme.textPrimary
font.pixelSize: 12
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: item.subtitle !== "" ? item.subtitle : item.artist
color: theme.textMuted
font.pixelSize: 10
elide: Text.ElideRight
}
}
QualityMini { id: qMini; tier: item.qualityTier; anchors.verticalCenter: parent.verticalCenter }
Text {
id: durText
visible: item.duration !== ""
anchors.verticalCenter: parent.verticalCenter
text: item.duration
color: theme.textMuted
font.pixelSize: 11
}
Rectangle {
id: heartBtn
width: 28
height: 28
radius: 6
anchors.verticalCenter: parent.verticalCenter
color: lhArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
name: item.isFavorite ? "heart-filled" : "heart"
width: 15
height: 15
anchors.centerIn: parent
tintName: item.isFavorite ? "favorite" : "muted"
}
MouseArea {
id: lhArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.libraryToggleFavorite(item.kind, item.id)
}
}
}
}
// ============================ view ===================================
// Offline gate (OfflinePlaceholder replica; the Slint offline RAIL is
// out of scope — see header note).
Column {
visible: QbzBridge.offline
anchors.centerIn: parent
spacing: 0
QbzIcon {
name: "cloud-off"
width: 56
height: 56
anchors.horizontalCenter: parent.horizontalCenter
tintName: "muted"
}
Item { width: 1; height: 18 }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzBridge.tr("You're offline")
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
Item { width: 1; height: 8 }
Text {
width: 420
text: QbzBridge.offlineMode === 2
? QbzBridge.tr("Offline mode is enabled. Disable it in Settings to use Qobuz.")
: QbzBridge.tr("No internet connection. Your local library and downloads keep working.")
color: theme.textSecondary
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
Column {
anchors.fill: parent
spacing: 0
visible: !QbzBridge.offline
// --- Toolbar: tab menu (left) + per-tab controls (right) ---------
Item {
width: parent.width
height: 56
// FavTabMenu (SegmentedTabBar with count badges).
Rectangle {
x: 32
y: 25 - height / 2
width: tabRow.width
height: tabRow.height
color: theme.surfaceElevated
radius: 6
Row {
id: tabRow
padding: 3
spacing: 4
SegTab { label: QbzBridge.tr("All"); count: counts.all || 0; active: root.activeTab === "all"; onClicked: root.activeTab = "all" }
SegTab { label: QbzBridge.tr("Tracks"); count: counts.tracks || 0; active: root.activeTab === "tracks"; onClicked: root.activeTab = "tracks" }
SegTab { label: QbzBridge.tr("Albums"); count: counts.albums || 0; active: root.activeTab === "albums"; onClicked: root.activeTab = "albums" }
SegTab { label: QbzBridge.tr("Artists"); count: counts.artists || 0; active: root.activeTab === "artists"; onClicked: root.activeTab = "artists" }
SegTab { label: QbzBridge.tr("Playlists"); count: counts.playlists || 0; active: root.activeTab === "playlists"; onClicked: root.activeTab = "playlists" }
SegTab { label: QbzBridge.tr("Labels"); count: counts.labels || 0; active: root.activeTab === "labels"; onClicked: root.activeTab = "labels" }
}
}
// Per-tab controls.
Row {
x: parent.width - width - 32
y: 25 - height / 2
height: 30
spacing: 8
// ===== All toolbar =====
Row {
visible: root.activeTab === "all"
spacing: 8
height: parent.height
SearchBox {
placeholder: QbzBridge.tr("Search your library")
onSearchEdited: {
root.search = text
allSearchDebounce.restart()
}
Timer {
id: allSearchDebounce
interval: 250
onTriggered: root.queueWindowReport(0, 59)
}
}
// Source switches (tooltips are the Slint copies).
ToolToggle { name: "shopping-bag"; active: root.showPurchases; onClicked: root.showPurchases = !root.showPurchases }
ToolToggle { name: "heart"; active: root.showFavorites; onClicked: root.showFavorites = !root.showFavorites }
ToolToggle { name: "user-plus"; active: root.showFollowing; onClicked: root.showFollowing = !root.showFollowing }
ToolToggle { name: "hard-drive"; active: root.showLocal; onClicked: root.showLocal = !root.showLocal }
// Genre filter — INERT stub (out of scope).
ToolToggle { name: "list-filter"; active: false }
// Grid / list toggle.
ToolToggle {
name: root.viewMode === "list" ? "layout-grid" : "list"
active: false
onClicked: root.viewMode = root.viewMode === "list" ? "grid" : "list"
}
// Sort menu (PlaylistView-style: field + direction caret).
Rectangle {
width: allSortRow.width
height: 30
radius: 6
color: allSortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
Row {
id: allSortRow
height: parent.height
leftPadding: 10
rightPadding: 10
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzBridge.tr("Sort") + ": " + (
root.sortBy === "title" ? QbzBridge.tr("Title")
: root.sortBy === "artist" ? QbzBridge.tr("Artist")
: QbzBridge.tr("Date added"))
color: theme.textSecondary
font.pixelSize: 12
}
QbzIcon {
name: root.sortAsc ? "chevron-up" : "chevron-down"
width: 12
height: 12
anchors.verticalCenter: parent.verticalCenter
tintName: "secondary"
}
}
MouseArea {
id: allSortArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: allSortMenu.open()
}
Popup {
id: allSortMenu
x: parent.width - 172
y: parent.height + 4
width: 172
padding: 5
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
Repeater {
model: [
{ "field": "date", "label": QbzBridge.tr("Date added") },
{ "field": "title", "label": QbzBridge.tr("Title") },
{ "field": "artist", "label": QbzBridge.tr("Artist") },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: sortOptArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 6
Text {
width: parent.width - 26
height: parent.height
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
font.weight: root.sortBy === modelData.field ? theme.weightSemibold : theme.weightRegular
verticalAlignment: Text.AlignVCenter
}
QbzIcon {
visible: root.sortBy === modelData.field
name: root.sortAsc ? "chevron-up" : "chevron-down"
width: 12
height: 12
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
}
MouseArea {
id: sortOptArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
// Re-pick flips direction; a
// new field resets to its
// natural default.
if (root.sortBy === modelData.field) {
root.sortAsc = !root.sortAsc
} else {
root.sortBy = modelData.field
root.sortAsc = modelData.field !== "date"
}
allSortMenu.close()
}
}
}
}
}
}
}
}
// ===== Other-tab toolbars =====
SearchBox {
visible: root.activeTab !== "all"
placeholder: QbzBridge.tr("Search")
onSearchEdited: root.tabSearch = text
}
// Albums sort popup (real, JS-side).
Rectangle {
visible: root.activeTab === "albums"
width: albumsSortRow.width
height: 30
radius: 6
color: albumsSortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
Row {
id: albumsSortRow
height: parent.height
leftPadding: 10
rightPadding: 10
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzBridge.tr("Sort") + ": " + root.albumsSort
color: theme.textSecondary
font.pixelSize: 12
}
QbzIcon { name: "chevron-down"; width: 12; height: 12; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
}
MouseArea {
id: albumsSortArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: albumsSortMenu.open()
}
Popup {
id: albumsSortMenu
x: parent.width - 172
y: parent.height + 4
width: 172
padding: 5
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
Repeater {
model: ["default", "title-asc", "title-desc", "artist-asc"]
delegate: Rectangle {
required property string modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: abSortOptArea.containsMouse ? theme.surfaceHover : "transparent"
Text {
anchors.fill: parent
anchors.leftMargin: 8
text: modelData
color: theme.textSecondary
font.pixelSize: 13
font.weight: root.albumsSort === modelData ? theme.weightSemibold : theme.weightRegular
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: abSortOptArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: { root.albumsSort = modelData; albumsSortMenu.close() }
}
}
}
}
}
}
// Playlists sub-tab (Library / Following).
Rectangle {
visible: root.activeTab === "playlists"
width: plSubRow.width
height: 30
radius: 6
color: theme.surfaceElevated
Row {
id: plSubRow
padding: 3
spacing: 4
Repeater {
model: [
{ "id": "favorites", "label": QbzBridge.tr("Library") },
{ "id": "following", "label": QbzBridge.tr("Following") },
]
delegate: Rectangle {
required property var modelData
property bool active: root.playlistsSubTab === modelData.id
width: plSubText.implicitWidth + 20
height: 24
radius: 4
color: active ? theme.surfaceMain : "transparent"
Text {
id: plSubText
anchors.centerIn: parent
text: modelData.label
color: parent.active ? theme.textPrimary : theme.textMuted
font.pixelSize: 12
font.weight: theme.weightMedium
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.playlistsSubTab = modelData.id
}
}
}
}
}
}
}
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
// ===================== SCROLLING CONTENT =========================
Item {
width: parent.width
height: parent.height - 57
clip: true
// Loading.
BusyIndicator {
anchors.centerIn: parent
running: QbzBridge.libraryLoading
visible: QbzBridge.libraryLoading
}
// Error + retry.
Column {
visible: !QbzBridge.libraryLoading && QbzBridge.libraryError !== ""
anchors.centerIn: parent
spacing: 10
Text {
text: QbzBridge.tr("Couldn't load your Library.")
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
Text {
text: QbzBridge.libraryError
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
Rectangle {
width: retryText.implicitWidth + 28
height: 32
radius: 6
anchors.horizontalCenter: parent.horizontalCenter
color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Text {
id: retryText
anchors.centerIn: parent
text: QbzBridge.tr("Retry")
color: theme.textPrimary
font.pixelSize: theme.fontLegal
}
MouseArea {
id: retryArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.reloadLibrary()
}
}
}
// ============ GRID (all tabs; All in grid mode) ==============
GridView {
id: grid
anchors.fill: parent
anchors.leftMargin: 32
anchors.rightMargin: 32
anchors.topMargin: 16
visible: !QbzBridge.libraryLoading && QbzBridge.libraryError === ""
&& (root.activeTab !== "all" || root.viewMode === "grid")
&& root.activeTab !== "tracks"
cellWidth: 220
cellHeight: 266
cacheBuffer: 266 * 2
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.visibleItems()
onContentYChanged: root.gridWindowReport()
onModelChanged: root.gridWindowReport()
onWidthChanged: root.gridWindowReport()
Component.onCompleted: root.gridWindowReport()
delegate: FeedGridCard {
width: 200
height: 246
item: modelData
}
}
// ============ LIST (All list mode + Tracks tab) ==============
ListView {
id: list
anchors.fill: parent
anchors.leftMargin: 32
anchors.rightMargin: 32
anchors.topMargin: 10
visible: !QbzBridge.libraryLoading && QbzBridge.libraryError === ""
&& ((root.activeTab === "all" && root.viewMode === "list")
|| root.activeTab === "tracks")
cacheBuffer: 44 * 10
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.visibleItems()
onContentYChanged: root.listWindowReport()
onModelChanged: root.listWindowReport()
Component.onCompleted: root.listWindowReport()
delegate: FeedListRow {
width: list.width
item: modelData
}
}
}
}
function gridWindowReport() {
var cols = Math.max(1, Math.floor(grid.width / grid.cellWidth))
var firstRow = Math.max(0, Math.floor(grid.contentY / grid.cellHeight) - 1)
var lastRow = Math.ceil((grid.contentY + grid.height) / grid.cellHeight) + 1
var m = grid.model
queueWindowReport(firstRow * cols, Math.min(m.length - 1, lastRow * cols - 1))
}
function listWindowReport() {
var first = Math.max(0, Math.floor(list.contentY / 44) - 4)
var last = Math.ceil((list.contentY + list.height) / 44) + 4
queueWindowReport(first, Math.min(list.model.length - 1, last))
}
}