Files
qbz/crates/qbz-qt/qml/kiosk/KioskLibrary.qml
T
vicrodh 17e08e93de fix(library): restore offline visibility and unify filters and sorting
Recover missing offline library entries, recognize cached-only libraries, and refresh availability after cache changes. Unify per-tab filters, compact the toolbar, and add reversible sort criteria with complete available purchase metadata.

cx
2026-09-09 17:08:33 -06:00

774 lines
33 KiB
QML

// KioskLibrary — the kiosk Library (favourites) view.
//
// ── WHAT THE AUDIT FOUND ──────────────────────────────────────────────────
// Three release blockers, all fixed here.
//
// 1. IT OPENED ON TRACKS. `activeTab` started at "tracks" and `KioskShell`
// navigated Library with the tab "tracks", so entering Library from the
// rail mounted the track list. Contract §2.2: Library opens on ALBUMS —
// from the rail, from a fresh mount and from any programmatic navigation
// that carries no tab. Back/Forward is the one exception, and it is the
// point: an entry that recorded Tracks restores Tracks.
//
// 2. THREE TABS WERE UNBOUNDED. Artists, Labels and Playlists mounted a
// `Repeater` over the whole subset. K0 measured the Artists tab at the
// 10 000-artist fixture: 130 037 visual items, 20 006 Loaders, 10 000 live
// images and 8 357 ms to idle; Playlists and Labels, 26 037 items and 2 000
// images each (evidence/runtime-baseline/partial-initial.jsonl). Every tab
// is a GridView or a ListView now — the mounted delegate count is the
// viewport plus one row of overscan, whatever the model holds.
//
// 3. COVERS WERE CAPPED, NOT WINDOWED. `albumCoverCap = 60` reproduced the
// Slint's frozen FAV_ALBUMS_WINDOW literally: album 61 kept a bare tile
// forever, and the non-album tabs went the other way and dispatched EVERY
// row's key at once. Both are replaced by one rule — report the mounted
// band, release what scrolls away.
//
// ── WHY THE GRID IS INLINE AND NOT KioskAlbumGrid ─────────────────────────
// The primitive takes `albums` as a plain array and its delegate binds
// `album: modelData`, so a resolved cover can only reach a card by REBUILDING
// that array. Handing a GridView a new model runs QQuickItemView::setModel(),
// which resets contentY to 0 and rebuilds every delegate — i.e. the grid would
// jump to the top each time a batch of covers landed, which is exactly while
// the user is looking at it. The grid below keeps the model IMMUTABLE (it
// changes only when the feed does) and lets each mounted cell bind its own
// `artwork` out of `artMap`, so an arrival re-evaluates a handful of strings
// and moves nothing. Everything else — pitch algebra, `positionViewAtIndex`
// focus, one row of overscan — is the primitive's, deliberately.
//
// ── ONE CARD, THREE SHAPES ────────────────────────────────────────────────
// KioskCard already declares `round`, which switches it to a circular tile
// with centred text: that is the artist/label shape. Playlists are the square
// shape over `covers[0]` (the first MEMBER-album cover — `artMap[artKey]`
// would be the playlist's own graphic, blank for most). So all four grids
// share one delegate and there is no per-cell Loader.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
color: "transparent"
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
property real pad: 16
// =====================================================================
// Tabs
// =====================================================================
// ALBUMS FIRST, and it is the default. The strip order follows.
readonly property var tabIds: ["albums", "tracks", "artists", "playlists", "labels"]
property string activeTab: "albums"
function tabLabel(id) {
return id === "albums" ? root.t("Releases")
: id === "tracks" ? root.t("Tracks")
: id === "artists" ? root.t("Artists")
: id === "playlists" ? root.t("Playlists")
: root.t("Labels")
}
/// THE recording setter: the strip, the router handshake and the NavRail
/// all land here, and the history entry is pushed with the OUTGOING state.
function activateTab(tab) {
if (!tab || tab === root.activeTab || root.tabIds.indexOf(tab) < 0)
return
nav.recordTab(tab)
root.activeTab = tab
}
// ContentRouter's one external tab seam. `sequence` makes re-selecting the
// same entry re-apply.
property var tabNavigationRequest: ({})
onTabNavigationRequestChanged: {
if (root.tabNavigationRequest && root.tabNavigationRequest.tab)
root.activateTab(root.tabNavigationRequest.tab)
}
onActiveTabChanged: {
root.publishNav()
root.resetWindow()
}
Component.onCompleted: root.publishNav()
// =====================================================================
// History — the shared stack, this view's opaque state on it
// =====================================================================
KioskNavigation {
id: nav
route: "library"
snapshot: ({ "activeTab": root.activeTab })
onRestore: function (saved) {
if (!saved)
return
var tab = typeof saved.activeTab === "string" ? saved.activeTab : ""
// A restored entry keeps the tab it recorded, Tracks included; an
// unknown one falls back to the contract default rather than
// leaving the view on a tab that no longer exists.
root.activeTab = root.tabIds.indexOf(tab) >= 0 ? tab : "albums"
}
}
// =====================================================================
// The merged feed and its five subsets
// =====================================================================
// ONE guarded parse: a raw JSON.parse inside a binding throws on the
// pre-publish frame and takes the whole view down.
readonly property var feed: root.parseFeed()
function parseFeed() {
try {
var parsed = JSON.parse(QbzLibrary.libraryJson)
return (parsed instanceof Array) ? parsed : []
} catch (e) {
return []
}
}
/// `group` empty = the kind alone decides (artists, labels).
function subset(kind, group) {
var out = []
var f = root.feed
for (var i = 0; i < f.length; i++) {
var x = f[i]
if (x.kind === kind && (group === "" || x.group === group))
out.push(x)
}
return out
}
readonly property var trackRows: root.activeTab === "tracks" ? root.subset("track", "favorites") : []
readonly property var albumRows: root.activeTab === "albums" ? root.subset("album", "favorites") : []
readonly property var artistRows: root.activeTab === "artists" ? root.subset("artist", "") : []
readonly property var playlistRows: root.activeTab === "playlists" ? root.subset("playlist", "favorites") : []
readonly property var labelRows: root.activeTab === "labels" ? root.subset("label", "") : []
/// The rows the mounted tab renders. IMMUTABLE while the feed is: a cover
/// arrival must not replace it (see the header).
readonly property var activeRows: root.activeTab === "albums" ? root.albumRows
: root.activeTab === "tracks" ? root.trackRows
: root.activeTab === "artists" ? root.artistRows
: root.activeTab === "playlists" ? root.playlistRows
: root.labelRows
readonly property bool ready:
!QbzLibrary.libraryLoading && QbzLibrary.libraryError === ""
function emptyTextFor(tab) {
return tab === "albums" ? root.t("No albums in your Library yet.")
: tab === "tracks" ? root.t("No tracks in your Library yet.")
: tab === "artists" ? root.t("No artists in your Library yet.")
: tab === "playlists" ? root.t("No playlists in your Library yet.")
: root.t("No labels in your Library yet.")
}
// =====================================================================
// Covers — the MOUNTED band, never a fixed cap
// =====================================================================
// {artKey: "file://…"}. Arrivals are coalesced into one rebind per frame:
// rebinding per arrival re-evaluates every mounted cell's `artwork` once
// per cover, which is quadratic in the window.
property var artMap: ({})
property var _artInbox: ({})
/// The last reported band, as [first, last] into `activeRows`.
property int _windowFirst: -1
property int _windowLast: -1
function artPathOf(key) { return root.artMap[key] || "" }
Timer {
id: artFlush
interval: 16
repeat: false
onTriggered: {
// A rebind needs a NEW object reference — a same-ref assignment is
// not a change in QML.
root.artMap = Object.assign({}, root.artMap, root._artInbox)
root._artInbox = ({})
}
}
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
root._artInbox[key] = path
if (!artFlush.running)
artFlush.start()
}
}
/// Report the mounted band. Keys inside the band plus one band of margin
/// survive; everything else is dropped, so a long scroll does not grow the
/// map without bound. A key already resolved is never re-requested — a
/// re-request costs Rust a stat per key, which a scroll would pay per pass.
function reportWindow(first, last, pixels) {
var rows = root.activeRows
if (rows.length === 0 || first < 0 || last < first) {
root._windowFirst = -1
root._windowLast = -1
return
}
first = Math.max(0, first)
last = Math.min(rows.length - 1, last)
root._windowFirst = first
root._windowLast = last
var span = last - first + 1
var lo = Math.max(0, first - span)
var hi = Math.min(rows.length - 1, last + span)
var keep = ({})
var i, key
for (i = lo; i <= hi; i++) {
key = rows[i] ? rows[i].artKey : ""
if (key)
keep[key] = true
}
var map = root.artMap
var changed = false
for (key in map) {
if (!keep[key]) {
delete map[key]
changed = true
}
}
for (key in root._artInbox)
if (!keep[key])
delete root._artInbox[key]
if (changed)
root.artMap = Object.assign({}, map)
var missing = []
var seen = ({})
for (i = first; i <= last; i++) {
key = rows[i] ? rows[i].artKey : ""
if (!key || seen[key])
continue
seen[key] = true
if (map[key] !== undefined || root._artInbox[key] !== undefined)
continue
missing.push(key)
}
if (missing.length > 0)
QbzLibrary.kioskArtworkWindow(JSON.stringify(missing), Math.ceil((pixels || 64) * Screen.devicePixelRatio))
}
/// A tab switch invalidates the band; the incoming body reports its own.
function resetWindow() {
root._windowFirst = -1
root._windowLast = -1
}
// =====================================================================
// Card objects
// =====================================================================
// The RESOLVED path is read per cell out of `artMap`, so an arrival
// re-evaluates the mounted cells and leaves the model alone.
function albumCard(row) {
if (!row)
return ({})
return {
"id": row.id, "title": row.title || "", "artist": row.artist || "",
"qualityTier": row.qualityTier || "",
"artwork": root.artPathOf(row.artKey)
}
}
/// Artists and labels both carry the display name in `title`.
function personCard(row) {
if (!row)
return ({})
return {
"id": row.id, "title": row.title || "", "artist": "",
"qualityTier": "", "artwork": root.artPathOf(row.artKey)
}
}
/// A playlist's cover is `covers[0]` — the first MEMBER-album cover, which
/// is what the reference's collage slot 0 holds. `artMap[artKey]` would be
/// the playlist's OWN graphic, blank for every playlist that has none.
function playlistCard(row) {
if (!row)
return ({})
return {
"id": row.id, "title": row.title || "", "artist": "",
"qualityTier": "",
"artwork": row.covers && row.covers.length > 0 ? row.covers[0] : ""
}
}
function trackCard(row) {
if (!row)
return ({})
return {
"id": row.id, "title": row.title || "", "artist": row.artist || "",
"duration": row.duration || "",
"artwork": root.artPathOf(row.artKey)
}
}
function cardFor(tab, row) {
return tab === "albums" ? root.albumCard(row)
: tab === "playlists" ? root.playlistCard(row)
: root.personCard(row)
}
// =====================================================================
// Activation
// =====================================================================
// Every one of these records its own history entry and publishes the new
// route from Rust, so the kiosk and Full UI take the same stack.
function openRow(tab, id) {
if (!id)
return
if (tab === "albums")
QbzAlbum.openAlbum(id)
else if (tab === "artists")
QbzArtist.openArtist(id)
else if (tab === "playlists")
QbzBridge.openPlaylist(id)
else if (tab === "labels")
QbzHome.openLabel(id)
}
/// The clicked row plays the whole VISIBLE list from there — the Qt member
/// that reproduces the reference's play-track action.
function playTrack(id) {
var rows = root.trackRows
var ids = []
for (var i = 0; i < rows.length; i++)
ids.push(rows[i].id)
QbzLibrary.libraryPlayVisible(JSON.stringify(ids), id)
}
// =====================================================================
// Nav geometry
// =====================================================================
property int _navColumns: 1
function publishNav() {
QbzKioskNav.publishNav(root.tabIds.length, Math.max(1, root._navColumns),
root.tabIds.length + root.activeRows.length, false)
}
onActiveRowsChanged: root.publishNav()
// Enter on a tab entry drives the same switch a tap does; `index < tabs`
// keeps this handler and the mounted body's disjoint.
Connections {
target: QbzKioskNav
function onActivateSeqChanged() {
if (!QbzKioskNav.navActive || QbzKioskNav.zone !== "content")
return
if (QbzKioskNav.index < 0 || QbzKioskNav.index >= QbzKioskNav.tabs)
return
var id = root.tabIds[QbzKioskNav.index]
if (id !== undefined)
root.activateTab(id)
}
}
// =====================================================================
// Tab strip — 64px, whole-cell hit areas
// =====================================================================
Rectangle {
id: tabStrip
anchors.left: root.left
anchors.right: root.right
anchors.top: root.top
height: 64
color: theme.surfaceMain
Flickable {
id: tabScroll
anchors.fill: parent
anchors.leftMargin: root.pad
anchors.rightMargin: root.pad
contentWidth: tabsRow.width
contentHeight: height
clip: true
flickableDirection: Flickable.HorizontalFlick
boundsBehavior: Flickable.StopAtBounds
Row {
id: tabsRow
height: tabScroll.height
spacing: 6
// A FIXED five-entry list, not a data collection.
Repeater {
model: root.tabIds
delegate: Rectangle {
id: tab
required property string modelData
required property int index
readonly property bool active: root.activeTab === tab.modelData
readonly property bool navFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& QbzKioskNav.index === tab.index
width: Math.max(88, tabText.implicitWidth + 28)
height: tabsRow.height
color: tab.active
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.10)
: "transparent"
radius: theme.radiusSm
border.width: tab.navFocused ? 2 : 0
border.color: theme.accent
Text {
id: tabText
anchors.centerIn: parent
text: root.tabLabel(tab.modelData)
color: tab.active ? theme.textPrimary : theme.textMuted
font.pixelSize: 16
font.weight: theme.weightSemibold
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 10
anchors.rightMargin: 10
anchors.bottom: parent.bottom
anchors.bottomMargin: 6
height: 3
radius: 2
color: tab.active ? theme.accent : "transparent"
}
MouseArea {
anchors.fill: parent
onClicked: root.activateTab(tab.modelData)
}
}
}
}
}
Rectangle {
anchors.left: tabStrip.left
anchors.right: tabStrip.right
y: tabStrip.height - 1
height: 1
color: theme.borderSubtle
}
}
// =====================================================================
// Content — ONE tab mounted at a time
// =====================================================================
// `Loader.active`, never `visible: false`: a hidden GridView keeps a
// viewport and a model and goes on refilling it (the LibraryView finding
// — half of every mount spent on the body that was not showing).
Item {
id: content
anchors.left: root.left
anchors.right: root.right
anchors.top: tabStrip.bottom
anchors.bottom: root.bottom
clip: true
// THE GRID for Albums, Artists, Playlists and Labels.
//
// ONE instance for all four: only one is ever mounted, and a tab
// switch is a model change, which a GridView answers by rebuilding
// its delegates from the top — which is what a tab switch should do.
// Four separate instances would only duplicate the pitch algebra.
Loader {
anchors.fill: parent
active: root.ready && root.activeTab !== "tracks"
sourceComponent: Item {
GridView {
id: feedGrid
anchors.fill: parent
/// Circular tile + centred text — the artist/label shape.
readonly property bool roundCells: root.activeTab === "artists"
|| root.activeTab === "labels"
/// The smallest cell that keeps a card's tap area well past
/// the 64px primary target at every supported width: album
/// cards land on 4 columns at 800px and 7 at 1280px, the
/// round ones on 5 and 9.
readonly property real minCell: feedGrid.roundCells ? 132 : 168
readonly property real gap: 14
readonly property real labelH: 52
readonly property real availableRowWidth: Math.max(0, feedGrid.width - 2 * root.pad + feedGrid.gap)
readonly property real maxArtHeight: Math.max(64, feedGrid.height - root.pad - feedGrid.labelH)
readonly property int columns: feedGrid.width > 0
? Math.min(Math.max(1, Math.floor(feedGrid.availableRowWidth / (64 + feedGrid.gap))),
Math.max(2, Math.floor(feedGrid.availableRowWidth / (feedGrid.minCell + feedGrid.gap)),
feedGrid.height > 0 ? Math.ceil(feedGrid.availableRowWidth / (feedGrid.maxArtHeight + feedGrid.gap)) : 1))
: 1
// The pitch is derived so the view's own
// floor(available / cellWidth) lands on `columns` exactly;
// the trailing cell's gap covers part of the right padding.
readonly property real rightPad: Math.max(0, root.pad - feedGrid.gap)
readonly property real avail:
Math.max(0, feedGrid.width - root.pad - feedGrid.rightPad)
readonly property real cardW: Math.max(0, feedGrid.cellWidth - feedGrid.gap)
model: root.activeRows
cellWidth: Math.max(1, Math.floor(feedGrid.avail / feedGrid.columns))
cellHeight: Math.max(1, feedGrid.cardW + feedGrid.labelH + feedGrid.gap)
leftMargin: root.pad
rightMargin: feedGrid.rightPad
topMargin: root.pad
bottomMargin: root.pad
clip: true
boundsBehavior: Flickable.StopAtBounds
// THE bound on live delegates: the viewport plus one row
// above and below. Nothing outside it is instantiated.
cacheBuffer: Math.max(0, Math.ceil(feedGrid.cellHeight))
reuseItems: true
// The ring is QbzKioskNav's; a highlighted current item
// would draw a second, contradictory one.
highlight: null
currentIndex: -1
keyNavigationEnabled: false
readonly property int focusedItem: QbzKioskNav.index - QbzKioskNav.tabs
readonly property bool itemFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& feedGrid.focusedItem >= 0
&& feedGrid.focusedItem < root.activeRows.length
/// Scroll the focused card in by the MINIMUM amount, and do
/// nothing when it is already in. Issued from a function on
/// a settled layout, never from a binding that feeds layout
/// (the "Recursion detected" panic class).
function scrollFocusIntoView() {
if (feedGrid.itemFocused)
feedGrid.positionViewAtIndex(feedGrid.focusedItem, GridView.Contain)
}
// The mounted band, derived from geometry rather than by
// walking delegates: exact for a uniform grid and
// independent of when the view happened to create one.
function rowAt(y) {
return feedGrid.cellHeight > 0
? Math.floor((y - feedGrid.originY) / feedGrid.cellHeight) : 0
}
function reportBand() {
if (!feedGrid.visible || root.activeRows.length === 0) {
root.resetWindow()
return
}
var firstRow = Math.max(0, feedGrid.rowAt(feedGrid.contentY) - 1)
var lastRow = Math.max(0, feedGrid.rowAt(
feedGrid.contentY + feedGrid.height - 1)) + 1
root.reportWindow(firstRow * feedGrid.columns,
(lastRow + 1) * feedGrid.columns - 1, feedGrid.cardW)
}
/// Coalesced: several of these fire on one flick frame, and
/// the artwork pass should see one call per row crossed.
function reportSoon() { Qt.callLater(feedGrid.reportBand) }
onContentYChanged: feedGrid.reportSoon()
onHeightChanged: feedGrid.reportSoon()
onWidthChanged: feedGrid.reportSoon()
onModelChanged: feedGrid.reportSoon()
onVisibleChanged: feedGrid.reportSoon()
onColumnsChanged: {
root._navColumns = feedGrid.columns
root.publishNav()
feedGrid.reportSoon()
}
Component.onCompleted: {
root._navColumns = feedGrid.columns
root.publishNav()
feedGrid.reportSoon()
}
Connections {
target: QbzKioskNav
function onIndexChanged() {
Qt.callLater(feedGrid.scrollFocusIntoView)
}
function onActivateSeqChanged() {
if (feedGrid.itemFocused)
root.openRow(root.activeTab,
root.activeRows[feedGrid.focusedItem].id)
}
}
delegate: Item {
id: cell
required property var modelData
required property int index
width: feedGrid.cellWidth
height: feedGrid.cellHeight
KioskCard {
artSize: feedGrid.cardW
round: feedGrid.roundCells
// The resolved cover is read PER CELL out of
// `artMap`, so an arrival re-evaluates a handful of
// strings instead of replacing the model.
album: root.cardFor(root.activeTab, cell.modelData)
navFocused: feedGrid.itemFocused
&& feedGrid.focusedItem === cell.index
onClicked: function (id) { root.openRow(root.activeTab, id) }
}
}
}
// The scope carries the TAB, so returning to a different one
// cannot pour this offset into it.
ScrollMemory { target: feedGrid; scope: "library:" + root.activeTab }
}
}
// THE TRACK LIST.
Loader {
anchors.fill: parent
active: root.ready && root.activeTab === "tracks"
sourceComponent: Item {
ListView {
id: tracksList
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
clip: true
topMargin: 8
bottomMargin: 8
spacing: 0
// One extra screenful of runway.
cacheBuffer: Math.min(64, Math.max(0, height / 2))
boundsBehavior: Flickable.StopAtBounds
reuseItems: true
model: root.trackRows
readonly property int focusedItem: QbzKioskNav.index - QbzKioskNav.tabs
readonly property bool itemFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& tracksList.focusedItem >= 0
&& tracksList.focusedItem < root.trackRows.length
function scrollFocusIntoView() {
if (tracksList.itemFocused)
tracksList.positionViewAtIndex(tracksList.focusedItem,
ListView.Contain)
}
function reportBand() {
if (!tracksList.visible || root.trackRows.length === 0) {
root.resetWindow()
return
}
var first = tracksList.indexAt(4, tracksList.contentY + 1)
var last = tracksList.indexAt(
4, tracksList.contentY + Math.max(1, tracksList.height) - 1)
if (first < 0)
first = Math.max(0, Math.floor(
(tracksList.contentY - tracksList.originY) / 64))
if (last < 0)
last = first + 12
root.reportWindow(Math.max(0, first - 1), last + 1, 48)
}
function reportSoon() { Qt.callLater(tracksList.reportBand) }
onContentYChanged: tracksList.reportSoon()
onHeightChanged: tracksList.reportSoon()
onModelChanged: tracksList.reportSoon()
onVisibleChanged: tracksList.reportSoon()
Component.onCompleted: {
root._navColumns = 1
root.publishNav()
tracksList.reportSoon()
}
Connections {
target: QbzKioskNav
function onIndexChanged() {
Qt.callLater(tracksList.scrollFocusIntoView)
}
function onActivateSeqChanged() {
if (tracksList.itemFocused)
root.playTrack(root.trackRows[tracksList.focusedItem].id)
}
}
delegate: KioskTrackRow {
id: trackDelegate
required property var modelData
required property int index
width: tracksList.width
// 64px is the contract's primary touch target; the
// shared primitive's desktop-era 62 is overridden here
// rather than edited.
height: 64
track: root.trackCard(trackDelegate.modelData)
navFocused: tracksList.itemFocused
&& tracksList.focusedItem === trackDelegate.index
onClicked: root.playTrack(trackDelegate.modelData.id)
}
}
ScrollMemory { target: tracksList; scope: "library:tracks" }
}
}
// Loading / error / empty. Static, mounts no artwork and no model, and
// unmounts entirely once the tab is populated.
KioskSkeleton {
anchors.fill: parent
kind: root.activeTab === "tracks" ? "list" : "grid"
columns: Math.max(2, root._navColumns)
pad: root.pad
gap: 14
rowHeight: 64
rowArtSize: 46
roundCells: root.activeTab === "artists" || root.activeTab === "labels"
loading: QbzLibrary.libraryLoading
error: QbzLibrary.libraryError
empty: root.ready && root.activeRows.length === 0
emptyText: root.emptyTextFor(root.activeTab)
}
// Retry. The kiosk primitive draws the message and no action, and a
// route that can only say "it failed" is a route the user has to
// leave. 64px, over the message.
Rectangle {
id: retry
visible: QbzLibrary.libraryError !== "" && !QbzLibrary.libraryLoading
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 56
width: Math.max(140, retryText.implicitWidth + 44)
height: 64
radius: theme.radiusSm
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Text {
id: retryText
anchors.centerIn: parent
text: root.t("Retry")
color: theme.textPrimary
font.pixelSize: 16
font.weight: theme.weightSemibold
}
MouseArea {
anchors.fill: parent
onClicked: QbzLibrary.reloadLibrary()
}
}
}
}