fix(qt): restore window lifecycle and improve grid artwork

Restore tray-aware close and quit confirmation, circular label covers, and missing label artist portraits. Publish artwork progressively, preload two grid rows, and skip redundant motion reports while preserving data refreshes.

Include regression tests and isolated artwork benchmarks. Verified Linux build, QML checks, and scrolling smoke tests.

cx
This commit is contained in:
vicrodh committed 2026-09-09 21:58:25 -06:00
1 parent 5fab503eb7
commit 2fc03d325d
61 files changed
+1591 -151

No files matched your search

@@ -7008,3 +7008,19 @@ msgstr "Quick Connect fehlgeschlagen: {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "Gib diesen Code unter Einstellungen > Quick Connect in einem bereits angemeldeten Jellyfin-Client ein. QBZ wartet bis zu 5 Minuten."
#: qbz-qt/qml/Main.qml
msgid "Close QBZ?"
msgstr "QBZ schließen?"
#: qbz-qt/qml/Main.qml
msgid "Yes"
msgstr "Ja"
#: qbz-qt/qml/Main.qml
msgid "No"
msgstr "Nein"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "Nicht erneut anzeigen"
@@ -2608,3 +2608,19 @@ msgstr "Quick Connect failed: {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
#: qbz-qt/qml/Main.qml
msgid "Close QBZ?"
msgstr "Close QBZ?"
#: qbz-qt/qml/Main.qml
msgid "Yes"
msgstr "Yes"
#: qbz-qt/qml/Main.qml
msgid "No"
msgstr "No"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "Don't show again"
@@ -7014,3 +7014,19 @@ msgstr "Quick Connect falló: {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "Ingresa este código en Ajustes > Quick Connect desde un cliente Jellyfin con sesión iniciada. QBZ espera hasta 5 minutos."
#: qbz-qt/qml/Main.qml
msgid "Close QBZ?"
msgstr "¿Cerrar QBZ?"
#: qbz-qt/qml/Main.qml
msgid "Yes"
msgstr "Sí"
#: qbz-qt/qml/Main.qml
msgid "No"
msgstr "No"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "No volver a mostrar"
@@ -7006,3 +7006,19 @@ msgstr "Échec de Quick Connect : {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "Saisissez ce code dans Paramètres > Quick Connect depuis un client Jellyfin déjà connecté. QBZ attend jusqu’à 5 minutes."
#: qbz-qt/qml/Main.qml
msgid "Close QBZ?"
msgstr "Fermer QBZ ?"
#: qbz-qt/qml/Main.qml
msgid "Yes"
msgstr "Oui"
#: qbz-qt/qml/Main.qml
msgid "No"
msgstr "Non"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "Ne plus afficher"
@@ -6989,3 +6989,19 @@ msgstr "Quick Connect に失敗しました: {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "ログイン済みの Jellyfin クライアントで「設定 > Quick Connect」にこのコードを入力してください。QBZ は最大5分間待機します。"
#: qbz-qt/qml/Main.qml
msgid "Close QBZ?"
msgstr "QBZを終了しますか?"
#: qbz-qt/qml/Main.qml
msgid "Yes"
msgstr "はい"
#: qbz-qt/qml/Main.qml
msgid "No"
msgstr "いいえ"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "今後表示しない"
@@ -6996,3 +6996,19 @@ msgstr "Quick Connect mislukt: {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "Voer deze code in bij Instellingen > Quick Connect in een Jellyfin-client die al is aangemeld. QBZ wacht maximaal 5 minuten."
#: qbz-qt/qml/Main.qml
msgid "Close QBZ?"
msgstr "QBZ afsluiten?"
#: qbz-qt/qml/Main.qml
msgid "Yes"
msgstr "Ja"
#: qbz-qt/qml/Main.qml
msgid "No"
msgstr "Nee"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "Niet opnieuw tonen"
@@ -7006,3 +7006,19 @@ msgstr "Falha no Quick Connect: {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "Insira este código em Configurações > Quick Connect em um cliente Jellyfin já conectado. O QBZ aguarda até 5 minutos."
#: qbz-qt/qml/Main.qml
msgid "Close QBZ?"
msgstr "Fechar o QBZ?"
#: qbz-qt/qml/Main.qml
msgid "Yes"
msgstr "Sim"
#: qbz-qt/qml/Main.qml
msgid "No"
msgstr "Não"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "Não mostrar novamente"
@@ -7027,3 +7027,19 @@ msgstr "Ошибка Quick Connect: {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "Введите этот код в разделе «Настройки > Quick Connect» клиента Jellyfin, в котором уже выполнен вход. QBZ ждёт до 5 минут."
#: qbz-qt/qml/Main.qml
msgid "Close QBZ?"
msgstr "Закрыть QBZ?"
#: qbz-qt/qml/Main.qml
msgid "Yes"
msgstr "Да"
#: qbz-qt/qml/Main.qml
msgid "No"
msgstr "Нет"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "Больше не показывать"
+2
View File
@@ -399,6 +399,8 @@ fn build_rhi_items() {
cc.file("cxx/qt_vulkan_probe.cpp");
println!("cargo:rerun-if-changed=cxx/win_shell.cpp");
cc.file("cxx/win_shell.cpp");
println!("cargo:rerun-if-changed=cxx/app_lifecycle.cpp");
cc.file("cxx/app_lifecycle.cpp");
// Shell_NotifyIconW tray. No Q_OBJECT, so no moc; the body is inside
// `#ifdef _WIN32` and compiles to nothing on Linux and macOS.
println!("cargo:rerun-if-changed=cxx/win_tray.cpp");
+45
View File
@@ -0,0 +1,45 @@
#include <QtCore/QCoreApplication>
#include <QtCore/QEvent>
#include <QtCore/QObject>
#include <QtGui/QKeyEvent>
namespace {
// Lifecycle commands are reserved even while a text field or mini window
// owns focus. Qt maps ControlModifier to Command on macOS.
class LifecycleFilter final : public QObject {
public:
LifecycleFilter(QObject *parent, void (*request)(bool))
: QObject(parent), request_(request) {}
protected:
bool eventFilter(QObject *receiver, QEvent *event) override {
if (receiver == QCoreApplication::instance() && event->type() == QEvent::Quit) {
event->ignore(); // termination is pending until QML confirms
request_(true);
return true;
}
if (event->type() != QEvent::KeyPress && event->type() != QEvent::ShortcutOverride)
return false;
auto *key = static_cast<QKeyEvent *>(event);
if (key->modifiers() != Qt::ControlModifier
|| (key->key() != Qt::Key_Q && key->key() != Qt::Key_W))
return false;
event->accept();
if (event->type() == QEvent::KeyPress && !key->isAutoRepeat())
request_(key->key() == Qt::Key_Q);
return true;
}
private:
void (*request_)(bool);
};
}
extern "C" void qbz_install_lifecycle_filter(void (*request)(bool)) {
static LifecycleFilter *filter = nullptr;
auto *app = QCoreApplication::instance();
if (!filter && app) {
filter = new LifecycleFilter(app, request);
app->installEventFilter(filter);
}
}
@@ -0,0 +1,34 @@
#include <QtCore/QCoreApplication>
#include <QtCore/QEvent>
#include <QtGui/QKeyEvent>
#include <cassert>
extern "C" void qbz_install_lifecycle_filter(void (*request)(bool));
static int quits = 0;
static int closes = 0;
static void request(bool quit) { quit ? ++quits : ++closes; }
int main(int argc, char **argv) {
QCoreApplication app(argc, argv);
qbz_install_lifecycle_filter(request);
qbz_install_lifecycle_filter(request); // no duplicate installation
QObject focused;
QKeyEvent override(QEvent::ShortcutOverride, Qt::Key_W, Qt::ControlModifier);
QCoreApplication::sendEvent(&focused, &override);
assert(override.isAccepted() && closes == 0);
QKeyEvent close(QEvent::KeyPress, Qt::Key_W, Qt::ControlModifier);
QCoreApplication::sendEvent(&focused, &close);
assert(closes == 1 && quits == 0);
QKeyEvent repeat(QEvent::KeyPress, Qt::Key_W, Qt::ControlModifier, {}, true);
QCoreApplication::sendEvent(&focused, &repeat);
assert(closes == 1);
QKeyEvent unrelated(QEvent::KeyPress, Qt::Key_Q, Qt::ControlModifier | Qt::ShiftModifier);
QCoreApplication::sendEvent(&focused, &unrelated);
assert(quits == 0);
QKeyEvent quit(QEvent::KeyPress, Qt::Key_Q, Qt::ControlModifier);
QCoreApplication::sendEvent(&focused, &quit);
assert(quits == 1);
QEvent nativeQuit(QEvent::Quit);
QCoreApplication::sendEvent(&app, &nativeQuit);
assert(!nativeQuit.isAccepted() && quits == 2 && closes == 1);
}
+51 -15
View File
@@ -100,7 +100,8 @@ ApplicationWindow {
// whenever the setting is off or nothing is loaded. Reactive: the
// binding re-evaluates on every track change and on the toggle itself
// (settings_qt pushes `windowTitleShow` live).
title: QbzShell.windowTitleShow && QbzPlayer.npHasTrack
title: QbzShell.isMacos && !QbzShell.systemTitleBar ? ""
: QbzShell.windowTitleShow && QbzPlayer.npHasTrack
? QbzPlayer.npTitle + " - " + QbzPlayer.npArtist + " | qbz"
: "QBZ"
// Custom chrome (phase 7/12): frameless but OPAQUE — the phase-7
@@ -905,7 +906,11 @@ ApplicationWindow {
// `closeEvent` is the QQuickCloseEvent on the one path that carries one and
// null on the other four.
function closeOrHide(closeEvent) {
var hide = QbzTray.trayLive && QbzTray.closeToTray
if (closeEvent)
closeEvent.accepted = false
if (quitConfirmation.opened)
return
var hide = QbzTray.trayLive && (QbzShell.isMacos || QbzTray.closeToTray)
// The evidence line (2026-08-04): logged RUST-side so it lands in
// qbz.log — console.log only reaches stderr, which owner reports
// never carry. It prints both operands and the arm taken, which is
@@ -929,13 +934,53 @@ ApplicationWindow {
// still alive 25s later). Qt.exit() stops the event loops directly:
// no close events, no veto. Geometry is flushed explicitly below,
// and onAboutToQuit still runs when exec() returns.
if (closeEvent)
closeEvent.accepted = true
requestQuit()
}
property bool quitAccepted: false
function requestQuit() {
if (quitAccepted || quitConfirmation.opened)
return
if (!QbzTray.confirmQuitEnabled()) {
finishQuit()
return
}
if (!window.visible || window.visibility === Window.Minimized)
window.showFromTray()
window.raise()
window.requestActivate()
quitConfirmation.checkboxChecked = false
quitConfirmation.open()
}
function finishQuit() {
if (quitAccepted)
return
quitAccepted = true
QbzTray.armQuitWatchdog()
window.persistWindowGeometryOnExit()
Qt.exit(0)
}
QbzConfirmModal {
id: quitConfirmation
anchors.fill: parent
title: QbzSession.tr("Close QBZ?", QbzSession.trRev)
cancelLabel: QbzSession.tr("No", QbzSession.trRev)
confirmLabel: QbzSession.tr("Yes", QbzSession.trRev)
checkboxLabel: QbzSession.tr("Don't show again", QbzSession.trRev)
danger: false
onCancelled: {
if (screenLoader.item)
screenLoader.item.forceActiveFocus()
}
onConfirmed: {
if (checkboxChecked)
QbzTray.disableQuitConfirmation()
window.finishQuit()
}
}
// The tray's four signals (src/tray_bridge.rs:87-100). Rust owns no window
// (§2.3), so every tray click hops ksni thread -> tray_qt verb ->
// tray_bridge::ui() -> qsignal -> here, and this block is where it finally
@@ -948,6 +993,7 @@ ApplicationWindow {
// the ambiguity that cost a diagnosis on 2026-08-04.
Connections {
target: QbzTray
function onCloseRequested() { window.closeOrHide(null) }
function onWindowShowRequested() {
console.log("[qml] tray -> showFromTray")
window.showFromTray()
@@ -969,17 +1015,7 @@ ApplicationWindow {
}
}
function onQuitRequested() {
console.log("[qml] tray -> quit: persisting geometry")
window.persistWindowGeometryOnExit()
console.log("[qml] tray -> quit: calling Qt.exit(0)")
// Qt.exit(0), NEVER Qt.quit() — see closeOrHide's quit arm: a
// Qt.quit() here is vetoable by any window's onClosing (the
// close-to-tray arm, the mini's unconditional refuse), and that
// veto is exactly how the tray Quit hid the window and left the
// process alive (2026-08-04, three owner reports). The hard-exit
// watchdog for this path was armed Rust-side in tray_qt::quit(),
// before the signal was even emitted.
Qt.exit(0)
window.requestQuit()
}
}
+7
View File
@@ -57,6 +57,10 @@ Rectangle {
property string ribbonKind: ""
// Artwork image source (file://… or "") — the host's cache lookup.
property string artSource: ""
property int artFadeMs: coverImage.immediateGridArtwork ? 0 : 200
readonly property bool artworkReady: coverImage.ready
readonly property bool artworkImmediate: coverImage.immediateGridArtwork
readonly property bool artworkRevealed: coverImage.revealed
// REMOTE cover url, for the pin payload only (the AlbumCard.slint pin
// TouchArea passes `album.artwork-url`). The pinned store keeps a
// denormalized display snapshot taken at pin time, so a host that
@@ -468,9 +472,12 @@ Rectangle {
// Texts above are now bounded. One batch root per grid card.
RoundedImage {
gridArtwork: true
id: coverImage
anchors.fill: parent
source: root.artSource
radius: theme.radiusSm
fadeMs: root.artFadeMs
}
// Empty-well glyph — opt-in, see `placeholderIcon`. Declared
+10 -1
View File
@@ -44,6 +44,9 @@ import "../theme"
Rectangle {
id: root
readonly property bool artworkReady: portraitLoader.item ? portraitLoader.item.ready : false
readonly property bool artworkImmediate: !root.kioskHost && portraitLoader.item
? portraitLoader.item.immediateGridArtwork : false
property bool kioskHost: false
property var item: ({})
@@ -182,6 +185,7 @@ Rectangle {
tintName: "muted"
}
Loader {
id: portraitLoader
anchors.fill: parent
sourceComponent: root.kioskHost ? kioskCover : desktopCover
Component {
@@ -190,7 +194,12 @@ Rectangle {
}
Component {
id: desktopCover
RoundedImage { anchors.fill: parent; source: root.artSource; radius: 95 }
RoundedImage {
gridArtwork: true
anchors.fill: parent
source: root.artSource
radius: 95
}
}
}
// Hover scrim (clipped to the circle by the parent's clip).
@@ -71,6 +71,7 @@ import "../theme"
Item {
id: root
property bool gridArtwork: false
/// 0..9 remote cover URLs, INDEX-SIGNIFICANT (Slint's url1..url9).
property var urls: []
@@ -175,6 +176,7 @@ Item {
Component {
id: customCover
RoundedImage {
gridArtwork: root.gridArtwork
source: root.customCoverPath
radius: root.radius
fit: "crop"
@@ -298,6 +300,7 @@ Item {
// custom cover or the kind glyph — an idle tile is still an Item.
model: root.collage ? root.cellCount : 0
delegate: RoundedImage {
gridArtwork: root.gridArtwork
required property int index
readonly property rect cell: root.cellOf(index)
x: cell.x
+11 -15
View File
@@ -9,8 +9,7 @@
//
// From the Slint reference, kept verbatim:
// - no logo -> indigo(#6366f1)->violet(#8b5cf6) gradient + disc-3 glyph;
// - a logo -> surface-elevated behind a CONTAIN-fit logo, never cropped
// (label logos have varied/transparent backgrounds, unlike album art);
// - a logo -> surface-elevated behind a cover-fit, circular-masked logo;
// - "a label is opened, not played" — the ONLY action a label exposes
// anywhere in the Slint is open ("Go to label", disc-3 icon,
// FavoritesActions.open-label). There is NO play, NO favorite, NO pin
@@ -41,6 +40,8 @@ import "../theme"
Rectangle {
id: root
readonly property bool artworkReady: coverImage.ready
readonly property bool artworkImmediate: coverImage.immediateGridArtwork
property var item: ({})
// Host-resolved artwork path (the AlbumCard artSource pattern).
@@ -94,9 +95,7 @@ Rectangle {
radius: 95
// No clip. It never clipped to the circle — QML's clip is a
// rectangular scissor and ignores `radius`; the round shape is
// each child's own `radius: 95` / RoundedImage mask. The logo
// is inset 28px with fit "contain" and the overlay row ends at
// y=157 < 190, so nothing overflows.
// each child's own `radius: 95` / RoundedImage mask.
// Logo arm background (the Slint Theme.surface-elevated).
color: theme.surfaceElevated
@@ -121,21 +120,18 @@ Rectangle {
}
}
// The logo itself — contain-fit inside the square INSCRIBED
// in the circle (190 / sqrt(2) ~= 134, so margin 28), which
// is what keeps a wide wordmark from being cut by the round
// container. radius 0: the inset box never reaches the edge,
// so it needs no clip of its own.
// Fill the disc edge to edge and crop through the circular
// mask, without an inset rectangular logo.
RoundedImage {
id: coverImage
gridArtwork: true
anchors.fill: parent
anchors.margins: 28
source: root.artSource
radius: 0
// Logos are contain-fit, never cropped (LabelCard).
fit: "contain"
radius: parent.radius
fit: "crop"
}
// Hover scrim (clipped to the circle by the parent's clip).
// Hover scrim with the same circular silhouette.
// 0.55, not the Slint's 0.25: the reference has no buttons
// over it, this one does and 0.25 leaves them unreadable
// over a light logo.
+7
View File
@@ -57,6 +57,10 @@ import "../theme"
Rectangle {
id: root
readonly property bool artworkReady: root.artSource !== "" && artworkLoader.item
? artworkLoader.item.ready : false
readonly property bool artworkImmediate: root.artSource !== "" && artworkLoader.item
? artworkLoader.item.immediateGridArtwork : false
property var item: ({})
// Host-resolved artwork path (the AlbumCard artSource pattern).
@@ -194,12 +198,14 @@ Rectangle {
// those surfaces pad too, and a genuinely square cover
// (`image.covers[0]`, the fallback that map picks) still crops.
Loader {
id: artworkLoader
anchors.fill: parent
sourceComponent: root.artSource !== "" ? singleArtwork : collageArtwork
}
Component {
id: singleArtwork
RoundedImage {
gridArtwork: true
source: root.artSource
radius: theme.radiusSm
fit: root.ownImage ? "pad" : "auto"
@@ -211,6 +217,7 @@ Rectangle {
Component {
id: collageArtwork
PlaylistCollage {
gridArtwork: true
urls: root.ownImage ? [] : root.collageUrls
radius: theme.radiusSm
}
@@ -60,6 +60,7 @@ import "../theme"
Item {
id: root
property bool gridArtwork: false
// Remote cover urls (feed row `covers`); only the first four distinct
// entries are drawn.
@@ -308,6 +309,7 @@ Item {
Repeater {
model: 4
delegate: RoundedImage {
gridArtwork: root.gridArtwork
required property int index
readonly property rect cell: root.cellOf(index)
visible: index < root.drawnTiles
+4
View File
@@ -30,6 +30,8 @@ import "../theme"
Item {
id: root
readonly property bool artworkReady: coverImage.ready
readonly property bool artworkImmediate: coverImage.immediateGridArtwork
/// The line under the panel (the seed's name).
property string seedTitle: ""
@@ -84,6 +86,8 @@ Item {
// No clip: RoundedImage confines itself on both arms; a clip is an
// unconditional batch root, one per visible card.
RoundedImage {
id: coverImage
gridArtwork: true
anchors.fill: parent
source: root.artSource
radius: 4
+2
View File
@@ -45,6 +45,8 @@ Rectangle {
// a rectangular scissor never produced this radius. A clip is an
// unconditional batch root, so this one cost a draw call per item.
RoundedImage {
id: coverImage
gridArtwork: true
anchors.fill: parent
source: card.artPath
radius: 4
+4
View File
@@ -20,6 +20,8 @@ import "../theme"
Rectangle {
id: root
readonly property bool artworkReady: coverImage.ready
readonly property bool artworkImmediate: coverImage.immediateGridArtwork
property var item: ({})
// Host-resolved artwork path (the AlbumCard artSource pattern).
@@ -115,6 +117,8 @@ Rectangle {
// a rectangular scissor never produced this radius. A clip is an
// unconditional batch root, so this one cost a draw call per item.
RoundedImage {
id: coverImage
gridArtwork: true
anchors.fill: parent
source: root.artSource
radius: theme.radiusSm
@@ -58,6 +58,8 @@ Item {
/// how a call site ends up setting a property that does not exist — which in
/// QML is not an error, just a silently-ignored line.
property bool danger: true
property string checkboxLabel: ""
property bool checkboxChecked: false
readonly property bool opened: root._open
property bool _open: false
@@ -184,6 +186,28 @@ Item {
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
wrapMode: Text.WordWrap
}
Row {
visible: root.checkboxLabel !== ""
width: parent.width
spacing: 10
QbzCheckbox {
kioskHost: root.kioskHost
checked: root.checkboxChecked
Accessible.name: root.checkboxLabel
onToggled: root.checkboxChecked = !root.checkboxChecked
}
Text {
width: parent.width - 54
text: root.checkboxLabel
color: theme.textSecondary
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
MouseArea {
anchors.fill: parent
onClicked: root.checkboxChecked = !root.checkboxChecked
}
}
}
Item {
width: parent.width
height: root.kioskHost ? 44 : 36
+16 -8
View File
@@ -54,11 +54,11 @@
// placeholder is still fully opaque on the frame the art appears.
// - `coverSource` — the art path, for hosts whose image is sealed inside a
// component they do not own (AlbumCard, PlaylistCard, SlimCard, …). This
// instance then loads the SAME path through its own hidden probe; because
// QQuickPixmapCache is shared and process-wide, that is the SAME cache
// entry the card is loading, so it costs no second decode and no second
// copy in memory — it only tells us WHEN the decode finished. The card's
// canvas paints on the next frame, which the 180ms fade below covers.
// instance loads the path through a hidden probe. Cache sharing depends
// on the resolved URL AND decode size: a card using a scaled derivative
// does not share this original-image decode. Prefer coverReady whenever
// the card exposes it, to avoid retaining both images. The default fade
// allows the card to paint after the probe becomes ready.
// `pending` is the third input: "this cell wants a cover at all". A row with
// no artwork slot passes false and gets no placeholder.
//
@@ -167,7 +167,8 @@ Item {
/// "This cell wants a cover." false retires the placeholder at once.
property bool pending: true
/// Host-driven readiness — bind to `RoundedImage.ready`.
property bool coverReady: false
property var artworkItem: null
property bool coverReady: artworkItem ? artworkItem.artworkReady : false
/// Self-probing readiness — the art path, when the image is sealed inside
/// a component the host does not own. "" disarms the probe entirely.
property string coverSource: ""
@@ -214,8 +215,15 @@ Item {
// the canvas paints on the next frame (~16ms in), while this is still at
// ~91% opacity, so the placeholder dissolves INTO the art.
readonly property bool retired: root.settled || root.handedOver || !root.pending
opacity: root.retired ? 0.0 : 1.0
Behavior on opacity { NumberAnimation { duration: 180 } }
property int handoverFadeMs: artworkItem && artworkItem.artworkImmediate ? 0 : 180
property real retirementOpacity: root.retired ? 0.0 : 1.0
// Immediate handover cannot inherit a Behavior already fading the old
// placeholder. Artless settling still uses its original 180ms fade.
opacity: root.handedOver && root.handoverFadeMs <= 0 ? 0.0 : root.retirementOpacity
Behavior on retirementOpacity {
enabled: root.handoverFadeMs > 0 || root.settled
NumberAnimation { duration: root.handedOver ? root.handoverFadeMs : 180 }
}
// Default only — a call site that assigns `visible` replaces this and
// keeps its own gate (see the block comment).
visible: root.opacity > 0.004
@@ -548,6 +548,7 @@ Column {
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Show track in window title", QbzSession.trRev)
visible: !QbzShell.isMacos || QbzShell.systemTitleBar
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.windowTitleShow === true
onToggled: function (v) { QbzBridge.settingsBool("window-title-show", v) }
@@ -604,30 +605,12 @@ Column {
// Absent while there is no tray icon to close to (owner
// 2026-08-21). The value still round-trips through the store, so
// turning the tray back on restores the answer the user gave.
visible: root.doc.trayEnable === true
visible: root.doc.trayEnable === true && root.doc.isMacos !== true
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.trayCloseToTray === true
onToggled: function (v) { QbzBridge.settingsBool("tray-close-to-tray", v) }
}
}
// macOS only: switch the activation policy to .accessory while closed to
// the menu bar (no Dock icon). Off = Spotify-style, keep the Dock icon.
// `visible` is inherited Item.visible and the parent is a Column, which
// skips invisible children — so on Linux this leaves no gap at all. The
// enable condition is DOUBLED on the row and on the toggle because
// SettingRow's `rowEnabled` only dims its own label column
// (controls/SettingRow.qml:26); it does not reach the control.
SettingRow { kioskHost: root.kioskHost;
visible: root.doc.isMacos === true
label: QbzSession.tr("Hide Dock icon when closed to menu bar", QbzSession.trRev)
description: QbzSession.tr("Run as a menu-bar-only app while the window is closed. Off keeps the Dock icon (like Spotify)", QbzSession.trRev)
rowEnabled: root.doc.trayEnable === true && root.doc.trayCloseToTray === true
QbzToggle { kioskHost: root.kioskHost;
enabled: root.doc.trayEnable === true && root.doc.trayCloseToTray === true
checked: root.doc.trayMacHideDock === true
onToggled: function (v) { QbzBridge.settingsBool("tray-mac-hide-dock", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
// Absent while the tray is off — there is no icon to give a
// variant to (owner 2026-08-21).
+9 -2
View File
@@ -464,7 +464,14 @@ Item {
/// the same measurement, as ContentRouter's page fade.
///
/// reduceMotion skips it: the art simply appears.
property int fadeMs: 200
// Card/grid consumers opt in. Detail artwork keeps its existing fade.
// High density retains the measured baseline until it meets the CPU gate.
property bool gridArtwork: false
readonly property bool immediateGridArtwork: gridArtwork
&& QbzLocal.artworkImmediateEnabled()
&& root._dpr <= 1
property int fadeMs: immediateGridArtwork ? 0 : 200
readonly property bool revealed: root.ready && !fadeIn.running && art.opacity >= 0.999
// ── The two arms ────────────────────────────────────────────────────────
// One Loader swap at startup (when GraphicsInfo.api resolves), not per
@@ -516,7 +523,7 @@ Item {
fadeIn.stop()
if (!root.ready) {
art.opacity = 0.0
} else if (QbzShell.reduceMotion) {
} else if (QbzShell.reduceMotion || root.fadeMs <= 0) {
art.opacity = 1.0
} else {
fadeIn.restart()
@@ -446,7 +446,12 @@ Column {
/// Snapshot what is on screen NOW. Call it BEFORE the bridge call: the
/// bridge may republish synchronously.
readonly property bool immediateArtwork: QbzLocal.artworkImmediateEnabled()
&& root._dpr <= 1
onImmediateArtworkChanged: if (immediateArtwork) { tailFade.stop(); clearTailFade() }
function armTailFade() {
if (root.immediateArtwork) { root.clearTailFade(); return }
var seen = {}
root._addIds(seen, root.albums)
for (var g = 0; g < root.grouped.length; g++)
+32 -23
View File
@@ -149,7 +149,6 @@ Rectangle {
property var coverMap: ({})
property var _coverInbox: ({})
property var _asked: ({})
readonly property int coverBatchCap: 48
// Batched on a 16 ms one-shot: `libraryArtworkReady` fires once PER COVER,
// and assigning `coverMap` per signal would republish the whole map to
// every delegate 48 times for one screenful. One dirty frame instead of
@@ -167,6 +166,7 @@ Rectangle {
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
if (root._asked[key] !== true || root.coverMap[key] === path) return
root._coverInbox[key] = path
if (!coverFlush.running)
coverFlush.start()
@@ -174,7 +174,7 @@ Rectangle {
}
function requestCovers(urls) {
var out = []
for (var i = 0; i < urls.length && out.length < root.coverBatchCap; i++) {
for (var i = 0; i < urls.length; i++) {
var u = urls[i]
if (u === "" || root._asked[u] === true || root.coverMap[u])
continue
@@ -184,17 +184,25 @@ Rectangle {
if (out.length > 0)
QbzShell.sidebarArtworkWindow(JSON.stringify(out))
}
/// Ask for every portrait the current model wants, capped per call.
/// Driven by the document landing and by scrolling — `requestCovers`
/// self-dedupes, so an over-eager caller costs a loop, not a fetch.
function requestVisibleCovers() {
if (!root.isReady)
// The native ListView already mounts two rows around the viewport. Ask
// for that bounded range, including after scrolling past the first page.
function requestVisibleCovers(view) {
if (!root.isReady || !view || !view.visible || !root.visible)
return
var urls = []
var rows = root.rowModel
for (var i = 0; i < rows.length; i++) {
if (rows[i].type === "header")
continue
if (rows.length === 0) return
var first = view.indexAt(4, view.contentY + 1)
var last = view.indexAt(4, view.contentY + Math.max(1, view.height) - 1)
if (first < 0) first = 0
if (last < 0) last = Math.min(rows.length - 1,
first + Math.ceil(view.height / (root.cardH + root.rowGap)))
var start = first
first = Math.max(0, first - 2)
last = Math.min(rows.length - 1, last + 2)
var urls = []
for (var offset = 0; offset <= last - first; offset++) {
var i = start + offset
if (i > last) i = first + i - last - 1
var arts = rows[i].artists || []
for (var j = 0; j < arts.length; j++)
urls.push(String(arts[j].artUrl || ""))
@@ -381,17 +389,6 @@ Rectangle {
readonly property int columns: Math.max(1,
Math.floor((gridPane.width + root.cardGap) / (root.cardW + root.cardGap)))
// Ask for the portraits whenever the row model settles. `rowModel` is
// rebuilt by a new document, a page appended, a filter, a search keystroke
// and a resize — every one of which can bring artists into view that were
// not there before, and `requestCovers` self-dedupes, so a redundant call
// costs a loop over the model and no fetch.
//
// NOT a `Component.onCompleted`: the view mounts BEFORE the first document
// lands (the route is recorded by `QbzScene.open` and discovery is async),
// so at completion the model is empty and the one shot would be wasted.
onRowModelChanged: root.requestVisibleCovers()
readonly property var rowModel: {
var out = []
var showHeaders = root.groupingEnabled
@@ -1007,7 +1004,19 @@ Rectangle {
boundsBehavior: Flickable.StopAtBounds
model: root.rowModel
spacing: 0
cacheBuffer: 2 * (root.cardH + root.rowGap) // Grid:150 ±5 items
cacheBuffer: 2 * (root.cardH + root.rowGap)
function reportCovers() {
if (visible && !coverWindow.running) coverWindow.start()
}
onContentYChanged: reportCovers()
onModelChanged: reportCovers()
onHeightChanged: reportCovers()
onVisibleChanged: reportCovers()
Timer {
id: coverWindow
interval: 180
onTriggered: root.requestVisibleCovers(list)
}
NumberAnimation {
id: jumpAnim
+9 -3
View File
@@ -600,6 +600,7 @@ Rectangle {
height: 246
AlbumCard {
id: railAlbumCard
albumId: modelData.id
source: modelData.source || ""
sources: modelData.sources || []
@@ -645,7 +646,8 @@ Rectangle {
width: 200
height: 200
pending: (modelData.artUrl || "") !== ""
coverSource: root.sectionArtOf(modelData)
artworkItem: railAlbumCard.artworkImmediate ? railAlbumCard : null
coverSource: railAlbumCard.artworkImmediate ? "" : root.sectionArtOf(modelData)
phase: root.skelPhase
cellIndex: index
// A cover whose download fails republishes the
@@ -944,6 +946,7 @@ Rectangle {
}
}
Loader {
id: pinnedCardLoader
anchors.fill: parent
sourceComponent: modelData.itemKind === "artist" ? pArtist
: modelData.itemKind === "playlist" ? pPlaylist : pAlbum
@@ -958,7 +961,8 @@ Rectangle {
height: 200
pending: modelData.itemKind !== "artist"
&& (modelData.artUrl || "") !== ""
coverSource: modelData.artPath || ""
artworkItem: pinnedCardLoader.item && pinnedCardLoader.item.artworkImmediate ? pinnedCardLoader.item : null
coverSource: artworkItem && artworkItem.artworkImmediate ? "" : (modelData.artPath || "")
phase: root.skelPhase
cellIndex: index
settleMs: 6000
@@ -1061,6 +1065,7 @@ Rectangle {
height: 234
RadioCard {
id: radioArtworkCard
seedTitle: modelData.title
seedSubtitle: modelData.artist
label: QbzSession.tr("RADIO", QbzSession.trRev)
@@ -1086,7 +1091,8 @@ Rectangle {
y: 29
blockRadius: 4
pending: (modelData.artUrl || "") !== ""
coverSource: root.forYouArtOf(modelData)
artworkItem: radioArtworkCard.artworkImmediate ? radioArtworkCard : null
coverSource: radioArtworkCard.artworkImmediate ? "" : root.forYouArtOf(modelData)
phase: root.skelPhase
cellIndex: index
settleMs: 6000
+47 -13
View File
@@ -892,10 +892,12 @@ Rectangle {
// --------------------- windowed artwork -----------------------------
// Report the mounted window as artKeys; prune far-away covers.
function reportWindow(visibleArray, first, last) {
property var _artSent: ({})
function reportWindow(visibleArray, first, last, visibleFirst) {
if (visibleArray.length === 0) {
root._artKeep = ({})
root._artRequested = ({})
root._artSent = ({})
root._artInbox = ({})
root.artPending = 0
if (Object.keys(root.artMap).length > 0)
@@ -917,13 +919,25 @@ Rectangle {
var m = artMap
var inbox = root._artInbox
var pending = 0
for (i = first; i <= last; i++) {
var now = Date.now()
// Visible and forward rows enter the existing worker queue first.
var start = Math.max(first, Math.min(last, visibleFirst === undefined ? first : visibleFirst))
for (var offset = 0; offset <= last - first; offset++) {
i = start + offset
if (i > last) i = first + i - last - 1
if (visibleArray[i].kind === "group-header") continue
var k = visibleArray[i].artKey
if (visibleArray[i].imageUrl !== "") {
keys.push(k)
requested[k] = true
if (!m[k] && !inbox[k]) pending++
if (!m[k] && !inbox[k]) {
pending++
// Scrolling must not launch the same pending download
// again every 180 ms. Permit a later retry after failure.
if (!root._artSent[k] || now - root._artSent[k] >= 30000) {
root._artSent[k] = now
keys.push(k)
}
}
}
}
root._artKeep = keep
@@ -941,8 +955,11 @@ Rectangle {
for (key in inbox) {
if (!keep[key]) delete inbox[key]
}
for (key in root._artSent) {
if (!keep[key]) delete root._artSent[key]
}
if (changed) artMap = Object.assign({}, m)
QbzLibrary.libraryArtworkWindow(JSON.stringify(keys))
if (keys.length > 0) QbzLibrary.libraryArtworkWindow(JSON.stringify(keys))
}
Connections {
@@ -1041,17 +1058,21 @@ Rectangle {
id: windowDebounce
interval: 180
onTriggered: root.reportWindow(
pendingRows ? pendingRows : root.visibleRows, pendingFirst, pendingLast)
pendingRows ? pendingRows : root.visibleRows, pendingFirst, pendingLast, pendingVisibleFirst)
property int pendingFirst: 0
property int pendingLast: 0
property int pendingVisibleFirst: 0
property var pendingRows: null
}
/// `rows` is optional — omit it and the band is read off `visibleRows`.
function queueWindowReport(first, last, rows) {
function queueWindowReport(first, last, rows, visibleFirst) {
windowDebounce.pendingFirst = first
windowDebounce.pendingLast = last
windowDebounce.pendingVisibleFirst = visibleFirst === undefined ? first : visibleFirst
windowDebounce.pendingRows = rows === undefined ? null : rows
windowDebounce.restart()
// Coalesce to the latest window without postponing it until scrolling
// stops. The existing 180 ms cap still bounds bridge traffic.
if (!windowDebounce.running) windowDebounce.start()
}
// All-tab search settle -> re-report the first window so the filtered
@@ -1362,7 +1383,7 @@ Rectangle {
boundsBehavior: Flickable.StopAtBounds
model: root.visibleRows
onContentYChanged: root.gridWindowReport()
onContentYChanged: root.gridWindowReport(true)
onModelChanged: root.gridWindowReport()
onWidthChanged: root.gridWindowReport()
onVisibleChanged: root.gridWindowReport()
@@ -1705,13 +1726,26 @@ Rectangle {
// rail above all, since it is the one body whose band is NOT a
// `visibleRows` band. `onVisibleChanged` keeps the gate from swallowing
// the first report of a body that becomes visible after its model landed.
function gridWindowReport() {
property string _gridMotionBand: ""
property double _gridMotionReportedAt: 0
function gridWindowReport(motionOnly) {
if (!grid.visible) return
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 visibleFirst = Math.max(0, Math.floor(grid.contentY / grid.cellHeight))
// Match the two rows of delegates already held by cacheBuffer.
var firstRow = Math.max(0, visibleFirst - 2)
var lastRow = Math.ceil((grid.contentY + grid.height) / grid.cellHeight) + 2
var m = root.visibleRows
queueWindowReport(firstRow * cols, Math.min(m.length - 1, lastRow * cols - 1))
var band = firstRow + ":" + lastRow + ":" + visibleFirst + ":" + cols
var now = Date.now()
// Do not restart the bridge timer for every pixel in the same band.
// Non-motion notifications bypass this guard; a periodic motion
// refresh also preserves the existing failed-request retry behavior.
if (motionOnly === true && root._gridMotionBand === band
&& now - root._gridMotionReportedAt < 1000) return
root._gridMotionBand = band
root._gridMotionReportedAt = now
queueWindowReport(firstRow * cols, Math.min(m.length - 1, lastRow * cols - 1), undefined, visibleFirst * cols)
}
function listWindowReport() {
if (!list.visible) return
+58 -2
View File
@@ -537,6 +537,41 @@ Rectangle {
// Decoded-cover map {artKey: "file://…"} — fed by the id-keyed signal.
property var artMap: ({})
property var _artKeep: ({})
// Keep the high-density path unchanged until it passes the CPU budget.
// This remains reactive when the window moves between displays.
readonly property bool immediateArtwork: QbzLocal.artworkImmediateEnabled()
&& Screen.devicePixelRatio <= 1
readonly property bool artTimingEnabled: QbzLocal.artworkTimingEnabled()
property var _artTraceWindows: ({})
property int _artTraceSequence: 0
function traceArtReport(rows, first, last, surface) {
if (!root.artTimingEnabled) return
var keys = []
for (var i = Math.max(0, first); i <= Math.min(last, rows.length - 1); i++)
if (rows[i] && rows[i].artKey) keys.push(rows[i].artKey)
var signature = keys.join("|")
var old = root._artTraceWindows[surface]
if (old && old.signature === signature) return
if (old) {
for (var j = 0; j < old.keys.length; j++)
QbzLocal.artworkTiming("window-end", old.keys[j], old.id)
}
var id = surface + ":" + (++root._artTraceSequence)
root._artTraceWindows[surface] = {id: id, keys: keys, signature: signature}
for (var k = 0; k < keys.length; k++)
QbzLocal.artworkTiming("report", keys[k], id)
}
function traceArt(stage, key) {
if (!root.artTimingEnabled) return
for (var surface in root._artTraceWindows) {
var w = root._artTraceWindows[surface]
if (w.keys.indexOf(key) >= 0)
QbzLocal.artworkTiming(stage, key, w.id)
}
}
// Covers arrive ONE AT A TIME (src/local_artwork.rs phase 2 streams each
// thumbnail through the id-keyed signal the moment it resolves). Rebinding
@@ -577,6 +612,13 @@ Rectangle {
interval: 16
repeat: false
onTriggered: {
if (root.immediateArtwork) {
// One rebind for this batch, with no decorative reveal front.
root.artMap = Object.assign({}, root.artMap, root._artHeld, root._artInbox)
root._artHeld = ({})
root._artInbox = ({})
return
}
var k
// Everything that arrived this tick joins whatever is still held.
for (k in root._artInbox) root._artHeld[k] = root._artInbox[k]
@@ -611,10 +653,12 @@ Rectangle {
Connections {
target: QbzLocal
function onLocalArtworkReady(key, path) {
root.traceArt("path", key)
root.nativeTracksModel.setArtwork(key, path)
root.nativeAlbumsModel.setArtwork(key, path)
root.nativeArtistsModel.setArtwork(key, path)
root.nativeArtistAlbumsModel.setArtwork(key, path)
if (root.immediateArtwork && !root._artKeep[key]) return
root._artInbox[key] = path
if (!artFlush.running) artFlush.start()
// An arrival is live evidence that the pass is still running, so
@@ -1269,6 +1313,12 @@ Rectangle {
/// covers. Cheap no-op when the surface was never registered.
function releaseWindow(key) {
var k = key || "default"
if (root.artTimingEnabled && root._artTraceWindows[k]) {
var traced = root._artTraceWindows[k]
for (var i = 0; i < traced.keys.length; i++)
QbzLocal.artworkTiming("window-end", traced.keys[i], traced.id)
delete root._artTraceWindows[k]
}
if (root._windows[k] === undefined && root._pending[k] === undefined) return
delete root._windows[k]
delete root._pending[k]
@@ -1294,6 +1344,7 @@ Rectangle {
}
}
var m = root.artMap
root._artKeep = keep
var changed = false
for (k in m) if (!keep[k]) { delete m[k]; changed = true }
// The not-yet-flushed arrivals are evicted too, or a cover that landed
@@ -1318,7 +1369,7 @@ Rectangle {
ak = rows[i] ? rows[i].artKey : ""
if (!ak || seen[ak]) continue
seen[ak] = true
if (order[ak] === undefined) order[ak] = ord++
if (!root.immediateArtwork && order[ak] === undefined) order[ak] = ord++
if (m[ak] !== undefined || root._artInbox[ak] !== undefined) continue
keys.push(ak)
}
@@ -1326,7 +1377,11 @@ Rectangle {
// Restart the wipe at the top of what is on screen NOW.
root._artOrder = order
root._artFront = 0
if (keys.length > 0) QbzLocal.artworkWindow(JSON.stringify(keys))
if (keys.length > 0) {
if (root.artTimingEnabled)
for (var n = 0; n < keys.length; n++) root.traceArt("request", keys[n])
QbzLocal.artworkWindow(JSON.stringify(keys))
}
}
/// Immediate single-surface report — the leading edge and the rate-limit
@@ -1444,6 +1499,7 @@ Rectangle {
property real _lastReportMs: 0
function queueWindowReport(rows, first, last, key) {
var k = key || "default"
if (rows && root.artTimingEnabled) root.traceArtReport(rows, first, last, k)
if (!windowDebounce.running
&& Date.now() - root._lastReportMs >= windowDebounce.interval) {
root._lastReportMs = Date.now()
+6 -1
View File
@@ -268,7 +268,7 @@ Rectangle {
// that decode finishes. Gating on `artPath !== ""` (what this used to
// do) drops the placeholder while the card's canvas is still blank.
pending: (card.artUrl || "") !== ""
coverSource: resolvedSource
coverSource: artworkItem && artworkItem.artworkImmediate ? "" : resolvedSource
// A cover whose download fails republishes the document with an
// empty artPath — without this the tile would shimmer forever.
settleMs: 6000
@@ -438,6 +438,7 @@ Rectangle {
onLoaded: resultCell.restoreMutableBindings()
}
CardArtSkeleton {
artworkItem: cardLoader.item && cardLoader.item.artworkImmediate ? cardLoader.item : null
visible: resultGrid.kind !== "artist"
card: resultCell.cardData
resolvedSource: resultGrid.artOf(resultCell.cardData)
@@ -929,6 +930,7 @@ Rectangle {
width: 200
height: 246
AlbumCard {
id: searchAlbumCard
albumId: modelData.id
title: modelData.title
artist: modelData.artist
@@ -946,6 +948,7 @@ Rectangle {
artworkUrl: modelData.artUrl || ""
}
CardArtSkeleton {
artworkItem: searchAlbumCard.artworkImmediate ? searchAlbumCard : null
card: modelData
phase: root.skelPhase
cellIndex: index
@@ -1163,6 +1166,7 @@ Rectangle {
width: 200
height: 246
PlaylistCard {
id: searchPlaylistCard
// artworkUrl is not passed: the card
// defaults it to `item.artUrl`, which is
// this row's remote cover.
@@ -1171,6 +1175,7 @@ Rectangle {
isPinned: modelData.isPinned === true
}
CardArtSkeleton {
artworkItem: searchPlaylistCard.artworkImmediate ? searchPlaylistCard : null
card: modelData
phase: root.skelPhase
cellIndex: index
@@ -202,6 +202,8 @@ Item {
// (ArtistGridCard/LabelCard). A bare Rectangle: it does not take pointer
// events, so the card's hover/click areas keep working underneath.
QbzSkeleton {
artworkItem: cell.item.kind !== "group-header" && cardLoader.item
&& cardLoader.item.artworkImmediate ? cardLoader.item : null
variant: "art"
width: 200
height: 200
@@ -209,7 +211,8 @@ Item {
|| cell.item.kind === "track"
|| cell.item.kind === "playlist")
&& cell.item.imageUrl !== ""
&& (cell.view.artMap[cell.item.artKey] || "") === ""
&& (cardLoader.item && cardLoader.item.artworkImmediate
? opacity > 0.004 : (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.
@@ -46,6 +46,7 @@ Rectangle {
tintName: "muted"
}
RoundedImage {
gridArtwork: true
id: subcardArt
anchors.fill: parent
source: root.artSource
@@ -17,6 +17,7 @@
// via the host's windowing helpers.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../../cards"
import "../../controls"
@@ -264,10 +265,15 @@ Item {
// moved the list by a pixel. Every state change that can alter the
// mounted band now reports: mount, becoming visible, the model rebuild,
// a viewport resize, and the scroll.
function report() {
// Only wheel/motion reports can be skipped. Model, layout and artwork
// notifications always refresh the window, including late native pages.
property string _motionBand: ""
property double _motionReportedAt: 0
function report(motionOnly) {
if (!view || !list) return
var count = nativeActive && nativeModel ? nativeModel.totalCount : entries.length
if (!list.visible || count === 0 || width <= 0) {
root._motionBand = ""
view.releaseWindow(root.surface)
return
}
@@ -275,9 +281,24 @@ Item {
var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1)
if (first < 0) first = 0
if (last < 0) last = Math.min(count - 1, first + 8)
var visibleFirst = first
// These delegates already exist in cacheBuffer. Resolve their covers
// too, so Qt can decode them before the next wheel step reveals them.
if (root.viewMode === "grid") {
first = Math.max(0, first - 2)
last = Math.min(count - 1, last + 2)
}
var band = first + ":" + last + ":" + visibleFirst
var now = Date.now()
if (motionOnly === true && root._motionBand === band
&& now - root._motionReportedAt < 1000) return
root._motionBand = band
root._motionReportedAt = now
if (nativeActive && nativeModel) {
var resident = []
for (var entryIndex = first; entryIndex <= last; entryIndex++) {
for (var offset = 0; offset <= last - first; offset++) {
var entryIndex = visibleFirst + offset
if (entryIndex > last) entryIndex = first + entryIndex - last - 1
var entry = nativeModel.rowAt(entryIndex)
if (!entry || entry.loading || !entry.items) continue
for (var itemIndex = 0; itemIndex < entry.items.length; itemIndex++)
@@ -344,7 +365,7 @@ Item {
// lazy FIRST. In the other order this would install that bug.
reuseItems: true
model: root.nativeActive && root.nativeModel ? root.nativeModel : root.entries
onContentYChanged: root.report()
onContentYChanged: root.report(true)
onModelChanged: root.report()
onHeightChanged: root.report()
onVisibleChanged: if (visible) root.reportSoon()
@@ -432,6 +453,16 @@ Item {
/// slot is filled, `root.emptySlot` when it is not.
readonly property var slot:
cardCell.modelData || root.emptySlot
property string artTracePendingKey: ""
Connections {
target: root.view && root.view.artTimingEnabled
&& cardCell.artTracePendingKey !== "" ? root.Window.window : null
function onFrameSwapped() {
if (cardCell.slot.artKey === cardCell.artTracePendingKey)
root.view.traceArt("frame", cardCell.artTracePendingKey)
cardCell.artTracePendingKey = ""
}
}
visible: cardCell.modelData !== null
width: 200
height: 246
@@ -494,6 +525,18 @@ Item {
qualityDetail: cardCell.slot.qualityDetail || ""
artSource: cardCell.slot.artPath || (root.view
? (root.view.artMap[cardCell.slot.artKey] || "") : "")
artFadeMs: root.view && root.view.immediateArtwork ? 0 : 200
onArtworkReadyChanged: {
if (root.view && root.view.artTimingEnabled && artworkReady) {
root.view.traceArt("ready", cardCell.slot.artKey)
}
}
onArtworkRevealedChanged: {
if (root.view && root.view.artTimingEnabled && artworkRevealed) {
root.view.traceArt("revealed", cardCell.slot.artKey)
cardCell.artTracePendingKey = cardCell.slot.artKey
}
}
pinArtworkUrl: artSource
isPinned: QbzLibrary.pinState("album", albumId)
// LocalLibraryView.slint:1267 `show-source-badge`
@@ -565,15 +608,10 @@ Item {
root.enqueueRequested(cardCell.slot.id, m)
}
}
// Per-item cover placeholder, handed over to the
// art itself: AlbumCard seals its RoundedImage
// away, so this uses QbzSkeleton's probe arm —
// `coverSource` loads the SAME pixmap-cache entry
// the card is loading and retires the placeholder
// when that decode completes, not when the path
// appears. A bare Rectangle, so it does not take
// pointer events and the card's own areas keep
// working underneath.
// Observe the card's actual derivative, so the placeholder
// neither decodes the original again nor retires before
// the displayed image is ready. The source probe remains
// only for the diagnostic baseline comparison.
// settleMs is mandatory here: local artwork
// resolution drops keys with no cover, so an
// artless album would otherwise shimmer forever
@@ -584,7 +622,10 @@ Item {
height: 200
pending: root.view
? root.view.artWanted(cardCell.slot.artKey) : false
coverSource: root.view
coverReady: root.view && root.view.immediateArtwork
? albumCard.artworkReady : false
handoverFadeMs: root.view && root.view.immediateArtwork ? 0 : 180
coverSource: root.view && !root.view.immediateArtwork
? root.view.artPathOf(cardCell.slot.artKey) : ""
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
@@ -73,6 +73,7 @@ Rectangle {
spacing: 8
CollectionMosaic {
gridArtwork: true
size: 184
urls: root.item.cellUrls || []
paths: root.item.cellPaths || []
@@ -115,6 +116,7 @@ Rectangle {
spacing: 12
CollectionMosaic {
gridArtwork: true
anchors.verticalCenter: parent.verticalCenter
size: 48
urls: root.item.cellUrls || []
@@ -111,6 +111,7 @@ Rectangle {
Component {
id: desktopCover
PlaylistCollage {
gridArtwork: true
anchors.fill: parent
layout: "pm"
urls: root.item.covers || []
@@ -75,6 +75,7 @@ Item {
// per cell, no scissor that rounds nothing).
RoundedImage {
gridArtwork: true
id: art
anchors.fill: parent
source: root.artUrl
@@ -583,7 +583,7 @@ Item {
width: pane.gridW
height: parent.height
clip: true
cacheBuffer: root.cardH
cacheBuffer: 2 * root.cardH
boundsBehavior: Flickable.StopAtBounds
model: root.albumEntries
@@ -741,11 +741,9 @@ Item {
first = 0
if (last < 0)
last = Math.min(root.albumEntries.length - 1, first + 3)
// One screen of buffer either side, which is what keeps a fast scroll
// from showing placeholders — and the cap above is what keeps a fast
// scroll from queueing the whole discography.
first = Math.max(0, first - 1)
last = Math.min(root.albumEntries.length - 1, last + 1)
// Match the two buffered grid rows, not a whole extra screen.
first = Math.max(0, first - 2)
last = Math.min(root.albumEntries.length - 1, last + 2)
var urls = []
for (var i = first; i <= last; i++) {
var e = root.albumEntries[i]
+113 -3
View File
@@ -430,11 +430,28 @@ pub fn attach_cached(sections: &mut [HomeSection]) -> Vec<String> {
/// paths are tokenized first — neither is ever handed to reqwest. Returns
/// when all downloads settled (failures are logged and skipped — the cards
/// keep their placeholder).
type ArtworkReady = Arc<dyn Fn(String, String) + Send + Sync>;
pub async fn download_missing(urls: Vec<String>) {
download_missing_inner(urls, None).await;
}
/// Publish each resolved cover without waiting for the slowest sibling.
/// Uses the same jobs and concurrency limit as the batch-only caller.
pub async fn download_missing_progressive(
urls: Vec<String>,
ready: impl Fn(String, String) + Send + Sync + 'static,
) {
download_missing_inner(urls, Some(Arc::new(ready))).await;
}
async fn download_missing_inner(urls: Vec<String>, ready: Option<ArtworkReady>) {
// (memo key = the caller's raw url, http url to GET), deduped by the
// fetch url so two rows sharing a cover download it once.
let mut jobs: Vec<(String, String)> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut primary_by_fetch = std::collections::HashMap::<String, String>::new();
let mut aliases = std::collections::HashMap::<String, Vec<String>>::new();
for url in urls {
// Overridden art never needs a fetch — cached_path answers it.
if !crate::cover_artwork_qt::override_for_url(&url).is_empty() {
@@ -442,6 +459,15 @@ pub async fn download_missing(urls: Vec<String>) {
}
match classify(&url) {
ArtUrl::Http(fetch) | ArtUrl::Plex(fetch) => {
if ready.is_some() {
let primary = primary_by_fetch
.entry(fetch.clone())
.or_insert_with(|| url.clone());
let group = aliases.entry(primary.clone()).or_default();
if !group.contains(&url) {
group.push(url.clone());
}
}
if seen.insert(fetch.clone()) {
jobs.push((url, fetch));
}
@@ -454,7 +480,16 @@ pub async fn download_missing(urls: Vec<String>) {
ArtUrl::Empty => {}
}
}
fetch_jobs(jobs).await;
let ready = ready.map(|callback| {
Arc::new(move |key: String, path: String| {
if let Some(keys) = aliases.get(&key) {
for alias in keys {
callback(alias.clone(), path.clone());
}
}
}) as ArtworkReady
});
fetch_jobs_with_ready(jobs, ready).await;
}
/// The shared download loop: `(cache_key, fetch url)` jobs, already deduped.
@@ -464,6 +499,10 @@ pub async fn download_missing(urls: Vec<String>) {
/// bounded pool, the error-page guard and the cache store rather than growing
/// a second copy of them.
async fn fetch_jobs(jobs: Vec<(String, String)>) {
fetch_jobs_with_ready(jobs, None).await;
}
async fn fetch_jobs_with_ready(jobs: Vec<(String, String)>, ready: Option<ArtworkReady>) {
if jobs.is_empty() {
return;
}
@@ -471,10 +510,14 @@ async fn fetch_jobs(jobs: Vec<(String, String)>) {
let mut handles = Vec::with_capacity(jobs.len());
for (key, url) in jobs {
let sem = Arc::clone(&semaphore);
let ready = ready.clone();
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.expect("semaphore open");
// A concurrent pass may have stored it while we queued.
if disk_path(&key, &url).is_some() {
if let Some(path) = disk_path(&key, &url) {
if let Some(ready) = &ready {
ready(key, file_url(&path.to_string_lossy()));
}
return;
}
let response = match HTTP.get(&url).send().await {
@@ -520,7 +563,10 @@ async fn fetch_jobs(jobs: Vec<(String, String)>) {
if let Some(path) = stored {
// Seed the memo so the republish that follows resolves from
// RAM instead of re-querying SQLite for every card.
memo_put(&key, path);
memo_put(&key, path.clone());
if let Some(ready) = &ready {
ready(key, file_url(&path.to_string_lossy()));
}
}
}));
}
@@ -862,6 +908,70 @@ async fn download_one(key: String, fetch: String) {
mod tests {
use super::*;
/// Run with a temporary XDG_CACHE_HOME and QBZ_ARTWORK_TEST_ISOLATED=1.
/// The image cache is process-global, so this deliberately runs alone.
#[tokio::test]
#[ignore = "requires an isolated cache profile; run with --ignored --test-threads=1"]
async fn progressive_cover_is_published_before_slow_sibling() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
assert_eq!(
std::env::var("QBZ_ARTWORK_TEST_ISOLATED").as_deref(),
Ok("1")
);
qbz_app::ensure_crypto_provider();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let release_slow = Arc::new(tokio::sync::Notify::new());
let gate = release_slow.clone();
let server = tokio::spawn(async move {
let mut peers = tokio::task::JoinSet::new();
for _ in 0..2 {
let (mut socket, _) = listener.accept().await.unwrap();
let gate = gate.clone();
peers.spawn(async move {
let mut request = [0; 2048];
let count = socket.read(&mut request).await.unwrap();
if String::from_utf8_lossy(&request[..count]).contains("/slow ") {
gate.notified().await;
}
let image = b"P6\n1 1\n255\n\xff\x00\x00";
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
image.len()
);
socket.write_all(headers.as_bytes()).await.unwrap();
socket.write_all(image).await.unwrap();
});
}
while let Some(result) = peers.join_next().await {
result.unwrap();
}
});
let fast = format!("{base}/fast");
let slow = format!("{base}/slow");
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let urls = vec![slow.clone(), fast.clone(), fast.clone()];
let download = tokio::spawn(download_missing_progressive(urls, move |key, path| {
tx.send((key, path)).unwrap();
}));
let first = tokio::time::timeout(std::time::Duration::from_secs(3), rx.recv())
.await
.expect("fast cover must not wait for slow cover")
.unwrap();
assert_eq!(first.0, fast);
assert!(!first.1.is_empty());
assert!(!download.is_finished());
release_slow.notify_one();
let second = tokio::time::timeout(std::time::Duration::from_secs(3), rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!(second.0, slow);
download.await.unwrap();
server.await.unwrap();
assert!(rx.recv().await.is_none(), "duplicate URLs must emit once");
}
#[test]
fn http_urls_are_fetchable() {
assert_eq!(
+55 -1
View File
@@ -406,6 +406,61 @@ fn fetch_label(generation: u64, id: u64) {
crate::artwork_qt::download_missing(missing).await;
refresh_label_art(generation);
}
// Label summaries can omit artist portraits entirely. Enrich only
// those entries after publishing the page, one request at a time.
let missing_artists: std::collections::BTreeSet<u64> = {
let Ok(s) = LABEL.lock() else { return };
if s.generation != generation {
return;
}
s.doc
.artists
.iter()
.filter(|card| card.art_url.is_empty())
.filter_map(|card| card.id.parse().ok())
.collect()
};
for artist_id in missing_artists {
if !LABEL
.lock()
.map(|s| s.generation == generation)
.unwrap_or(false)
{
return;
}
let artist = match runtime.core().get_artist(artist_id).await {
Ok(artist) => artist,
Err(error) => {
log::debug!(
"[qbz-qt] label artist portrait lookup failed ({artist_id}): {error}"
);
continue;
}
};
let Some(url) = artist
.image
.as_ref()
.and_then(|image| image.for_px(400))
.filter(|url| !url.is_empty())
.cloned()
else {
continue;
};
{
let Ok(mut s) = LABEL.lock() else { return };
if s.generation != generation {
return;
}
for card in &mut s.doc.artists {
if card.id == artist_id.to_string() && card.art_url.is_empty() {
card.art_url = url.clone();
}
}
}
crate::artwork_qt::download_missing(vec![url]).await;
refresh_label_art(generation);
}
});
}
@@ -952,7 +1007,6 @@ fn sort_cards(items: &mut [HomeCard], sort: &str) {
}
}
// ===========================================================================
// Live language switch
// ===========================================================================
+31
View File
@@ -674,6 +674,12 @@ pub mod qbz_local {
/// emits `localArtworkReady` per hit.
#[qinvokable]
fn artwork_window(self: Pin<&mut QbzLocal>, keys_json: QString);
#[qinvokable]
fn artwork_timing_enabled(self: &QbzLocal) -> bool;
#[qinvokable]
fn artwork_immediate_enabled(self: &QbzLocal) -> bool;
#[qinvokable]
fn artwork_timing(self: &QbzLocal, stage: QString, key: QString, window: QString);
/// Derive the same flat tint + blurred atmosphere used by Qobuz
/// AlbumView from an already-resolved local cover. Decode work stays
/// off the Qt thread; key/path return with the result as generation
@@ -1634,6 +1640,31 @@ impl qbz_local::QbzLocal {
// --- Artwork -----------------------------------------------------------
pub fn artwork_timing_enabled(&self) -> bool {
log::log_enabled!(target: "qbz.nav.timing", log::Level::Debug)
}
pub fn artwork_immediate_enabled(&self) -> bool {
// Diagnostic A/B switch: the same binary can exercise the previous
// reveal path without changing its compiler, backend or dependencies.
std::env::var("QBZ_GRID_IMMEDIATE_ART").as_deref() != Ok("0")
}
pub fn artwork_timing(&self, stage: QString, key: QString, window: QString) {
if !self.artwork_timing_enabled() {
return;
}
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
let elapsed = START
.get_or_init(std::time::Instant::now)
.elapsed()
.as_secs_f64()
* 1000.0;
log::debug!(target: "qbz.nav.timing",
"art ms={elapsed:.3} stage={} window={} key={}",
stage.to_string(), window.to_string(), key.to_string());
}
pub fn artwork_window(self: Pin<&mut Self>, keys_json: QString) {
let keys: Vec<String> = serde_json::from_str(&keys_json.to_string()).unwrap_or_default();
if keys.is_empty() {
+12 -14
View File
@@ -1378,14 +1378,7 @@ pub(crate) fn sidebar_artwork_window(urls_json: String) {
return;
}
spawn(async move {
let urls = missing;
artwork_qt::download_missing(urls.clone()).await;
for url in urls {
let path = artwork_qt::cached_path(&url);
if !path.is_empty() {
emit_library_artwork(url, path);
}
}
artwork_qt::download_missing_progressive(missing, emit_library_artwork).await;
});
}
@@ -3163,14 +3156,19 @@ pub(crate) fn library_artwork_window_at_px(keys_json: String, pixels: Option<i32
return;
}
spawn(async move {
let urls: Vec<String> = missing.iter().map(|(_, u)| u.clone()).collect();
artwork_qt::download_missing(urls).await;
let urls = missing.iter().map(|(_, url)| url.clone()).collect();
let mut keys_by_url = std::collections::HashMap::<String, Vec<String>>::new();
for (key, url) in missing {
let path = artwork_qt::cached_path(&url);
if !path.is_empty() {
emit_library_artwork(key, path);
}
keys_by_url.entry(url).or_default().push(key);
}
artwork_qt::download_missing_progressive(urls, move |url, path| {
if let Some(keys) = keys_by_url.get(&url) {
for key in keys {
emit_library_artwork(key.clone(), path.clone());
}
}
})
.await;
});
}
+35 -9
View File
@@ -97,6 +97,13 @@ pub mod qbz_tray {
#[qinvokable]
fn arm_quit_watchdog(self: Pin<&mut QbzTray>);
#[qinvokable]
fn confirm_quit_enabled(self: &QbzTray) -> bool;
#[qinvokable]
fn disable_quit_confirmation(self: Pin<&mut QbzTray>);
#[qsignal]
fn close_requested(self: Pin<&mut QbzTray>);
/// Show + raise + focus the main window -> `Main.qml`'s
/// `showFromTray()`.
#[qsignal]
@@ -166,6 +173,20 @@ impl Default for QbzTrayRust {
static QT_THREAD: OnceLock<CxxQtThread<QbzTray>> = OnceLock::new();
unsafe extern "C" {
fn qbz_install_lifecycle_filter(request: extern "C" fn(bool));
}
extern "C" fn request_lifecycle(quit: bool) {
ui(move |mut tray| {
if quit {
tray.as_mut().quit_requested();
} else {
tray.as_mut().close_requested();
}
});
}
/// Queue a tray-bridge mutation onto the Qt event loop (no-op before boot
/// registers the thread). **Every tray verb reaches QML through here** — the
/// ksni item runs on its own thread and must never touch a QObject directly.
@@ -180,6 +201,9 @@ impl qbz_tray::QbzTray {
if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] tray Qt thread already registered");
}
// SAFETY: boot runs on the GUI thread after QGuiApplication exists;
// callback has static lifetime and queues all QML work.
unsafe { qbz_install_lifecycle_filter(request_lifecycle) };
// If `tray_qt::init` ran before this point its queued closure was
// dropped on the floor -- `ui()` above is a no-op until the line
// right here. Pick the work up now; we are already on the GUI thread.
@@ -198,19 +222,13 @@ impl qbz_tray::QbzTray {
/// (`hideToTray` / `showFromTray`), so the macOS Dock-icon policy hangs
/// here rather than growing a third visibility owner.
///
/// Hiding drops the Dock icon only when the user asked for it; showing
/// ALWAYS restores it, with no setting check — a user who turns the option
/// off while the app sits in the menu bar must still get their icon back,
/// and restoring an icon that is already there is free.
/// A live tray keeps hidden windows reachable, so hiding removes the Dock
/// icon. Showing always restores it. Minimizing does not call this method.
pub fn set_window_shown(self: Pin<&mut Self>, shown: bool) {
crate::tray_qt::set_window_shown(shown);
if shown {
crate::tray_qt::set_mac_dock_hidden(false);
} else if crate::settings_qt::tray()
.get_settings()
.map(|t| t.mac_hide_dock)
.unwrap_or(false)
{
} else if *self.as_ref().tray_live() {
crate::tray_qt::set_mac_dock_hidden(true);
}
}
@@ -231,4 +249,12 @@ impl qbz_tray::QbzTray {
pub fn arm_quit_watchdog(self: Pin<&mut Self>) {
crate::arm_hard_exit_watchdog("QML quit arm");
}
pub fn confirm_quit_enabled(&self) -> bool {
crate::settings_qt::pref_bool("confirm_quit", true)
}
pub fn disable_quit_confirmation(self: Pin<&mut Self>) {
crate::settings_qt::save_pref("confirm_quit", serde_json::json!(false));
}
}
+1 -1
View File
@@ -673,7 +673,7 @@ pub(crate) fn set_window_shown(shown: bool) {
/// without this path owing anything beyond the geometry flush (§5.7).
pub(crate) fn quit() {
log::info!("[tray] quit requested");
crate::arm_hard_exit_watchdog("tray quit");
// QML confirms first; only the accepted exit arms the watchdog.
crate::tray_bridge::ui(|mut t| {
t.as_mut().quit_requested();
});
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Isolated A/B microbenchmark of the real RoundedImage/Skeleton components.
Measures the reveal/decode path, NOT the complete application's memory budget
or the Rust thumbnail pipeline. Uses existing thumbs read-only; fixtures and
logs are temporary. Run without a Cargo build or screen recording in progress.
"""
import argparse
import json
import os
import re
from pathlib import Path
import subprocess
import tempfile
import time
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
QML = '/usr/lib64/qt6/bin/qml'
def run(root, candidate, dpr, display, repeat):
env = dict(os.environ, DISPLAY=display, QT_QPA_PLATFORM='xcb',
QSG_RHI_BACKEND='opengl', LIBGL_ALWAYS_SOFTWARE='1', QT_SCALE_FACTOR=str(dpr))
env.pop('WAYLAND_DISPLAY', None)
log = root / f'run-{dpr}-{repeat}-{candidate}.log'
bench = root / 'Bench.qml'
bench.write_text(re.sub(r'property bool candidate: (?:true|false)',
'property bool candidate: ' + str(candidate).lower(), bench.read_text()))
with log.open('w') as stream:
p = subprocess.Popen([QML, '-I', str(root / 'imports'), '-f', str(root / 'Bench.qml')],
env=env, stdout=stream, stderr=subprocess.STDOUT)
rss_peak = cpu = rss_last = 0
try:
deadline = time.monotonic() + 25
while p.poll() is None and time.monotonic() < deadline:
try:
stat = Path(f'/proc/{p.pid}/stat').read_text().split(') ', 1)[1].split()
cpu = (int(stat[11]) + int(stat[12])) / os.sysconf('SC_CLK_TCK')
rss = Path(f'/proc/{p.pid}/smaps_rollup').read_text()
rss_last = int(next(line.split()[1] for line in rss.splitlines() if line.startswith('Rss:')))
rss_peak = max(rss_peak, rss_last)
except (FileNotFoundError, ProcessLookupError):
pass
time.sleep(.05)
if p.poll() is None:
raise RuntimeError('benchmark did not finish')
finally:
if p.poll() is None:
p.kill(); p.wait()
content = log.read_text()
if p.returncode or any(s in content for s in ('ReferenceError', 'TypeError', 'is not a type', 'Cannot open', 'failed to create')):
raise RuntimeError(content)
match = re.search(r'BENCH_RESULT (\d+) (\d+) (\d+) (\d+) (\d+)', content)
if not match or int(match[1]) < 100 or int(match[2]) < 100 or int(match[3]) < 100 or int(match[4]) < 100 or int(match[5]) != 0:
raise RuntimeError('benchmark did not render enough images/frames: '+content)
return dict(ready_events=int(match[1]), frames=int(match[2]), revealed=int(match[3]), handed_over=int(match[4]), candidate=candidate, dpr=dpr, cpu_s=cpu, peak_mib=rss_peak/1024,
end_mib=rss_last/1024, log=str(log))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--thumbs', type=Path, default=Path.home()/'.local/share/qbz/thumbnails')
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--repeats', type=int, default=3)
args = parser.parse_args()
root = Path(tempfile.mkdtemp(prefix='qbz-artwork-bench-'))
module = root/'imports/com/blitzfc/qbz'; module.mkdir(parents=True)
derivatives = {}; sources = []
for src in sorted(args.thumbs.iterdir()):
if len(sources) >= 96: break
try:
im = Image.open(src).convert('RGBA')
except (OSError, ValueError):
continue
url = src.resolve().as_uri(); sources.append(url)
for edge in (200, 400):
scale = max(edge/im.width, edge/im.height)
w, h = int(im.width*scale+.5), int(im.height*scale+.5)
out = root/f'{len(sources)}-{w}x{h}.png'
im.resize((w,h), Image.Resampling.LANCZOS).save(out)
derivatives[f'{url}|{w}x{h}'] = out.as_uri()
if len(sources) < 30: raise RuntimeError('need at least 30 existing thumbnails')
(module/'qmldir').write_text('module com.blitzfc.qbz\nsingleton QbzShell 1.0 QbzShell.qml\nsingleton QbzPlayer 1.0 QbzPlayer.qml\nsingleton QbzLocal 1.0 QbzLocal.qml\nsingleton QbzSession 1.0 QbzSession.qml\n')
(module/'QbzShell.qml').write_text('pragma Singleton\nimport QtQuick\nQtObject { property string themeJson: ""; property int ambientMode: 0; property bool reduceMotion: false; property bool forceCanvasArt: false }')
(module/'QbzLocal.qml').write_text('pragma Singleton\nimport QtQuick\nQtObject { function artworkImmediateEnabled() { return true } }')
(module/'QbzPlayer.qml').write_text('pragma Singleton\nimport QtQuick\nQtObject { property bool npHasTrack: false }')
(module/'QbzSession.qml').write_text('pragma Singleton\nimport QtQuick\nQtObject { property var paths: '+json.dumps(derivatives)+'; signal artScaledReady(string path, string scaled, int w, int h); function artScaledCached(p,w,h) { return paths[p+"|"+w+"x"+h] || "" } function artScaled(p,w,h) {} }')
(root/'Bench.qml').write_text('''import QtQuick
import QtQuick.Window
import "'''+(ROOT/'crates/qbz-qt/qml/theme').as_uri()+'''" as Theme
import "'''+(ROOT/'crates/qbz-qt/qml/controls').as_uri()+'''" as Controls
Window {
id: benchWindow; width: 1280; height: 900; visible: true; color: "#121212"
property bool candidate: false
property int readyEvents: 0
property int revealEvents: 0
property int handoverEvents: 0
property int modeErrors: 0
property int frames: 0
onFrameSwapped: frames++
Component.onCompleted: grid.model = 200
property var sources: '''+json.dumps(sources)+'''
ListView {
id: grid; anchors.fill: parent; model: 0; cacheBuffer: 0; reuseItems: true
delegate: Row {
id: row; required property int index; height: 246; spacing: 10
Repeater {
model: 6
delegate: Item {
required property int index; width: 200; height: 246
property bool candidate: false
property bool immediateReveal: false
Component.onCompleted: {
candidate = benchWindow.candidate && benchWindow.screen.devicePixelRatio <= 1
immediateReveal = candidate && benchWindow.screen.devicePixelRatio <= 1
artSource = Qt.binding(function () {
return benchWindow ? benchWindow.sources[(row.index*6+index)%benchWindow.sources.length] : ""
})
}
property string artSource: benchWindow ? benchWindow.sources[(row.index*6+index)%benchWindow.sources.length] : ""
Theme.RoundedImage { id: art; width: 200; height: 200; radius: 8; source: parent.artSource; gridArtwork: parent.candidate; onReadyChanged: if (ready && benchWindow) benchWindow.readyEvents++
onRevealedChanged: if (revealed && benchWindow) { benchWindow.revealEvents++; if (fadeMs !== (benchWindow.candidate && benchWindow.screen.devicePixelRatio <= 1 ? 0 : 200)) benchWindow.modeErrors++ }
}
Controls.QbzSkeleton { width: 200; height: 200; variant: "art";
coverSource: parent.candidate ? "" : parent.artSource;
coverReady: parent.candidate && art.ready;
handoverFadeMs: parent.immediateReveal ? 0 : 180; animated: false
onHandedOverChanged: if (handedOver && benchWindow) { benchWindow.handoverEvents++; if (handoverFadeMs !== (benchWindow.candidate && benchWindow.screen.devicePixelRatio <= 1 ? 0 : 180)) benchWindow.modeErrors++ }
}
}
}
}
SequentialAnimation on contentY {
running: true
PauseAnimation { duration: 500 }
NumberAnimation { to: 18000; duration: 6000 }
NumberAnimation { to: 0; duration: 6000 }
PauseAnimation { duration: 1500 }
ScriptAction { script: { console.log("BENCH_RESULT", benchWindow.readyEvents, benchWindow.frames, benchWindow.revealEvents, benchWindow.handoverEvents, benchWindow.modeErrors); grid.model = 0; Qt.callLater(function() { Qt.exit(0) }) } }
}
}
}''')
xvfb = subprocess.Popen(['Xvfb','-displayfd','1','-screen','0','2560x1800x24','-nolisten','tcp'], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
try:
display = ':'+xvfb.stdout.readline().decode().strip()
rows=[]
for dpr in (1,2):
for repeat in range(args.repeats):
for candidate in ([False,True] if repeat%2==0 else [True,False]):
result=run(root,candidate,dpr,display,repeat); rows.append(result)
print(json.dumps(result), flush=True)
args.output.write_text(json.dumps({'scope':'RoundedImage + QbzSkeleton microbenchmark, Xvfb/llvmpipe; not whole QBZ', 'fixtures':str(root), 'runs':rows},indent=2)+'\n')
finally:
xvfb.terminate(); xvfb.wait(timeout=5)
if __name__ == '__main__': main()
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Whole-QBZ A/B probe on a private X server, using a read-only metadata copy.
Requires QBZ_PROBE_LIBRARY. Run only with no concurrent build or other probe.
"""
import argparse, json, os, subprocess, sys, time
from pathlib import Path
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('binary',type=Path)
parser.add_argument('--baseline-binary',type=Path,help='Compare two builds with immediate artwork enabled in both')
parser.add_argument('--output',type=Path,required=True)
parser.add_argument('--repeats',type=int,default=2)
args=parser.parse_args()
results=[]
for repeat in range(args.repeats):
for candidate in ((0,1) if repeat%2==0 else (1,0)):
env=dict(os.environ,QBZ_GRID_IMMEDIATE_ART=str(1 if args.baseline_binary else candidate))
binary = args.baseline_binary if args.baseline_binary and candidate == 0 else args.binary
process=subprocess.Popen([sys.executable,str(Path(__file__).with_name('probe_qt_private_ui.py')),str(binary.resolve())],
env=env,stdin=subprocess.PIPE,stdout=subprocess.PIPE,text=True)
root=None
def command(value):
process.stdin.write(value+'\n');process.stdin.flush()
while True:
line=process.stdout.readline()
if not line:raise RuntimeError('probe stopped')
if line.strip()=='ready':return
print(line.strip(),flush=True)
def wait_log(fragment):
deadline=time.monotonic()+45
while time.monotonic()<deadline:
if process.poll() is not None:raise RuntimeError('probe exited')
log=(root/'app.log').read_text(errors='replace')
if fragment in log or (fragment=='QbzCore initialized' and 'Starting in offline-tolerant mode' in log):return
time.sleep(.2)
raise RuntimeError('missing startup marker: '+fragment)
try:
info=json.loads(process.stdout.readline());root=Path(info['root'])
print(json.dumps({'repeat':repeat,'candidate':candidate,'profile':str(root)}),flush=True)
wait_log('QbzCore initialized')
time.sleep(2)
command('click 588 520') # Start offline in the fresh profile.
wait_log('phase=catch-up-active')
time.sleep(2)
command('click 464 110') # Local Library > Albums.
wait_log('phase=albums-native')
time.sleep(5)
command('shot') # Baseline evidence only, outside measured interval.
command('bench')
log=(root/'app.log').read_text(errors='replace')
for error in ('ReferenceError', 'TypeError', 'Cannot read', 'is not a type', 'Unable to assign'):
if error in log:raise RuntimeError('invalid UI run: '+error+'; '+str(root/'app.log'))
result=json.loads((root/'benchmark.json').read_text())
result.update(repeat=repeat,profile=str(root),scenario=candidate,binary=str(binary.resolve()))
results.append(result)
args.output.write_text(json.dumps({'scope':'Whole QBZ, Xvfb/llvmpipe, copied metadata, no playback', 'runs':results},indent=2)+'\n')
finally:
if process.poll() is None:
process.stdin.write('quit\n');process.stdin.flush()
try:process.wait(timeout=10)
except subprocess.TimeoutExpired:process.kill();process.wait()
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Private X11 QBZ probe. Copies library metadata; never controls the user's display.
Commands on stdin: shot, click X Y, wheel N, key NAME, stats, quit.
"""
import ctypes as C
from ctypes.util import find_library
import json, os, signal, sqlite3, subprocess, sys, tempfile, time
from pathlib import Path
import shutil
from PIL import ImageGrab
root=Path(tempfile.mkdtemp(prefix='qbz-private-ui-'))
for folder in ('config','data/qbz/users/0','cache','state','run'):
(root/folder).mkdir(parents=True,exist_ok=True,mode=0o700)
origin=Path.home()/'.local/share/qbz'
library = Path(os.environ['QBZ_PROBE_LIBRARY'])
for source, target in ((library, root/'data/qbz/users/0/library.db'),
(origin/'legal_settings.db', root/'data/qbz/legal_settings.db')):
src=sqlite3.connect(f'file:{source}?mode=ro',uri=True)
dst=sqlite3.connect(target)
src.backup(dst); dst.close(); src.close()
shutil.copytree(origin/'thumbnails',root/'data/qbz/thumbnails')
dpr=int(os.environ.get('QBZ_PROBE_DPR','1'))
if dpr not in (1,2): raise ValueError('QBZ_PROBE_DPR must be 1 or 2')
xvfb=subprocess.Popen(['Xvfb','-displayfd','1','-screen','0',f'{1280*dpr}x{900*dpr}x24','-nolisten','tcp'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL)
display=':'+xvfb.stdout.readline().decode().strip()
x=C.CDLL(find_library('X11')); xt=C.CDLL(find_library('Xtst'))
x.XOpenDisplay.argtypes=[C.c_char_p]; x.XOpenDisplay.restype=C.c_void_p
d=x.XOpenDisplay(display.encode())
x.XFlush.argtypes=[C.c_void_p]; x.XCloseDisplay.argtypes=[C.c_void_p]
x.XStringToKeysym.argtypes=[C.c_char_p]; x.XStringToKeysym.restype=C.c_ulong
x.XKeysymToKeycode.argtypes=[C.c_void_p,C.c_ulong]; x.XKeysymToKeycode.restype=C.c_uint
xt.XTestFakeMotionEvent.argtypes=[C.c_void_p,C.c_int,C.c_int,C.c_int,C.c_ulong]
xt.XTestFakeButtonEvent.argtypes=[C.c_void_p,C.c_uint,C.c_int,C.c_ulong]
xt.XTestFakeKeyEvent.argtypes=[C.c_void_p,C.c_uint,C.c_int,C.c_ulong]
env=dict(os.environ,QT_SCALE_FACTOR=str(dpr),DISPLAY=display,QT_QPA_PLATFORM='xcb',QSG_RHI_BACKEND='opengl',LIBGL_ALWAYS_SOFTWARE='1',RUST_LOG='info')
for var,folder in [('XDG_CONFIG_HOME','config'),('XDG_DATA_HOME','data'),('XDG_CACHE_HOME','cache'),('XDG_STATE_HOME','state'),('XDG_RUNTIME_DIR','run')]:env[var]=str(root/folder)
env.pop('WAYLAND_DISPLAY',None);env.pop('DBUS_SESSION_BUS_ADDRESS',None)
# This is an offline-library benchmark. Fail external HTTP promptly inside
# this child instead of letting bundle/CDN latency enter startup or CPU totals.
for proxy in ('HTTP_PROXY','HTTPS_PROXY','ALL_PROXY','http_proxy','https_proxy','all_proxy'):
env[proxy]='http://127.0.0.1:9'
env['NO_PROXY']=env['no_proxy']='127.0.0.1,localhost'
log=(root/'app.log').open('w')
app=subprocess.Popen(['dbus-run-session','--',str(Path(sys.argv[1]).resolve())],env=env,stdout=log,stderr=subprocess.STDOUT,start_new_session=True)
print(json.dumps({'root':str(root),'display':display,'supervisor':app.pid}),flush=True)
def button(n):
xt.XTestFakeButtonEvent(d,n,1,0);xt.XTestFakeButtonEvent(d,n,0,0);x.XFlush(d)
def key(name):
keys=[x.XKeysymToKeycode(d,x.XStringToKeysym(k.encode())) for k in name.split('+')]
for k in keys:xt.XTestFakeKeyEvent(d,k,1,0)
for k in reversed(keys):xt.XTestFakeKeyEvent(d,k,0,0)
x.XFlush(d)
def process_stats():
for p in Path('/proc').iterdir():
if not p.name.isdigit():continue
try:
e=(p/'environ').read_bytes()
if b'XDG_RUNTIME_DIR='+str(root/'run').encode()+b'\0' not in e:continue
if (p/'exe').resolve() != Path(sys.argv[1]).resolve():continue
st=(p/'stat').read_text().split(') ',1)[1].split()
rss=next(s for s in (p/'smaps_rollup').read_text().splitlines() if s.startswith('Rss:'))
return {'pid':int(p.name),'cpu_s':(int(st[11])+int(st[12]))/os.sysconf('SC_CLK_TCK'),'rss_mib':int(rss.split()[1])/1024}
except (FileNotFoundError,ProcessLookupError,PermissionError):pass
raise RuntimeError('QBZ process is absent')
def benchmark():
# Real wheel events, no capture or logging per frame. Keep the pointer
# inside the grid. Each fresh profile follows the same finite route.
xt.XTestFakeMotionEvent(d,-1,750*dpr,400*dpr,0);x.XFlush(d)
start=process_stats(); peak=start['rss_mib']; samples=[]
for direction in (5,4,5,4):
for step in range(40):
button(direction);time.sleep(.1)
point=process_stats();peak=max(peak,point['rss_mib']);samples.append(point)
scrolled=process_stats()
for second in range(60):
time.sleep(1);point=process_stats();peak=max(peak,point['rss_mib']);samples.append(point)
end=process_stats()
result={'dpr':dpr,'start':start,'scroll_end':scrolled,'end':end,'peak_mib':peak,
'scroll_cpu_s':scrolled['cpu_s']-start['cpu_s'],
'idle_cpu_s':end['cpu_s']-scrolled['cpu_s'],
'candidate':os.environ.get('QBZ_GRID_IMMEDIATE_ART','1'),
'samples':samples}
(root/'benchmark.json').write_text(json.dumps(result,indent=2)+'\n')
print(json.dumps({k:v for k,v in result.items() if k!='samples'}),flush=True)
try:
for line in sys.stdin:
args=line.strip().split()
if not args:continue
if args[0]=='shot':
path=root/'screen.png';ImageGrab.grab(xdisplay=display).save(path);print(path,flush=True)
elif args[0]=='click':
xt.XTestFakeMotionEvent(d,-1,int(args[1])*dpr,int(args[2])*dpr,0);button(1)
elif args[0]=='wheel':
n=int(args[1])
for _ in range(abs(n)):button(5 if n>0 else 4);time.sleep(.016)
elif args[0]=='key':key(args[1])
elif args[0]=='stats':print(json.dumps(process_stats()),flush=True)
elif args[0]=='bench':benchmark()
elif args[0]=='quit':break
print('ready',flush=True)
finally:
try:os.killpg(app.pid,signal.SIGTERM)
except ProcessLookupError:pass
try:app.wait(timeout=5)
except subprocess.TimeoutExpired:os.killpg(app.pid,signal.SIGKILL);app.wait()
x.XCloseDisplay(d);xvfb.terminate();xvfb.wait(timeout=5);log.close()
+5
View File
@@ -0,0 +1,5 @@
P3
2 2
255
0 0 255 0 0 255
0 0 255 0 0 255
+5
View File
@@ -0,0 +1,5 @@
P3
2 2
255
255 0 0 255 0 0
255 0 0 255 0 0
@@ -0,0 +1,3 @@
pragma Singleton
import QtQuick
QtObject { signal libraryArtworkReady(string key, string path); signal pinChanged(string key, bool value); signal libraryFavoriteChanged(string key, bool value) }
@@ -1,6 +1,7 @@
pragma Singleton
import QtQuick
QtObject {
function artworkImmediateEnabled() { return true }
property string mediaStatus: "{}"
property int pairingCancels: 0
function mediaCancelQuickConnect() { pairingCancels++ }
@@ -0,0 +1,3 @@
pragma Singleton
import QtQuick
QtObject { property int entitlementRev: 0; function isAlbumPurchased(id,rev) { return false } function albumPurchasedQuality(id,rev) { return "" } }
@@ -1,3 +1,7 @@
pragma Singleton
import QtQuick
QtObject { property int trRev: 0; function tr(text, rev) { return text } }
QtObject {
signal artScaledReady(string path, string scaled, int w, int h)
function artScaledCached(path, w, h) { return "" }
function artScaled(path, w, h) {}
property int trRev: 0; function tr(text, rev) { return text } }
@@ -1,6 +1,9 @@
pragma Singleton
import QtQuick
QtObject {
signal trackCacheStatusChanged(string trackId, int status, real progress)
property bool reduceMotion: false
property bool forceCanvasArt: false
property string restoreScope: ""
property real scrollRestore: 0
function reportScroll(scope, y) {}
@@ -4,3 +4,5 @@ singleton QbzPlayer 1.0 QbzPlayer.qml
singleton QbzLocal 1.0 QbzLocal.qml
singleton QbzSession 1.0 QbzSession.qml
singleton QbzBridge 1.0 QbzBridge.qml
singleton QbzLibrary 1.0 QbzLibrary.qml
singleton QbzPurchases 1.0 QbzPurchases.qml
@@ -0,0 +1,53 @@
import QtQuick
import QtQuick.Window
import QtTest
import com.blitzfc.qbz
import "../../crates/qbz-qt/qml/theme" as Theme
import "../../crates/qbz-qt/qml/controls" as Controls
Item {
width: 240; height: 240
Theme.RoundedImage { id: art; width: 200; height: 200; fadeMs: 0; radius: 8 }
Controls.QbzSkeleton {
id: skeleton; width: 200; height: 200; animated: false
coverReady: art.ready; handoverFadeMs: 0
}
Theme.RoundedImage { id: gridArt; width: 200; height: 200; gridArtwork: true; visible: false }
TestCase {
name: "ArtworkHandover"; when: windowShown
function init() { art.source = ""; art.fadeMs = 0; QbzShell.forceCanvasArt = false }
function test_ready_image_replaces_placeholder_without_fade() {
art.source = Qt.resolvedUrl("fixtures/red.ppm")
tryCompare(art, "ready", true)
tryCompare(art, "revealed", true)
compare(skeleton.handedOver, true)
tryCompare(skeleton, "opacity", 0, 100)
tryCompare(skeleton, "opacity", 0, 100)
waitForRendering(art, 100)
var shot = grabImage(art)
verify(shot.red(100,100) >= 254 && shot.green(100,100) <= 2 && shot.blue(100,100) <= 2)
}
function test_recycled_source_draws_new_pixels() {
art.source = Qt.resolvedUrl("fixtures/red.ppm")
tryCompare(art, "revealed", true)
art.source = Qt.resolvedUrl("fixtures/blue.ppm")
tryCompare(art, "revealed", true)
tryCompare(skeleton, "opacity", 0, 100)
waitForRendering(art, 100)
var shot = grabImage(art)
verify(shot.blue(100,100) >= 254 && shot.red(100,100) <= 2 && shot.green(100,100) <= 2)
}
function test_shared_grid_policy() {
compare(gridArt.immediateGridArtwork, gridArt._dpr <= 1)
compare(gridArt.fadeMs, gridArt.immediateGridArtwork ? 0 : 200)
gridArt.gridArtwork = false
compare(gridArt.fadeMs, 200)
gridArt.gridArtwork = true
}
function test_existing_fade_remains_opt_in_default() {
art.fadeMs = 200
art.source = Qt.resolvedUrl("fixtures/blue.ppm")
tryCompare(art, "ready", true)
tryCompare(art, "revealed", true, 1000)
}
}
}
+83
View File
@@ -0,0 +1,83 @@
import QtQuick
import QtQuick.Window
import QtTest
import com.blitzfc.qbz
import "../../crates/qbz-qt/qml/views/library" as Library
Item {
width: 240; height: 270
QtObject {
id: host
property var artMap: ({})
property bool showSourceBadges: false
property string activeTab: "all"
property bool skelPhase: false
function askRemoveReleaseFavorites(item) {}
}
Library.FeedGridCell { id: cell; view: host; item: ({kind: "group-header", id: "", title: ""}) }
GridView {
id: bufferedGrid
width: 880; height: 266; cellWidth: 220; cellHeight: 266
cacheBuffer: 2 * cellHeight
model: 0
delegate: Library.FeedGridCell {
required property int index
view: host
item: ({kind:"album", id:String(index), artKey:"buffer"+index,
title:"Buffered", imageUrl:"fixture", artist:"Artist"})
}
}
TestCase {
name: "GridCards"; when: windowShown
function test_two_rows_decode_before_scrolling() {
bufferedGrid.model = 40
var paths = {}
for (var i=0;i<12;i++) paths["buffer"+i]=Qt.resolvedUrl("fixtures/red.ppm").toString()
host.artMap = paths
function ready(index) {
var delegate=bufferedGrid.itemAtIndex(index)
if (!delegate) return false
for (var i=0;i<delegate.children.length;i++) {
var child=delegate.children[i]
if (child.item && child.item.artworkReady === true) return true
}
return false
}
for (var index=4;index<12;index++) {
tryVerify(function() {return ready(index)}, 3000,
"buffered row must be decoded before entering the viewport")
}
compare(bufferedGrid.contentY, 0)
bufferedGrid.contentY = 532
for (index=8;index<12;index++) verify(ready(index))
bufferedGrid.model=0
}
function test_real_card_handover_data() {
return ["album", "track", "artist", "label", "playlist"].map(function(kind) {
return {tag: kind, kind: kind}
})
}
function test_real_card_handover(data) {
host.artMap = ({})
cell.item = {kind:data.kind, id:"test", artKey:"cover", title:"Fixture", artist:"Artist",
artistId:"1", genre:"", year:"2000", qualityTier:"", source:"qobuz",
imageUrl:"fixture", isFavorite:false, isPinned:false, playlistOwnImage:true}
function card() {
for (var i=0;i<cell.children.length;i++) {
var child=cell.children[i]
if (child.item && child.item.artworkReady !== undefined) return child.item
}
return null
}
tryVerify(function() { return card() !== null })
compare(card().artworkReady, false)
host.artMap = {cover:Qt.resolvedUrl("fixtures/red.ppm").toString()}
tryVerify(function() { return card().artworkReady })
compare(card().artworkImmediate, Screen.devicePixelRatio <= 1)
if (Screen.devicePixelRatio > 1) wait(250)
waitForRendering(cell, 100)
var shot=grabImage(cell)
verify(shot.red(100,100)>240 && shot.green(100,100)<10, "cover must replace grey at center")
cell.item = {kind:"group-header", id:"", title:""}
}
}
}
@@ -0,0 +1,49 @@
import QtQuick
import QtTest
import "../../crates/qbz-qt/qml/controls" as Controls
Item {
width: 800
height: 600
Controls.QbzConfirmModal {
id: modal
anchors.fill: parent
title: "Close QBZ?"
confirmLabel: "Yes"
cancelLabel: "No"
checkboxLabel: "Don't show again"
danger: false
}
SignalSpy { id: confirmed; target: modal; signalName: "confirmed" }
SignalSpy { id: cancelled; target: modal; signalName: "cancelled" }
TestCase {
name: "QuitConfirmation"
when: windowShown
function init() {
confirmed.clear(); cancelled.clear()
modal.checkboxChecked = false
modal.open()
wait(30)
}
function cleanup() { modal.close() }
function test_escape_cancels_without_confirming() {
keyClick(Qt.Key_Escape)
compare(cancelled.count, 1)
compare(confirmed.count, 0)
verify(!modal.opened)
}
function test_initial_enter_is_no() {
keyClick(Qt.Key_Return)
compare(cancelled.count, 1)
compare(confirmed.count, 0)
}
function test_keyboard_yes_preserves_checkbox_for_handler() {
modal.checkboxChecked = true
keyClick(Qt.Key_Tab)
keyClick(Qt.Key_Return)
compare(confirmed.count, 1)
compare(cancelled.count, 0)
verify(modal.checkboxChecked)
verify(!modal.opened)
}
}
}
+66
View File
@@ -0,0 +1,66 @@
// Run the actual artwork batching callbacks; no account, disk cache or audio.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync(new URL('../crates/qbz-qt/qml/views/LocalLibraryView.qml', import.meta.url), 'utf8');
function block(marker, from = 0) {
const pos = source.indexOf(marker, from);
assert.ok(pos >= 0, marker);
const begin = source.indexOf('{', pos);
let depth = 1;
for (let end = begin + 1; end < source.length; end++) {
if (source[end] === '{') depth++;
if (source[end] === '}' && --depth === 0) return "(function () {" + source.slice(begin + 1, end) + "})()";
}
throw new Error(`unclosed ${marker}`);
}
let ticks = 0;
let pulses = 0;
let models = 0;
const model = {setArtwork() { models++; }};
const root = {
immediateArtwork: true, artTimingEnabled: false,
artMap: {}, _artInbox: {}, _artHeld: {}, _artKeep: {},
_artOrder: {}, _artFront: 0, artRevealStep: 6,
nativeTracksModel: model, nativeAlbumsModel: model,
nativeArtistsModel: model, nativeArtistAlbumsModel: model,
traceArt() {}, artPulse: false,
};
const ctx = vm.createContext({root,
artFlush: {running: false, start() { ticks++; }},
artPulseOff: {restart() { pulses++; }},
QbzLocal: {artworkWindow() {}},
});
const flush = block('onTriggered:', source.indexOf('id: artFlush'));
const arrive = block('function onLocalArtworkReady(');
for (let i = 0; i < 30; i++) {
root._artInbox[`cover${i}`] = `path${i}`;
root._artOrder[`cover${i}`] = i;
}
vm.runInContext(flush, ctx);
assert.equal(Object.keys(root.artMap).length, 30, 'ready covers must not wait behind the reveal front');
assert.equal(Object.keys(root._artInbox).length, 0);
assert.equal(ticks, 0, 'no follow-up reveal timer');
root._artKeep = {visible: true};
ctx.key = 'old-window'; ctx.path = 'stale.png';
vm.runInContext(arrive, ctx);
assert.equal(root._artInbox['old-window'], undefined, 'late results cannot repopulate the viewport cache');
assert.equal(pulses, 0, 'late results cannot restart the placeholder animation');
assert.equal(models, 4, 'native catalog enrichment still receives the resolved path');
ctx.key = 'visible'; ctx.path = 'visible.png';
vm.runInContext(arrive, ctx);
assert.equal(root._artInbox.visible, 'visible.png');
assert.equal(ticks, 1);
assert.equal(pulses, 1);
// A/B baseline preserves the previous ordered reveal.
root.immediateArtwork = false;
root.artMap = {}; root._artInbox = {}; root._artHeld = {}; root._artFront = 0;
for (let i = 0; i < 12; i++) root._artInbox[`cover${i}`] = `path${i}`;
vm.runInContext(flush, ctx);
assert.equal(Object.keys(root.artMap).length, 6);
assert.equal(Object.keys(root._artHeld).length, 6);
root.immediateArtwork = true;
vm.runInContext(flush, ctx);
assert.equal(Object.keys(root.artMap).length, 12, 'moving to DPR 1 releases held covers');
assert.equal(Object.keys(root._artHeld).length, 0);
console.log('Artwork reveal: immediate batch, stale-result eviction, bounded timers and A/B baseline passed');
+96
View File
@@ -0,0 +1,96 @@
// Exercise the production range/report callbacks with fake native view geometry.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const base = new URL('../crates/qbz-qt/qml/views/', import.meta.url);
function fn(file, name) {
const source=fs.readFileSync(new URL(file,base),'utf8');
const begin=source.indexOf('function '+name+'('); assert.ok(begin>=0);
const open=source.indexOf('{',begin); let depth=1;
for(let i=open+1;i<source.length;i++) {
if(source[i]==='{')depth++;
if(source[i]==='}'&&!--depth)return '('+source.slice(begin,i+1)+')';
}
throw Error(name);
}
function call(file,name,ctx,...args) {return vm.runInNewContext(fn(file,name),ctx)(...args);}
let windows=[];
const rows=Array.from({length:200},(_,i)=>({kind:'album',artKey:'a'+i,imageUrl:'url'+i}));
const root={visibleRows:rows};
let grid={visible:true,width:880,cellWidth:220,cellHeight:266,height:532,contentY:2660};
call('LibraryView.qml','gridWindowReport',{root,grid,queueWindowReport:(...a)=>windows.push(a)});
assert.deepEqual(windows[0].filter((_,i)=>i!==2),[32,55,40]); // visible rows 10/11, ±2
let starts=0;
const timer={running:false,start(){starts++;this.running=true;}};
for(let i=0;i<50;i++)call('LibraryView.qml','queueWindowReport',{windowDebounce:timer},i,i+20);
assert.equal(starts,1,'continuous scroll must not postpone the flush');
assert.equal(timer.pendingFirst,49,'only the latest pending window survives');
Object.assign(root,{artMap:{},_artInbox:{},_artSent:{}});
const requests=[];
const ctx={root,artMap:root.artMap,QbzLibrary:{libraryArtworkWindow:s=>requests.push(JSON.parse(s))}};
call('LibraryView.qml','reportWindow',ctx,rows,32,55,40);
assert.equal(requests[0][0],'a40','visible art precedes buffered art');
assert.equal(requests[0].length,24);
call('LibraryView.qml','reportWindow',ctx,rows,32,55,40);
assert.equal(requests.length,1,'pending downloads are not duplicated every tick');
root._artSent.a40 -= 30000;
call('LibraryView.qml','reportWindow',ctx,rows,32,55,40);
assert.deepEqual(requests[1],['a40'],'a failed request can be retried on a later report');
root.artMap.a40='cached.png'; root._artSent.a40 -= 30000;
call('LibraryView.qml','reportWindow',ctx,rows,32,55,40);
assert.equal(requests.length,2,'resolved covers do not re-enter the bridge');
call('LibraryView.qml','reportWindow',ctx,[],0,-1);
assert.equal(Object.keys(root._artSent).length,0,'empty model releases request state');
// Native Local Library uses entry rows, not album indices. Two buffered rows
// of four albums must be requested even though they are outside the viewport.
let captured;
const entries=Array.from({length:40},(_,i)=>({t:1,items:Array.from({length:4},(_,j)=>({id:i*4+j}))}));
const local={viewMode:'grid',surface:'albums'};
const view={releaseWindow(){throw Error('unexpected release');},queueWindowReport:r=>captured=r};
const list={visible:true,contentY:1000,height:200,indexAt:(x,y)=>y<1100?10:11};
call('local/LocalAlbumCollection.qml','report',{root:local,view,list,nativeActive:true,nativeModel:{totalCount:40,rowAt:i=>entries[i]},width:800});
assert.equal(captured.length,24);
assert.equal(captured[0].id,40);
assert.deepEqual([...captured.map(x=>x.id)].sort((a,b)=>a-b),Array.from({length:24},(_,i)=>32+i));
// Scene must request the current band after row 48, not keep walking the head.
let urls;
const scene={isReady:true,visible:true,cardH:246,rowGap:11,rowModel:entries.map((e,i)=>({artists:[{artUrl:'u'+i}]})),requestCovers:u=>urls=u};
call('ArtistSceneView.qml','requestVisibleCovers',{root:scene},list);
assert.deepEqual([...urls],['u10','u11','u12','u13','u8','u9']);
console.log('Grid prefetch: two rows, visible priority, continuous-scroll flush, dedup and native ranges passed');
// Motion within the same band must not rebuild native QVariant rows or keep
// scheduling Library reports. Late data/layout/artwork callbacks bypass it.
let clock = 10000;
const fakeDate = {now: () => clock};
let reads = 0, sends = 0;
const motionCtx = {root:local,view:{releaseWindow(){},queueWindowReport(){sends++;}},
list,nativeActive:true,nativeModel:{totalCount:40,rowAt:i=>{reads++;return entries[i];}},
width:800,Date:fakeDate};
local._motionBand = '';
call('local/LocalAlbumCollection.qml','report',motionCtx,true);
for(let i=0;i<100;i++)call('local/LocalAlbumCollection.qml','report',motionCtx,true);
assert.equal(reads,6,'100 motion events in one band read native rows only once');
assert.equal(sends,1);
call('local/LocalAlbumCollection.qml','report',motionCtx);
assert.equal(sends,2,'late page/artwork reports bypass motion dedup');
clock += 1001;
call('local/LocalAlbumCollection.qml','report',motionCtx,true);
assert.equal(sends,3,'continued movement permits a retry refresh');
list.indexAt=(x,y)=>y<1100?11:12;
call('local/LocalAlbumCollection.qml','report',motionCtx,true);
assert.equal(sends,4,'crossing a row boundary immediately advances prefetch');
let gridSends=0;
const gridCtx={root,grid,Date:fakeDate,queueWindowReport(){gridSends++;}};
root._gridMotionBand='';
for(let i=0;i<100;i++)call('LibraryView.qml','gridWindowReport',gridCtx,true);
assert.equal(gridSends,1);
call('LibraryView.qml','gridWindowReport',gridCtx);
assert.equal(gridSends,2,'a filter/model replacement cannot be suppressed');
grid.contentY += grid.cellHeight;
call('LibraryView.qml','gridWindowReport',gridCtx,true);
assert.equal(gridSends,3);
clock += 1001;
call('LibraryView.qml','gridWindowReport',gridCtx,true);
assert.equal(gridSends,4);
console.log('Motion dedup: native reads, late pages, model refresh, row changes and retries passed');
+59
View File
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const qml = fs.readFileSync(new URL('../crates/qbz-qt/qml/Main.qml', import.meta.url), 'utf8');
function body(name) {
const pos = qml.indexOf(`function ${name}(`);
assert.ok(pos >= 0, name);
const begin = qml.indexOf('{', pos);
for (let i = begin + 1, depth = 1; i < qml.length; i++) {
if (qml[i] === '{') depth++;
if (qml[i] === '}' && --depth === 0) return qml.slice(begin + 1, i);
}
throw new Error(name);
}
function fixture(tray, mac, closeToTray = true, confirm = true) {
const calls = [];
const ctx = vm.createContext({
QbzTray: {trayLive: tray, closeToTray, closeDecision() {},
confirmQuitEnabled: () => confirm, armQuitWatchdog: () => calls.push('watchdog')},
QbzShell: {isMacos: mac}, Window: {Minimized: 3},
Qt: {exit: (code) => calls.push(`exit:${code}`)},
quitConfirmation: {opened: false, checkboxChecked: true, open() {this.opened = true;}},
visible: true, visibility: 2, quitAccepted: false,
hideToTray: () => calls.push('hide'), showFromTray: () => calls.push('show'),
raise() {}, requestActivate() {}, persistWindowGeometryOnExit: () => calls.push('persist'),
});
ctx.window = ctx;
for (const name of ['closeOrHide', 'requestQuit', 'finishQuit'])
vm.runInContext(`function ${name}(${name === 'closeOrHide' ? 'closeEvent' : ''}) {${body(name)}}`, ctx);
return {ctx, calls};
}
for (const mac of [false, true]) {
let {ctx, calls} = fixture(true, mac);
const event = {accepted: true};
ctx.closeOrHide(event);
assert.deepEqual(calls, ['hide']);
assert.equal(event.accepted, false);
({ctx, calls} = fixture(false, mac));
ctx.closeOrHide({accepted: true});
assert.equal(ctx.quitConfirmation.opened, true);
assert.deepEqual(calls, [], 'dialog must not arm the watchdog');
ctx.quitConfirmation.opened = false; // user cancels
assert.deepEqual(calls, []);
ctx.requestQuit();
ctx.quitConfirmation.opened = false; // user accepts
ctx.finishQuit(); ctx.finishQuit();
assert.deepEqual(calls, ['watchdog', 'persist', 'exit:0']);
({ctx, calls} = fixture(true, mac));
ctx.requestQuit();
assert.equal(ctx.quitConfirmation.opened, true, 'Quit always confirms, even with tray');
assert.deepEqual(calls, []);
({ctx, calls} = fixture(true, mac, true, false));
ctx.requestQuit();
assert.deepEqual(calls, ['watchdog', 'persist', 'exit:0']);
}
const {ctx, calls} = fixture(true, true, false);
ctx.closeOrHide(null);
assert.deepEqual(calls, ['hide'], 'macOS tray lifecycle supersedes legacy close preference');
console.log('Window lifecycle: tray/no-tray, macOS policy, confirmation cancellation and one-shot exit passed');