From 2fc03d325d800ca2a0c6d8cf16a1f23e66da4590 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:58:25 -0600 Subject: [PATCH] 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 --- .../translations/de/LC_MESSAGES/qbz-ui.po | 16 ++ .../translations/en/LC_MESSAGES/qbz-ui.po | 16 ++ .../translations/es/LC_MESSAGES/qbz-ui.po | 16 ++ .../translations/fr/LC_MESSAGES/qbz-ui.po | 16 ++ .../translations/ja/LC_MESSAGES/qbz-ui.po | 16 ++ .../translations/nl/LC_MESSAGES/qbz-ui.po | 16 ++ .../translations/pt/LC_MESSAGES/qbz-ui.po | 16 ++ .../translations/ru/LC_MESSAGES/qbz-ui.po | 16 ++ crates/qbz-qt/build.rs | 2 + crates/qbz-qt/cxx/app_lifecycle.cpp | 45 +++++ .../qbz-qt/cxx/tests/app_lifecycle_test.cpp | 34 ++++ crates/qbz-qt/qml/Main.qml | 66 ++++++-- crates/qbz-qt/qml/cards/AlbumCard.qml | 7 + crates/qbz-qt/qml/cards/ArtistCard.qml | 11 +- crates/qbz-qt/qml/cards/CollectionMosaic.qml | 3 + crates/qbz-qt/qml/cards/LabelCard.qml | 26 ++- crates/qbz-qt/qml/cards/PlaylistCard.qml | 7 + crates/qbz-qt/qml/cards/PlaylistCollage.qml | 2 + crates/qbz-qt/qml/cards/RadioCard.qml | 4 + crates/qbz-qt/qml/cards/SlimCard.qml | 2 + crates/qbz-qt/qml/cards/TrackCard.qml | 4 + .../qbz-qt/qml/controls/QbzConfirmModal.qml | 24 +++ crates/qbz-qt/qml/controls/QbzSkeleton.qml | 24 ++- .../qml/settings/AppearanceSettings.qml | 21 +-- crates/qbz-qt/qml/theme/RoundedImage.qml | 11 +- crates/qbz-qt/qml/views/AlbumCollection.qml | 5 + crates/qbz-qt/qml/views/ArtistSceneView.qml | 55 ++++--- crates/qbz-qt/qml/views/HomeView.qml | 12 +- crates/qbz-qt/qml/views/LibraryView.qml | 60 +++++-- crates/qbz-qt/qml/views/LocalLibraryView.qml | 60 ++++++- crates/qbz-qt/qml/views/SearchView.qml | 7 +- .../qbz-qt/qml/views/library/FeedGridCell.qml | 5 +- .../qbz-qt/qml/views/local/FolderSubcard.qml | 1 + .../qml/views/local/LocalAlbumCollection.qml | 67 ++++++-- crates/qbz-qt/qml/views/myqbz/MyQbzCard.qml | 2 + .../qml/views/playlistmanager/PmGridCard.qml | 1 + .../qml/views/purchases/PurchaseGridCard.qml | 1 + .../qbz-qt/qml/views/scene/ScenePanelMode.qml | 10 +- crates/qbz-qt/src/artwork_qt.rs | 116 ++++++++++++- crates/qbz-qt/src/label_qt.rs | 56 ++++++- crates/qbz-qt/src/local_bridge.rs | 31 ++++ crates/qbz-qt/src/main.rs | 26 ++- crates/qbz-qt/src/tray_bridge.rs | 44 ++++- crates/qbz-qt/src/tray_qt.rs | 2 +- scripts/bench_qt_artwork.py | 155 ++++++++++++++++++ scripts/bench_qt_private_ui.py | 61 +++++++ scripts/probe_qt_private_ui.py | 110 +++++++++++++ scripts/qml-tests/fixtures/blue.ppm | 5 + scripts/qml-tests/fixtures/red.ppm | 5 + .../imports/com/blitzfc/qbz/QbzLibrary.qml | 3 + .../imports/com/blitzfc/qbz/QbzLocal.qml | 1 + .../imports/com/blitzfc/qbz/QbzPurchases.qml | 3 + .../imports/com/blitzfc/qbz/QbzSession.qml | 6 +- .../imports/com/blitzfc/qbz/QbzShell.qml | 3 + .../qml-tests/imports/com/blitzfc/qbz/qmldir | 2 + scripts/qml-tests/tst_artwork_handover.qml | 53 ++++++ scripts/qml-tests/tst_grid_cards.qml | 83 ++++++++++ scripts/qml-tests/tst_quit_confirmation.qml | 49 ++++++ scripts/test_qt_artwork_reveal.mjs | 66 ++++++++ scripts/test_qt_grid_prefetch.mjs | 96 +++++++++++ scripts/test_qt_window_lifecycle.mjs | 59 +++++++ 61 files changed, 1591 insertions(+), 151 deletions(-) create mode 100644 crates/qbz-qt/cxx/app_lifecycle.cpp create mode 100644 crates/qbz-qt/cxx/tests/app_lifecycle_test.cpp create mode 100644 scripts/bench_qt_artwork.py create mode 100644 scripts/bench_qt_private_ui.py create mode 100644 scripts/probe_qt_private_ui.py create mode 100644 scripts/qml-tests/fixtures/blue.ppm create mode 100644 scripts/qml-tests/fixtures/red.ppm create mode 100644 scripts/qml-tests/imports/com/blitzfc/qbz/QbzLibrary.qml create mode 100644 scripts/qml-tests/imports/com/blitzfc/qbz/QbzPurchases.qml create mode 100644 scripts/qml-tests/tst_artwork_handover.qml create mode 100644 scripts/qml-tests/tst_grid_cards.qml create mode 100644 scripts/qml-tests/tst_quit_confirmation.qml create mode 100644 scripts/test_qt_artwork_reveal.mjs create mode 100644 scripts/test_qt_grid_prefetch.mjs create mode 100644 scripts/test_qt_window_lifecycle.mjs diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index 6cbb3eb7c..05b45961a 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index 774a6b363..102163eaa 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index b97e59f15..1bf5814fb 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index 12293cde5..1b7d47b0a 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index fdd3503b2..ac465eead 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -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 "今後表示しない" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index 7c56baaa3..9647d31f7 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index a181aeb59..c8329a8b3 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index 3579f01f6..9fad51ce8 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -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 "Больше не показывать" diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 7e0cead42..c021ad9fa 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -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"); diff --git a/crates/qbz-qt/cxx/app_lifecycle.cpp b/crates/qbz-qt/cxx/app_lifecycle.cpp new file mode 100644 index 000000000..c1fc76abc --- /dev/null +++ b/crates/qbz-qt/cxx/app_lifecycle.cpp @@ -0,0 +1,45 @@ +#include +#include +#include +#include + +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(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); + } +} diff --git a/crates/qbz-qt/cxx/tests/app_lifecycle_test.cpp b/crates/qbz-qt/cxx/tests/app_lifecycle_test.cpp new file mode 100644 index 000000000..2de964750 --- /dev/null +++ b/crates/qbz-qt/cxx/tests/app_lifecycle_test.cpp @@ -0,0 +1,34 @@ +#include +#include +#include +#include + +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); +} diff --git a/crates/qbz-qt/qml/Main.qml b/crates/qbz-qt/qml/Main.qml index 084251252..8faf289b7 100644 --- a/crates/qbz-qt/qml/Main.qml +++ b/crates/qbz-qt/qml/Main.qml @@ -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() } } diff --git a/crates/qbz-qt/qml/cards/AlbumCard.qml b/crates/qbz-qt/qml/cards/AlbumCard.qml index 7e8a3a756..cfd2af5ac 100644 --- a/crates/qbz-qt/qml/cards/AlbumCard.qml +++ b/crates/qbz-qt/qml/cards/AlbumCard.qml @@ -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 diff --git a/crates/qbz-qt/qml/cards/ArtistCard.qml b/crates/qbz-qt/qml/cards/ArtistCard.qml index 24bb34cb4..42e7ab7e3 100644 --- a/crates/qbz-qt/qml/cards/ArtistCard.qml +++ b/crates/qbz-qt/qml/cards/ArtistCard.qml @@ -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). diff --git a/crates/qbz-qt/qml/cards/CollectionMosaic.qml b/crates/qbz-qt/qml/cards/CollectionMosaic.qml index beaf8a1dd..ba3d98ace 100644 --- a/crates/qbz-qt/qml/cards/CollectionMosaic.qml +++ b/crates/qbz-qt/qml/cards/CollectionMosaic.qml @@ -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 diff --git a/crates/qbz-qt/qml/cards/LabelCard.qml b/crates/qbz-qt/qml/cards/LabelCard.qml index f9030efc0..70bd42f9d 100644 --- a/crates/qbz-qt/qml/cards/LabelCard.qml +++ b/crates/qbz-qt/qml/cards/LabelCard.qml @@ -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. diff --git a/crates/qbz-qt/qml/cards/PlaylistCard.qml b/crates/qbz-qt/qml/cards/PlaylistCard.qml index cf49e1aa2..33733e3de 100644 --- a/crates/qbz-qt/qml/cards/PlaylistCard.qml +++ b/crates/qbz-qt/qml/cards/PlaylistCard.qml @@ -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 } diff --git a/crates/qbz-qt/qml/cards/PlaylistCollage.qml b/crates/qbz-qt/qml/cards/PlaylistCollage.qml index 408cb8d4f..361d0ed0c 100644 --- a/crates/qbz-qt/qml/cards/PlaylistCollage.qml +++ b/crates/qbz-qt/qml/cards/PlaylistCollage.qml @@ -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 diff --git a/crates/qbz-qt/qml/cards/RadioCard.qml b/crates/qbz-qt/qml/cards/RadioCard.qml index 36afd82b1..0d765e5a0 100644 --- a/crates/qbz-qt/qml/cards/RadioCard.qml +++ b/crates/qbz-qt/qml/cards/RadioCard.qml @@ -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 diff --git a/crates/qbz-qt/qml/cards/SlimCard.qml b/crates/qbz-qt/qml/cards/SlimCard.qml index 9930ee04d..53cc3b142 100644 --- a/crates/qbz-qt/qml/cards/SlimCard.qml +++ b/crates/qbz-qt/qml/cards/SlimCard.qml @@ -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 diff --git a/crates/qbz-qt/qml/cards/TrackCard.qml b/crates/qbz-qt/qml/cards/TrackCard.qml index b7b8c5a47..6c4d060fb 100644 --- a/crates/qbz-qt/qml/cards/TrackCard.qml +++ b/crates/qbz-qt/qml/cards/TrackCard.qml @@ -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 diff --git a/crates/qbz-qt/qml/controls/QbzConfirmModal.qml b/crates/qbz-qt/qml/controls/QbzConfirmModal.qml index 5b33fb77f..e929f344a 100644 --- a/crates/qbz-qt/qml/controls/QbzConfirmModal.qml +++ b/crates/qbz-qt/qml/controls/QbzConfirmModal.qml @@ -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 diff --git a/crates/qbz-qt/qml/controls/QbzSkeleton.qml b/crates/qbz-qt/qml/controls/QbzSkeleton.qml index a2ddeb1b0..1256c1c9d 100644 --- a/crates/qbz-qt/qml/controls/QbzSkeleton.qml +++ b/crates/qbz-qt/qml/controls/QbzSkeleton.qml @@ -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 diff --git a/crates/qbz-qt/qml/settings/AppearanceSettings.qml b/crates/qbz-qt/qml/settings/AppearanceSettings.qml index 11a882260..9d68ceb44 100644 --- a/crates/qbz-qt/qml/settings/AppearanceSettings.qml +++ b/crates/qbz-qt/qml/settings/AppearanceSettings.qml @@ -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). diff --git a/crates/qbz-qt/qml/theme/RoundedImage.qml b/crates/qbz-qt/qml/theme/RoundedImage.qml index 2af28a14a..a134664ec 100644 --- a/crates/qbz-qt/qml/theme/RoundedImage.qml +++ b/crates/qbz-qt/qml/theme/RoundedImage.qml @@ -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() diff --git a/crates/qbz-qt/qml/views/AlbumCollection.qml b/crates/qbz-qt/qml/views/AlbumCollection.qml index d0afc806d..cf1b58088 100644 --- a/crates/qbz-qt/qml/views/AlbumCollection.qml +++ b/crates/qbz-qt/qml/views/AlbumCollection.qml @@ -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++) diff --git a/crates/qbz-qt/qml/views/ArtistSceneView.qml b/crates/qbz-qt/qml/views/ArtistSceneView.qml index ba254e8f0..5070e1675 100644 --- a/crates/qbz-qt/qml/views/ArtistSceneView.qml +++ b/crates/qbz-qt/qml/views/ArtistSceneView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/HomeView.qml b/crates/qbz-qt/qml/views/HomeView.qml index 1bffa0641..ee807854d 100644 --- a/crates/qbz-qt/qml/views/HomeView.qml +++ b/crates/qbz-qt/qml/views/HomeView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/LibraryView.qml b/crates/qbz-qt/qml/views/LibraryView.qml index 84a99faf3..3500f6f46 100644 --- a/crates/qbz-qt/qml/views/LibraryView.qml +++ b/crates/qbz-qt/qml/views/LibraryView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/LocalLibraryView.qml b/crates/qbz-qt/qml/views/LocalLibraryView.qml index 2d96c01d7..a7a6fed69 100644 --- a/crates/qbz-qt/qml/views/LocalLibraryView.qml +++ b/crates/qbz-qt/qml/views/LocalLibraryView.qml @@ -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() diff --git a/crates/qbz-qt/qml/views/SearchView.qml b/crates/qbz-qt/qml/views/SearchView.qml index f2f8b2c84..8e315fb3b 100644 --- a/crates/qbz-qt/qml/views/SearchView.qml +++ b/crates/qbz-qt/qml/views/SearchView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/library/FeedGridCell.qml b/crates/qbz-qt/qml/views/library/FeedGridCell.qml index e3dfede92..f1eb66520 100644 --- a/crates/qbz-qt/qml/views/library/FeedGridCell.qml +++ b/crates/qbz-qt/qml/views/library/FeedGridCell.qml @@ -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. diff --git a/crates/qbz-qt/qml/views/local/FolderSubcard.qml b/crates/qbz-qt/qml/views/local/FolderSubcard.qml index d1e440afc..97717ed5a 100644 --- a/crates/qbz-qt/qml/views/local/FolderSubcard.qml +++ b/crates/qbz-qt/qml/views/local/FolderSubcard.qml @@ -46,6 +46,7 @@ Rectangle { tintName: "muted" } RoundedImage { + gridArtwork: true id: subcardArt anchors.fill: parent source: root.artSource diff --git a/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml b/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml index 9fa227af3..c6248f014 100644 --- a/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml +++ b/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzCard.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzCard.qml index 42423cf9a..c79aeb4b5 100644 --- a/crates/qbz-qt/qml/views/myqbz/MyQbzCard.qml +++ b/crates/qbz-qt/qml/views/myqbz/MyQbzCard.qml @@ -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 || [] diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmGridCard.qml b/crates/qbz-qt/qml/views/playlistmanager/PmGridCard.qml index 40365479f..947deabe0 100644 --- a/crates/qbz-qt/qml/views/playlistmanager/PmGridCard.qml +++ b/crates/qbz-qt/qml/views/playlistmanager/PmGridCard.qml @@ -111,6 +111,7 @@ Rectangle { Component { id: desktopCover PlaylistCollage { + gridArtwork: true anchors.fill: parent layout: "pm" urls: root.item.covers || [] diff --git a/crates/qbz-qt/qml/views/purchases/PurchaseGridCard.qml b/crates/qbz-qt/qml/views/purchases/PurchaseGridCard.qml index 9ab59151a..97943c8eb 100644 --- a/crates/qbz-qt/qml/views/purchases/PurchaseGridCard.qml +++ b/crates/qbz-qt/qml/views/purchases/PurchaseGridCard.qml @@ -75,6 +75,7 @@ Item { // per cell, no scissor that rounds nothing). RoundedImage { + gridArtwork: true id: art anchors.fill: parent source: root.artUrl diff --git a/crates/qbz-qt/qml/views/scene/ScenePanelMode.qml b/crates/qbz-qt/qml/views/scene/ScenePanelMode.qml index 23d8f51cf..1b72037f1 100644 --- a/crates/qbz-qt/qml/views/scene/ScenePanelMode.qml +++ b/crates/qbz-qt/qml/views/scene/ScenePanelMode.qml @@ -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] diff --git a/crates/qbz-qt/src/artwork_qt.rs b/crates/qbz-qt/src/artwork_qt.rs index 8b7c450d3..9d7ffb3ca 100644 --- a/crates/qbz-qt/src/artwork_qt.rs +++ b/crates/qbz-qt/src/artwork_qt.rs @@ -430,11 +430,28 @@ pub fn attach_cached(sections: &mut [HomeSection]) -> Vec { /// 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; + pub async fn download_missing(urls: Vec) { + 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, + ready: impl Fn(String, String) + Send + Sync + 'static, +) { + download_missing_inner(urls, Some(Arc::new(ready))).await; +} + +async fn download_missing_inner(urls: Vec, ready: Option) { // (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 = HashSet::new(); + let mut primary_by_fetch = std::collections::HashMap::::new(); + let mut aliases = std::collections::HashMap::>::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) { } 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) { 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) { /// 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) { 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!( diff --git a/crates/qbz-qt/src/label_qt.rs b/crates/qbz-qt/src/label_qt.rs index 96f27151a..cf2d0f8cf 100644 --- a/crates/qbz-qt/src/label_qt.rs +++ b/crates/qbz-qt/src/label_qt.rs @@ -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 = { + 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 // =========================================================================== diff --git a/crates/qbz-qt/src/local_bridge.rs b/crates/qbz-qt/src/local_bridge.rs index 56d13aba9..5a9f997ca 100644 --- a/crates/qbz-qt/src/local_bridge.rs +++ b/crates/qbz-qt/src/local_bridge.rs @@ -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::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 = serde_json::from_str(&keys_json.to_string()).unwrap_or_default(); if keys.is_empty() { diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index a4546c7a7..e88e0b3a0 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -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 = 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::>::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; }); } diff --git a/crates/qbz-qt/src/tray_bridge.rs b/crates/qbz-qt/src/tray_bridge.rs index 7214da0e2..2785ea32b 100644 --- a/crates/qbz-qt/src/tray_bridge.rs +++ b/crates/qbz-qt/src/tray_bridge.rs @@ -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> = 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)); + } } diff --git a/crates/qbz-qt/src/tray_qt.rs b/crates/qbz-qt/src/tray_qt.rs index d584c55d2..e3bc9d15e 100644 --- a/crates/qbz-qt/src/tray_qt.rs +++ b/crates/qbz-qt/src/tray_qt.rs @@ -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(); }); diff --git a/scripts/bench_qt_artwork.py b/scripts/bench_qt_artwork.py new file mode 100644 index 000000000..401d6cff3 --- /dev/null +++ b/scripts/bench_qt_artwork.py @@ -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() diff --git a/scripts/bench_qt_private_ui.py b/scripts/bench_qt_private_ui.py new file mode 100644 index 000000000..4378f1e78 --- /dev/null +++ b/scripts/bench_qt_private_ui.py @@ -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() 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() diff --git a/scripts/probe_qt_private_ui.py b/scripts/probe_qt_private_ui.py new file mode 100644 index 000000000..72ea7e79a --- /dev/null +++ b/scripts/probe_qt_private_ui.py @@ -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() diff --git a/scripts/qml-tests/fixtures/blue.ppm b/scripts/qml-tests/fixtures/blue.ppm new file mode 100644 index 000000000..8af45d84a --- /dev/null +++ b/scripts/qml-tests/fixtures/blue.ppm @@ -0,0 +1,5 @@ +P3 +2 2 +255 +0 0 255 0 0 255 +0 0 255 0 0 255 diff --git a/scripts/qml-tests/fixtures/red.ppm b/scripts/qml-tests/fixtures/red.ppm new file mode 100644 index 000000000..8e71d597a --- /dev/null +++ b/scripts/qml-tests/fixtures/red.ppm @@ -0,0 +1,5 @@ +P3 +2 2 +255 +255 0 0 255 0 0 +255 0 0 255 0 0 diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzLibrary.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzLibrary.qml new file mode 100644 index 000000000..66fa3570b --- /dev/null +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzLibrary.qml @@ -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) } diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzLocal.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzLocal.qml index 858477992..194020216 100644 --- a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzLocal.qml +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzLocal.qml @@ -1,6 +1,7 @@ pragma Singleton import QtQuick QtObject { + function artworkImmediateEnabled() { return true } property string mediaStatus: "{}" property int pairingCancels: 0 function mediaCancelQuickConnect() { pairingCancels++ } diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzPurchases.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzPurchases.qml new file mode 100644 index 000000000..cc094f46a --- /dev/null +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzPurchases.qml @@ -0,0 +1,3 @@ +pragma Singleton +import QtQuick +QtObject { property int entitlementRev: 0; function isAlbumPurchased(id,rev) { return false } function albumPurchasedQuality(id,rev) { return "" } } diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzSession.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzSession.qml index 604040195..80e4b45b4 100644 --- a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzSession.qml +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzSession.qml @@ -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 } } diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzShell.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzShell.qml index 2af30ca4b..7894d5d98 100644 --- a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzShell.qml +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzShell.qml @@ -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) {} diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir b/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir index 8d2deb6d0..8c97264f2 100644 --- a/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir @@ -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 diff --git a/scripts/qml-tests/tst_artwork_handover.qml b/scripts/qml-tests/tst_artwork_handover.qml new file mode 100644 index 000000000..91ffd87c7 --- /dev/null +++ b/scripts/qml-tests/tst_artwork_handover.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) + } + } +} diff --git a/scripts/qml-tests/tst_grid_cards.qml b/scripts/qml-tests/tst_grid_cards.qml new file mode 100644 index 000000000..44112893a --- /dev/null +++ b/scripts/qml-tests/tst_grid_cards.qml @@ -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 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:""} + } + } +} diff --git a/scripts/qml-tests/tst_quit_confirmation.qml b/scripts/qml-tests/tst_quit_confirmation.qml new file mode 100644 index 000000000..1a046a19c --- /dev/null +++ b/scripts/qml-tests/tst_quit_confirmation.qml @@ -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) + } + } +} diff --git a/scripts/test_qt_artwork_reveal.mjs b/scripts/test_qt_artwork_reveal.mjs new file mode 100644 index 000000000..bc16f0d59 --- /dev/null +++ b/scripts/test_qt_artwork_reveal.mjs @@ -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'); diff --git a/scripts/test_qt_grid_prefetch.mjs b/scripts/test_qt_grid_prefetch.mjs new file mode 100644 index 000000000..65f9342d1 --- /dev/null +++ b/scripts/test_qt_grid_prefetch.mjs @@ -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({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'); diff --git a/scripts/test_qt_window_lifecycle.mjs b/scripts/test_qt_window_lifecycle.mjs new file mode 100644 index 000000000..c1b792a1e --- /dev/null +++ b/scripts/test_qt_window_lifecycle.mjs @@ -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');