fix(qt): restore window lifecycle and improve grid artwork
Restore tray-aware close and quit confirmation, circular label covers, and missing label artist portraits. Publish artwork progressively, preload two grid rows, and skip redundant motion reports while preserving data refreshes. Include regression tests and isolated artwork benchmarks. Verified Linux build, QML checks, and scrolling smoke tests. cx
This commit is contained in:
61 files changed
+1591
-151
No files matched your search
@@ -7008,3 +7008,19 @@ msgstr "Quick Connect fehlgeschlagen: {}"
|
||||
|
||||
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
|
||||
msgstr "Gib diesen Code unter Einstellungen > Quick Connect in einem bereits angemeldeten Jellyfin-Client ein. QBZ wartet bis zu 5 Minuten."
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Close QBZ?"
|
||||
msgstr "QBZ schließen?"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Yes"
|
||||
msgstr "Ja"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "No"
|
||||
msgstr "Nein"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Don't show again"
|
||||
msgstr "Nicht erneut anzeigen"
|
||||
@@ -2608,3 +2608,19 @@ msgstr "Quick Connect failed: {}"
|
||||
|
||||
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
|
||||
msgstr "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Close QBZ?"
|
||||
msgstr "Close QBZ?"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Yes"
|
||||
msgstr "Yes"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "No"
|
||||
msgstr "No"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Don't show again"
|
||||
msgstr "Don't show again"
|
||||
@@ -7014,3 +7014,19 @@ msgstr "Quick Connect falló: {}"
|
||||
|
||||
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
|
||||
msgstr "Ingresa este código en Ajustes > Quick Connect desde un cliente Jellyfin con sesión iniciada. QBZ espera hasta 5 minutos."
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Close QBZ?"
|
||||
msgstr "¿Cerrar QBZ?"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Yes"
|
||||
msgstr "Sí"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "No"
|
||||
msgstr "No"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Don't show again"
|
||||
msgstr "No volver a mostrar"
|
||||
@@ -7006,3 +7006,19 @@ msgstr "Échec de Quick Connect : {}"
|
||||
|
||||
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
|
||||
msgstr "Saisissez ce code dans Paramètres > Quick Connect depuis un client Jellyfin déjà connecté. QBZ attend jusqu’à 5 minutes."
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Close QBZ?"
|
||||
msgstr "Fermer QBZ ?"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Yes"
|
||||
msgstr "Oui"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "No"
|
||||
msgstr "Non"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Don't show again"
|
||||
msgstr "Ne plus afficher"
|
||||
@@ -6989,3 +6989,19 @@ msgstr "Quick Connect に失敗しました: {}"
|
||||
|
||||
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
|
||||
msgstr "ログイン済みの Jellyfin クライアントで「設定 > Quick Connect」にこのコードを入力してください。QBZ は最大5分間待機します。"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Close QBZ?"
|
||||
msgstr "QBZを終了しますか?"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Yes"
|
||||
msgstr "はい"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "No"
|
||||
msgstr "いいえ"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Don't show again"
|
||||
msgstr "今後表示しない"
|
||||
@@ -6996,3 +6996,19 @@ msgstr "Quick Connect mislukt: {}"
|
||||
|
||||
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
|
||||
msgstr "Voer deze code in bij Instellingen > Quick Connect in een Jellyfin-client die al is aangemeld. QBZ wacht maximaal 5 minuten."
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Close QBZ?"
|
||||
msgstr "QBZ afsluiten?"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Yes"
|
||||
msgstr "Ja"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "No"
|
||||
msgstr "Nee"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Don't show again"
|
||||
msgstr "Niet opnieuw tonen"
|
||||
@@ -7006,3 +7006,19 @@ msgstr "Falha no Quick Connect: {}"
|
||||
|
||||
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
|
||||
msgstr "Insira este código em Configurações > Quick Connect em um cliente Jellyfin já conectado. O QBZ aguarda até 5 minutos."
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Close QBZ?"
|
||||
msgstr "Fechar o QBZ?"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "No"
|
||||
msgstr "Não"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Don't show again"
|
||||
msgstr "Não mostrar novamente"
|
||||
@@ -7027,3 +7027,19 @@ msgstr "Ошибка Quick Connect: {}"
|
||||
|
||||
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
|
||||
msgstr "Введите этот код в разделе «Настройки > Quick Connect» клиента Jellyfin, в котором уже выполнен вход. QBZ ждёт до 5 минут."
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Close QBZ?"
|
||||
msgstr "Закрыть QBZ?"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Yes"
|
||||
msgstr "Да"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "No"
|
||||
msgstr "Нет"
|
||||
|
||||
#: qbz-qt/qml/Main.qml
|
||||
msgid "Don't show again"
|
||||
msgstr "Больше не показывать"
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#include <QtCore/QCoreApplication>
|
||||
#include <QtCore/QEvent>
|
||||
#include <QtCore/QObject>
|
||||
#include <QtGui/QKeyEvent>
|
||||
|
||||
namespace {
|
||||
// Lifecycle commands are reserved even while a text field or mini window
|
||||
// owns focus. Qt maps ControlModifier to Command on macOS.
|
||||
class LifecycleFilter final : public QObject {
|
||||
public:
|
||||
LifecycleFilter(QObject *parent, void (*request)(bool))
|
||||
: QObject(parent), request_(request) {}
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *receiver, QEvent *event) override {
|
||||
if (receiver == QCoreApplication::instance() && event->type() == QEvent::Quit) {
|
||||
event->ignore(); // termination is pending until QML confirms
|
||||
request_(true);
|
||||
return true;
|
||||
}
|
||||
if (event->type() != QEvent::KeyPress && event->type() != QEvent::ShortcutOverride)
|
||||
return false;
|
||||
auto *key = static_cast<QKeyEvent *>(event);
|
||||
if (key->modifiers() != Qt::ControlModifier
|
||||
|| (key->key() != Qt::Key_Q && key->key() != Qt::Key_W))
|
||||
return false;
|
||||
event->accept();
|
||||
if (event->type() == QEvent::KeyPress && !key->isAutoRepeat())
|
||||
request_(key->key() == Qt::Key_Q);
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
void (*request_)(bool);
|
||||
};
|
||||
}
|
||||
|
||||
extern "C" void qbz_install_lifecycle_filter(void (*request)(bool)) {
|
||||
static LifecycleFilter *filter = nullptr;
|
||||
auto *app = QCoreApplication::instance();
|
||||
if (!filter && app) {
|
||||
filter = new LifecycleFilter(app, request);
|
||||
app->installEventFilter(filter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#include <QtCore/QCoreApplication>
|
||||
#include <QtCore/QEvent>
|
||||
#include <QtGui/QKeyEvent>
|
||||
#include <cassert>
|
||||
|
||||
extern "C" void qbz_install_lifecycle_filter(void (*request)(bool));
|
||||
static int quits = 0;
|
||||
static int closes = 0;
|
||||
static void request(bool quit) { quit ? ++quits : ++closes; }
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
QCoreApplication app(argc, argv);
|
||||
qbz_install_lifecycle_filter(request);
|
||||
qbz_install_lifecycle_filter(request); // no duplicate installation
|
||||
QObject focused;
|
||||
QKeyEvent override(QEvent::ShortcutOverride, Qt::Key_W, Qt::ControlModifier);
|
||||
QCoreApplication::sendEvent(&focused, &override);
|
||||
assert(override.isAccepted() && closes == 0);
|
||||
QKeyEvent close(QEvent::KeyPress, Qt::Key_W, Qt::ControlModifier);
|
||||
QCoreApplication::sendEvent(&focused, &close);
|
||||
assert(closes == 1 && quits == 0);
|
||||
QKeyEvent repeat(QEvent::KeyPress, Qt::Key_W, Qt::ControlModifier, {}, true);
|
||||
QCoreApplication::sendEvent(&focused, &repeat);
|
||||
assert(closes == 1);
|
||||
QKeyEvent unrelated(QEvent::KeyPress, Qt::Key_Q, Qt::ControlModifier | Qt::ShiftModifier);
|
||||
QCoreApplication::sendEvent(&focused, &unrelated);
|
||||
assert(quits == 0);
|
||||
QKeyEvent quit(QEvent::KeyPress, Qt::Key_Q, Qt::ControlModifier);
|
||||
QCoreApplication::sendEvent(&focused, &quit);
|
||||
assert(quits == 1);
|
||||
QEvent nativeQuit(QEvent::Quit);
|
||||
QCoreApplication::sendEvent(&app, &nativeQuit);
|
||||
assert(!nativeQuit.isAccepted() && quits == 2 && closes == 1);
|
||||
}
|
||||
+51
-15
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,9 @@ import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
readonly property bool artworkReady: portraitLoader.item ? portraitLoader.item.ready : false
|
||||
readonly property bool artworkImmediate: !root.kioskHost && portraitLoader.item
|
||||
? portraitLoader.item.immediateGridArtwork : false
|
||||
property bool kioskHost: false
|
||||
|
||||
property var item: ({})
|
||||
@@ -182,6 +185,7 @@ Rectangle {
|
||||
tintName: "muted"
|
||||
}
|
||||
Loader {
|
||||
id: portraitLoader
|
||||
anchors.fill: parent
|
||||
sourceComponent: root.kioskHost ? kioskCover : desktopCover
|
||||
Component {
|
||||
@@ -190,7 +194,12 @@ Rectangle {
|
||||
}
|
||||
Component {
|
||||
id: desktopCover
|
||||
RoundedImage { anchors.fill: parent; source: root.artSource; radius: 95 }
|
||||
RoundedImage {
|
||||
gridArtwork: true
|
||||
anchors.fill: parent
|
||||
source: root.artSource
|
||||
radius: 95
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hover scrim (clipped to the circle by the parent's clip).
|
||||
|
||||
@@ -71,6 +71,7 @@ import "../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property bool gridArtwork: false
|
||||
|
||||
/// 0..9 remote cover URLs, INDEX-SIGNIFICANT (Slint's url1..url9).
|
||||
property var urls: []
|
||||
@@ -175,6 +176,7 @@ Item {
|
||||
Component {
|
||||
id: customCover
|
||||
RoundedImage {
|
||||
gridArtwork: root.gridArtwork
|
||||
source: root.customCoverPath
|
||||
radius: root.radius
|
||||
fit: "crop"
|
||||
@@ -298,6 +300,7 @@ Item {
|
||||
// custom cover or the kind glyph — an idle tile is still an Item.
|
||||
model: root.collage ? root.cellCount : 0
|
||||
delegate: RoundedImage {
|
||||
gridArtwork: root.gridArtwork
|
||||
required property int index
|
||||
readonly property rect cell: root.cellOf(index)
|
||||
x: cell.x
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -57,6 +57,10 @@ import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
readonly property bool artworkReady: root.artSource !== "" && artworkLoader.item
|
||||
? artworkLoader.item.ready : false
|
||||
readonly property bool artworkImmediate: root.artSource !== "" && artworkLoader.item
|
||||
? artworkLoader.item.immediateGridArtwork : false
|
||||
|
||||
property var item: ({})
|
||||
// Host-resolved artwork path (the AlbumCard artSource pattern).
|
||||
@@ -194,12 +198,14 @@ Rectangle {
|
||||
// those surfaces pad too, and a genuinely square cover
|
||||
// (`image.covers[0]`, the fallback that map picks) still crops.
|
||||
Loader {
|
||||
id: artworkLoader
|
||||
anchors.fill: parent
|
||||
sourceComponent: root.artSource !== "" ? singleArtwork : collageArtwork
|
||||
}
|
||||
Component {
|
||||
id: singleArtwork
|
||||
RoundedImage {
|
||||
gridArtwork: true
|
||||
source: root.artSource
|
||||
radius: theme.radiusSm
|
||||
fit: root.ownImage ? "pad" : "auto"
|
||||
@@ -211,6 +217,7 @@ Rectangle {
|
||||
Component {
|
||||
id: collageArtwork
|
||||
PlaylistCollage {
|
||||
gridArtwork: true
|
||||
urls: root.ownImage ? [] : root.collageUrls
|
||||
radius: theme.radiusSm
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import "../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property bool gridArtwork: false
|
||||
|
||||
// Remote cover urls (feed row `covers`); only the first four distinct
|
||||
// entries are drawn.
|
||||
@@ -308,6 +309,7 @@ Item {
|
||||
Repeater {
|
||||
model: 4
|
||||
delegate: RoundedImage {
|
||||
gridArtwork: root.gridArtwork
|
||||
required property int index
|
||||
readonly property rect cell: root.cellOf(index)
|
||||
visible: index < root.drawnTiles
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,8 @@ import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
readonly property bool artworkReady: coverImage.ready
|
||||
readonly property bool artworkImmediate: coverImage.immediateGridArtwork
|
||||
|
||||
property var item: ({})
|
||||
// Host-resolved artwork path (the AlbumCard artSource pattern).
|
||||
@@ -115,6 +117,8 @@ Rectangle {
|
||||
// a rectangular scissor never produced this radius. A clip is an
|
||||
// unconditional batch root, so this one cost a draw call per item.
|
||||
RoundedImage {
|
||||
id: coverImage
|
||||
gridArtwork: true
|
||||
anchors.fill: parent
|
||||
source: root.artSource
|
||||
radius: theme.radiusSm
|
||||
|
||||
@@ -58,6 +58,8 @@ Item {
|
||||
/// how a call site ends up setting a property that does not exist — which in
|
||||
/// QML is not an error, just a silently-ignored line.
|
||||
property bool danger: true
|
||||
property string checkboxLabel: ""
|
||||
property bool checkboxChecked: false
|
||||
|
||||
readonly property bool opened: root._open
|
||||
property bool _open: false
|
||||
@@ -184,6 +186,28 @@ Item {
|
||||
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Row {
|
||||
visible: root.checkboxLabel !== ""
|
||||
width: parent.width
|
||||
spacing: 10
|
||||
QbzCheckbox {
|
||||
kioskHost: root.kioskHost
|
||||
checked: root.checkboxChecked
|
||||
Accessible.name: root.checkboxLabel
|
||||
onToggled: root.checkboxChecked = !root.checkboxChecked
|
||||
}
|
||||
Text {
|
||||
width: parent.width - 54
|
||||
text: root.checkboxLabel
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
wrapMode: Text.WordWrap
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.checkboxChecked = !root.checkboxChecked
|
||||
}
|
||||
}
|
||||
}
|
||||
Item {
|
||||
width: parent.width
|
||||
height: root.kioskHost ? 44 : 36
|
||||
|
||||
@@ -54,11 +54,11 @@
|
||||
// placeholder is still fully opaque on the frame the art appears.
|
||||
// - `coverSource` — the art path, for hosts whose image is sealed inside a
|
||||
// component they do not own (AlbumCard, PlaylistCard, SlimCard, …). This
|
||||
// instance then loads the SAME path through its own hidden probe; because
|
||||
// QQuickPixmapCache is shared and process-wide, that is the SAME cache
|
||||
// entry the card is loading, so it costs no second decode and no second
|
||||
// copy in memory — it only tells us WHEN the decode finished. The card's
|
||||
// canvas paints on the next frame, which the 180ms fade below covers.
|
||||
// instance loads the path through a hidden probe. Cache sharing depends
|
||||
// on the resolved URL AND decode size: a card using a scaled derivative
|
||||
// does not share this original-image decode. Prefer coverReady whenever
|
||||
// the card exposes it, to avoid retaining both images. The default fade
|
||||
// allows the card to paint after the probe becomes ready.
|
||||
// `pending` is the third input: "this cell wants a cover at all". A row with
|
||||
// no artwork slot passes false and gets no placeholder.
|
||||
//
|
||||
@@ -167,7 +167,8 @@ Item {
|
||||
/// "This cell wants a cover." false retires the placeholder at once.
|
||||
property bool pending: true
|
||||
/// Host-driven readiness — bind to `RoundedImage.ready`.
|
||||
property bool coverReady: false
|
||||
property var artworkItem: null
|
||||
property bool coverReady: artworkItem ? artworkItem.artworkReady : false
|
||||
/// Self-probing readiness — the art path, when the image is sealed inside
|
||||
/// a component the host does not own. "" disarms the probe entirely.
|
||||
property string coverSource: ""
|
||||
@@ -214,8 +215,15 @@ Item {
|
||||
// the canvas paints on the next frame (~16ms in), while this is still at
|
||||
// ~91% opacity, so the placeholder dissolves INTO the art.
|
||||
readonly property bool retired: root.settled || root.handedOver || !root.pending
|
||||
opacity: root.retired ? 0.0 : 1.0
|
||||
Behavior on opacity { NumberAnimation { duration: 180 } }
|
||||
property int handoverFadeMs: artworkItem && artworkItem.artworkImmediate ? 0 : 180
|
||||
property real retirementOpacity: root.retired ? 0.0 : 1.0
|
||||
// Immediate handover cannot inherit a Behavior already fading the old
|
||||
// placeholder. Artless settling still uses its original 180ms fade.
|
||||
opacity: root.handedOver && root.handoverFadeMs <= 0 ? 0.0 : root.retirementOpacity
|
||||
Behavior on retirementOpacity {
|
||||
enabled: root.handoverFadeMs > 0 || root.settled
|
||||
NumberAnimation { duration: root.handedOver ? root.handoverFadeMs : 180 }
|
||||
}
|
||||
// Default only — a call site that assigns `visible` replaces this and
|
||||
// keeps its own gate (see the block comment).
|
||||
visible: root.opacity > 0.004
|
||||
|
||||
@@ -548,6 +548,7 @@ Column {
|
||||
}
|
||||
SettingRow { kioskHost: root.kioskHost;
|
||||
label: QbzSession.tr("Show track in window title", QbzSession.trRev)
|
||||
visible: !QbzShell.isMacos || QbzShell.systemTitleBar
|
||||
QbzToggle { kioskHost: root.kioskHost;
|
||||
checked: root.doc.windowTitleShow === true
|
||||
onToggled: function (v) { QbzBridge.settingsBool("window-title-show", v) }
|
||||
@@ -604,30 +605,12 @@ Column {
|
||||
// Absent while there is no tray icon to close to (owner
|
||||
// 2026-08-21). The value still round-trips through the store, so
|
||||
// turning the tray back on restores the answer the user gave.
|
||||
visible: root.doc.trayEnable === true
|
||||
visible: root.doc.trayEnable === true && root.doc.isMacos !== true
|
||||
QbzToggle { kioskHost: root.kioskHost;
|
||||
checked: root.doc.trayCloseToTray === true
|
||||
onToggled: function (v) { QbzBridge.settingsBool("tray-close-to-tray", v) }
|
||||
}
|
||||
}
|
||||
// macOS only: switch the activation policy to .accessory while closed to
|
||||
// the menu bar (no Dock icon). Off = Spotify-style, keep the Dock icon.
|
||||
// `visible` is inherited Item.visible and the parent is a Column, which
|
||||
// skips invisible children — so on Linux this leaves no gap at all. The
|
||||
// enable condition is DOUBLED on the row and on the toggle because
|
||||
// SettingRow's `rowEnabled` only dims its own label column
|
||||
// (controls/SettingRow.qml:26); it does not reach the control.
|
||||
SettingRow { kioskHost: root.kioskHost;
|
||||
visible: root.doc.isMacos === true
|
||||
label: QbzSession.tr("Hide Dock icon when closed to menu bar", QbzSession.trRev)
|
||||
description: QbzSession.tr("Run as a menu-bar-only app while the window is closed. Off keeps the Dock icon (like Spotify)", QbzSession.trRev)
|
||||
rowEnabled: root.doc.trayEnable === true && root.doc.trayCloseToTray === true
|
||||
QbzToggle { kioskHost: root.kioskHost;
|
||||
enabled: root.doc.trayEnable === true && root.doc.trayCloseToTray === true
|
||||
checked: root.doc.trayMacHideDock === true
|
||||
onToggled: function (v) { QbzBridge.settingsBool("tray-mac-hide-dock", v) }
|
||||
}
|
||||
}
|
||||
SettingRow { kioskHost: root.kioskHost;
|
||||
// Absent while the tray is off — there is no icon to give a
|
||||
// variant to (owner 2026-08-21).
|
||||
|
||||
@@ -464,7 +464,14 @@ Item {
|
||||
/// the same measurement, as ContentRouter's page fade.
|
||||
///
|
||||
/// reduceMotion skips it: the art simply appears.
|
||||
property int fadeMs: 200
|
||||
// Card/grid consumers opt in. Detail artwork keeps its existing fade.
|
||||
// High density retains the measured baseline until it meets the CPU gate.
|
||||
property bool gridArtwork: false
|
||||
readonly property bool immediateGridArtwork: gridArtwork
|
||||
&& QbzLocal.artworkImmediateEnabled()
|
||||
&& root._dpr <= 1
|
||||
property int fadeMs: immediateGridArtwork ? 0 : 200
|
||||
readonly property bool revealed: root.ready && !fadeIn.running && art.opacity >= 0.999
|
||||
|
||||
// ── The two arms ────────────────────────────────────────────────────────
|
||||
// One Loader swap at startup (when GraphicsInfo.api resolves), not per
|
||||
@@ -516,7 +523,7 @@ Item {
|
||||
fadeIn.stop()
|
||||
if (!root.ready) {
|
||||
art.opacity = 0.0
|
||||
} else if (QbzShell.reduceMotion) {
|
||||
} else if (QbzShell.reduceMotion || root.fadeMs <= 0) {
|
||||
art.opacity = 1.0
|
||||
} else {
|
||||
fadeIn.restart()
|
||||
|
||||
@@ -446,7 +446,12 @@ Column {
|
||||
|
||||
/// Snapshot what is on screen NOW. Call it BEFORE the bridge call: the
|
||||
/// bridge may republish synchronously.
|
||||
readonly property bool immediateArtwork: QbzLocal.artworkImmediateEnabled()
|
||||
&& root._dpr <= 1
|
||||
onImmediateArtworkChanged: if (immediateArtwork) { tailFade.stop(); clearTailFade() }
|
||||
|
||||
function armTailFade() {
|
||||
if (root.immediateArtwork) { root.clearTailFade(); return }
|
||||
var seen = {}
|
||||
root._addIds(seen, root.albums)
|
||||
for (var g = 0; g < root.grouped.length; g++)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -268,7 +268,7 @@ Rectangle {
|
||||
// that decode finishes. Gating on `artPath !== ""` (what this used to
|
||||
// do) drops the placeholder while the card's canvas is still blank.
|
||||
pending: (card.artUrl || "") !== ""
|
||||
coverSource: resolvedSource
|
||||
coverSource: artworkItem && artworkItem.artworkImmediate ? "" : resolvedSource
|
||||
// A cover whose download fails republishes the document with an
|
||||
// empty artPath — without this the tile would shimmer forever.
|
||||
settleMs: 6000
|
||||
@@ -438,6 +438,7 @@ Rectangle {
|
||||
onLoaded: resultCell.restoreMutableBindings()
|
||||
}
|
||||
CardArtSkeleton {
|
||||
artworkItem: cardLoader.item && cardLoader.item.artworkImmediate ? cardLoader.item : null
|
||||
visible: resultGrid.kind !== "artist"
|
||||
card: resultCell.cardData
|
||||
resolvedSource: resultGrid.artOf(resultCell.cardData)
|
||||
@@ -929,6 +930,7 @@ Rectangle {
|
||||
width: 200
|
||||
height: 246
|
||||
AlbumCard {
|
||||
id: searchAlbumCard
|
||||
albumId: modelData.id
|
||||
title: modelData.title
|
||||
artist: modelData.artist
|
||||
@@ -946,6 +948,7 @@ Rectangle {
|
||||
artworkUrl: modelData.artUrl || ""
|
||||
}
|
||||
CardArtSkeleton {
|
||||
artworkItem: searchAlbumCard.artworkImmediate ? searchAlbumCard : null
|
||||
card: modelData
|
||||
phase: root.skelPhase
|
||||
cellIndex: index
|
||||
@@ -1163,6 +1166,7 @@ Rectangle {
|
||||
width: 200
|
||||
height: 246
|
||||
PlaylistCard {
|
||||
id: searchPlaylistCard
|
||||
// artworkUrl is not passed: the card
|
||||
// defaults it to `item.artUrl`, which is
|
||||
// this row's remote cover.
|
||||
@@ -1171,6 +1175,7 @@ Rectangle {
|
||||
isPinned: modelData.isPinned === true
|
||||
}
|
||||
CardArtSkeleton {
|
||||
artworkItem: searchPlaylistCard.artworkImmediate ? searchPlaylistCard : null
|
||||
card: modelData
|
||||
phase: root.skelPhase
|
||||
cellIndex: index
|
||||
|
||||
@@ -202,6 +202,8 @@ Item {
|
||||
// (ArtistGridCard/LabelCard). A bare Rectangle: it does not take pointer
|
||||
// events, so the card's hover/click areas keep working underneath.
|
||||
QbzSkeleton {
|
||||
artworkItem: cell.item.kind !== "group-header" && cardLoader.item
|
||||
&& cardLoader.item.artworkImmediate ? cardLoader.item : null
|
||||
variant: "art"
|
||||
width: 200
|
||||
height: 200
|
||||
@@ -209,7 +211,8 @@ Item {
|
||||
|| cell.item.kind === "track"
|
||||
|| cell.item.kind === "playlist")
|
||||
&& cell.item.imageUrl !== ""
|
||||
&& (cell.view.artMap[cell.item.artKey] || "") === ""
|
||||
&& (cardLoader.item && cardLoader.item.artworkImmediate
|
||||
? opacity > 0.004 : (cell.view.artMap[cell.item.artKey] || "") === "")
|
||||
// TrackCard's unavailable scrim is semantic content. A pending
|
||||
// artwork skeleton must never paint over it and turn the honest
|
||||
// state back into an ambiguous grey tile.
|
||||
|
||||
@@ -46,6 +46,7 @@ Rectangle {
|
||||
tintName: "muted"
|
||||
}
|
||||
RoundedImage {
|
||||
gridArtwork: true
|
||||
id: subcardArt
|
||||
anchors.fill: parent
|
||||
source: root.artSource
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
// via the host's windowing helpers.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Window
|
||||
import com.blitzfc.qbz
|
||||
import "../../cards"
|
||||
import "../../controls"
|
||||
@@ -264,10 +265,15 @@ Item {
|
||||
// moved the list by a pixel. Every state change that can alter the
|
||||
// mounted band now reports: mount, becoming visible, the model rebuild,
|
||||
// a viewport resize, and the scroll.
|
||||
function report() {
|
||||
// Only wheel/motion reports can be skipped. Model, layout and artwork
|
||||
// notifications always refresh the window, including late native pages.
|
||||
property string _motionBand: ""
|
||||
property double _motionReportedAt: 0
|
||||
function report(motionOnly) {
|
||||
if (!view || !list) return
|
||||
var count = nativeActive && nativeModel ? nativeModel.totalCount : entries.length
|
||||
if (!list.visible || count === 0 || width <= 0) {
|
||||
root._motionBand = ""
|
||||
view.releaseWindow(root.surface)
|
||||
return
|
||||
}
|
||||
@@ -275,9 +281,24 @@ Item {
|
||||
var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1)
|
||||
if (first < 0) first = 0
|
||||
if (last < 0) last = Math.min(count - 1, first + 8)
|
||||
var visibleFirst = first
|
||||
// These delegates already exist in cacheBuffer. Resolve their covers
|
||||
// too, so Qt can decode them before the next wheel step reveals them.
|
||||
if (root.viewMode === "grid") {
|
||||
first = Math.max(0, first - 2)
|
||||
last = Math.min(count - 1, last + 2)
|
||||
}
|
||||
var band = first + ":" + last + ":" + visibleFirst
|
||||
var now = Date.now()
|
||||
if (motionOnly === true && root._motionBand === band
|
||||
&& now - root._motionReportedAt < 1000) return
|
||||
root._motionBand = band
|
||||
root._motionReportedAt = now
|
||||
if (nativeActive && nativeModel) {
|
||||
var resident = []
|
||||
for (var entryIndex = first; entryIndex <= last; entryIndex++) {
|
||||
for (var offset = 0; offset <= last - first; offset++) {
|
||||
var entryIndex = visibleFirst + offset
|
||||
if (entryIndex > last) entryIndex = first + entryIndex - last - 1
|
||||
var entry = nativeModel.rowAt(entryIndex)
|
||||
if (!entry || entry.loading || !entry.items) continue
|
||||
for (var itemIndex = 0; itemIndex < entry.items.length; itemIndex++)
|
||||
@@ -344,7 +365,7 @@ Item {
|
||||
// lazy FIRST. In the other order this would install that bug.
|
||||
reuseItems: true
|
||||
model: root.nativeActive && root.nativeModel ? root.nativeModel : root.entries
|
||||
onContentYChanged: root.report()
|
||||
onContentYChanged: root.report(true)
|
||||
onModelChanged: root.report()
|
||||
onHeightChanged: root.report()
|
||||
onVisibleChanged: if (visible) root.reportSoon()
|
||||
@@ -432,6 +453,16 @@ Item {
|
||||
/// slot is filled, `root.emptySlot` when it is not.
|
||||
readonly property var slot:
|
||||
cardCell.modelData || root.emptySlot
|
||||
property string artTracePendingKey: ""
|
||||
Connections {
|
||||
target: root.view && root.view.artTimingEnabled
|
||||
&& cardCell.artTracePendingKey !== "" ? root.Window.window : null
|
||||
function onFrameSwapped() {
|
||||
if (cardCell.slot.artKey === cardCell.artTracePendingKey)
|
||||
root.view.traceArt("frame", cardCell.artTracePendingKey)
|
||||
cardCell.artTracePendingKey = ""
|
||||
}
|
||||
}
|
||||
visible: cardCell.modelData !== null
|
||||
width: 200
|
||||
height: 246
|
||||
@@ -494,6 +525,18 @@ Item {
|
||||
qualityDetail: cardCell.slot.qualityDetail || ""
|
||||
artSource: cardCell.slot.artPath || (root.view
|
||||
? (root.view.artMap[cardCell.slot.artKey] || "") : "")
|
||||
artFadeMs: root.view && root.view.immediateArtwork ? 0 : 200
|
||||
onArtworkReadyChanged: {
|
||||
if (root.view && root.view.artTimingEnabled && artworkReady) {
|
||||
root.view.traceArt("ready", cardCell.slot.artKey)
|
||||
}
|
||||
}
|
||||
onArtworkRevealedChanged: {
|
||||
if (root.view && root.view.artTimingEnabled && artworkRevealed) {
|
||||
root.view.traceArt("revealed", cardCell.slot.artKey)
|
||||
cardCell.artTracePendingKey = cardCell.slot.artKey
|
||||
}
|
||||
}
|
||||
pinArtworkUrl: artSource
|
||||
isPinned: QbzLibrary.pinState("album", albumId)
|
||||
// LocalLibraryView.slint:1267 `show-source-badge`
|
||||
@@ -565,15 +608,10 @@ Item {
|
||||
root.enqueueRequested(cardCell.slot.id, m)
|
||||
}
|
||||
}
|
||||
// Per-item cover placeholder, handed over to the
|
||||
// art itself: AlbumCard seals its RoundedImage
|
||||
// away, so this uses QbzSkeleton's probe arm —
|
||||
// `coverSource` loads the SAME pixmap-cache entry
|
||||
// the card is loading and retires the placeholder
|
||||
// when that decode completes, not when the path
|
||||
// appears. A bare Rectangle, so it does not take
|
||||
// pointer events and the card's own areas keep
|
||||
// working underneath.
|
||||
// Observe the card's actual derivative, so the placeholder
|
||||
// neither decodes the original again nor retires before
|
||||
// the displayed image is ready. The source probe remains
|
||||
// only for the diagnostic baseline comparison.
|
||||
// settleMs is mandatory here: local artwork
|
||||
// resolution drops keys with no cover, so an
|
||||
// artless album would otherwise shimmer forever
|
||||
@@ -584,7 +622,10 @@ Item {
|
||||
height: 200
|
||||
pending: root.view
|
||||
? root.view.artWanted(cardCell.slot.artKey) : false
|
||||
coverSource: root.view
|
||||
coverReady: root.view && root.view.immediateArtwork
|
||||
? albumCard.artworkReady : false
|
||||
handoverFadeMs: root.view && root.view.immediateArtwork ? 0 : 180
|
||||
coverSource: root.view && !root.view.immediateArtwork
|
||||
? root.view.artPathOf(cardCell.slot.artKey) : ""
|
||||
phase: root.view ? root.view.skelPhase : false
|
||||
settleMs: root.view ? root.view.artSettleMs : 0
|
||||
|
||||
@@ -73,6 +73,7 @@ Rectangle {
|
||||
spacing: 8
|
||||
|
||||
CollectionMosaic {
|
||||
gridArtwork: true
|
||||
size: 184
|
||||
urls: root.item.cellUrls || []
|
||||
paths: root.item.cellPaths || []
|
||||
@@ -115,6 +116,7 @@ Rectangle {
|
||||
spacing: 12
|
||||
|
||||
CollectionMosaic {
|
||||
gridArtwork: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 48
|
||||
urls: root.item.cellUrls || []
|
||||
|
||||
@@ -111,6 +111,7 @@ Rectangle {
|
||||
Component {
|
||||
id: desktopCover
|
||||
PlaylistCollage {
|
||||
gridArtwork: true
|
||||
anchors.fill: parent
|
||||
layout: "pm"
|
||||
urls: root.item.covers || []
|
||||
|
||||
@@ -75,6 +75,7 @@ Item {
|
||||
// per cell, no scissor that rounds nothing).
|
||||
|
||||
RoundedImage {
|
||||
gridArtwork: true
|
||||
id: art
|
||||
anchors.fill: parent
|
||||
source: root.artUrl
|
||||
|
||||
@@ -583,7 +583,7 @@ Item {
|
||||
width: pane.gridW
|
||||
height: parent.height
|
||||
clip: true
|
||||
cacheBuffer: root.cardH
|
||||
cacheBuffer: 2 * root.cardH
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.albumEntries
|
||||
|
||||
@@ -741,11 +741,9 @@ Item {
|
||||
first = 0
|
||||
if (last < 0)
|
||||
last = Math.min(root.albumEntries.length - 1, first + 3)
|
||||
// One screen of buffer either side, which is what keeps a fast scroll
|
||||
// from showing placeholders — and the cap above is what keeps a fast
|
||||
// scroll from queueing the whole discography.
|
||||
first = Math.max(0, first - 1)
|
||||
last = Math.min(root.albumEntries.length - 1, last + 1)
|
||||
// Match the two buffered grid rows, not a whole extra screen.
|
||||
first = Math.max(0, first - 2)
|
||||
last = Math.min(root.albumEntries.length - 1, last + 2)
|
||||
var urls = []
|
||||
for (var i = first; i <= last; i++) {
|
||||
var e = root.albumEntries[i]
|
||||
|
||||
@@ -430,11 +430,28 @@ pub fn attach_cached(sections: &mut [HomeSection]) -> Vec<String> {
|
||||
/// paths are tokenized first — neither is ever handed to reqwest. Returns
|
||||
/// when all downloads settled (failures are logged and skipped — the cards
|
||||
/// keep their placeholder).
|
||||
type ArtworkReady = Arc<dyn Fn(String, String) + Send + Sync>;
|
||||
|
||||
pub async fn download_missing(urls: Vec<String>) {
|
||||
download_missing_inner(urls, None).await;
|
||||
}
|
||||
|
||||
/// Publish each resolved cover without waiting for the slowest sibling.
|
||||
/// Uses the same jobs and concurrency limit as the batch-only caller.
|
||||
pub async fn download_missing_progressive(
|
||||
urls: Vec<String>,
|
||||
ready: impl Fn(String, String) + Send + Sync + 'static,
|
||||
) {
|
||||
download_missing_inner(urls, Some(Arc::new(ready))).await;
|
||||
}
|
||||
|
||||
async fn download_missing_inner(urls: Vec<String>, ready: Option<ArtworkReady>) {
|
||||
// (memo key = the caller's raw url, http url to GET), deduped by the
|
||||
// fetch url so two rows sharing a cover download it once.
|
||||
let mut jobs: Vec<(String, String)> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut primary_by_fetch = std::collections::HashMap::<String, String>::new();
|
||||
let mut aliases = std::collections::HashMap::<String, Vec<String>>::new();
|
||||
for url in urls {
|
||||
// Overridden art never needs a fetch — cached_path answers it.
|
||||
if !crate::cover_artwork_qt::override_for_url(&url).is_empty() {
|
||||
@@ -442,6 +459,15 @@ pub async fn download_missing(urls: Vec<String>) {
|
||||
}
|
||||
match classify(&url) {
|
||||
ArtUrl::Http(fetch) | ArtUrl::Plex(fetch) => {
|
||||
if ready.is_some() {
|
||||
let primary = primary_by_fetch
|
||||
.entry(fetch.clone())
|
||||
.or_insert_with(|| url.clone());
|
||||
let group = aliases.entry(primary.clone()).or_default();
|
||||
if !group.contains(&url) {
|
||||
group.push(url.clone());
|
||||
}
|
||||
}
|
||||
if seen.insert(fetch.clone()) {
|
||||
jobs.push((url, fetch));
|
||||
}
|
||||
@@ -454,7 +480,16 @@ pub async fn download_missing(urls: Vec<String>) {
|
||||
ArtUrl::Empty => {}
|
||||
}
|
||||
}
|
||||
fetch_jobs(jobs).await;
|
||||
let ready = ready.map(|callback| {
|
||||
Arc::new(move |key: String, path: String| {
|
||||
if let Some(keys) = aliases.get(&key) {
|
||||
for alias in keys {
|
||||
callback(alias.clone(), path.clone());
|
||||
}
|
||||
}
|
||||
}) as ArtworkReady
|
||||
});
|
||||
fetch_jobs_with_ready(jobs, ready).await;
|
||||
}
|
||||
|
||||
/// The shared download loop: `(cache_key, fetch url)` jobs, already deduped.
|
||||
@@ -464,6 +499,10 @@ pub async fn download_missing(urls: Vec<String>) {
|
||||
/// bounded pool, the error-page guard and the cache store rather than growing
|
||||
/// a second copy of them.
|
||||
async fn fetch_jobs(jobs: Vec<(String, String)>) {
|
||||
fetch_jobs_with_ready(jobs, None).await;
|
||||
}
|
||||
|
||||
async fn fetch_jobs_with_ready(jobs: Vec<(String, String)>, ready: Option<ArtworkReady>) {
|
||||
if jobs.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -471,10 +510,14 @@ async fn fetch_jobs(jobs: Vec<(String, String)>) {
|
||||
let mut handles = Vec::with_capacity(jobs.len());
|
||||
for (key, url) in jobs {
|
||||
let sem = Arc::clone(&semaphore);
|
||||
let ready = ready.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = sem.acquire().await.expect("semaphore open");
|
||||
// A concurrent pass may have stored it while we queued.
|
||||
if disk_path(&key, &url).is_some() {
|
||||
if let Some(path) = disk_path(&key, &url) {
|
||||
if let Some(ready) = &ready {
|
||||
ready(key, file_url(&path.to_string_lossy()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let response = match HTTP.get(&url).send().await {
|
||||
@@ -520,7 +563,10 @@ async fn fetch_jobs(jobs: Vec<(String, String)>) {
|
||||
if let Some(path) = stored {
|
||||
// Seed the memo so the republish that follows resolves from
|
||||
// RAM instead of re-querying SQLite for every card.
|
||||
memo_put(&key, path);
|
||||
memo_put(&key, path.clone());
|
||||
if let Some(ready) = &ready {
|
||||
ready(key, file_url(&path.to_string_lossy()));
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -862,6 +908,70 @@ async fn download_one(key: String, fetch: String) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Run with a temporary XDG_CACHE_HOME and QBZ_ARTWORK_TEST_ISOLATED=1.
|
||||
/// The image cache is process-global, so this deliberately runs alone.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires an isolated cache profile; run with --ignored --test-threads=1"]
|
||||
async fn progressive_cover_is_published_before_slow_sibling() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
assert_eq!(
|
||||
std::env::var("QBZ_ARTWORK_TEST_ISOLATED").as_deref(),
|
||||
Ok("1")
|
||||
);
|
||||
qbz_app::ensure_crypto_provider();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let release_slow = Arc::new(tokio::sync::Notify::new());
|
||||
let gate = release_slow.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
let mut peers = tokio::task::JoinSet::new();
|
||||
for _ in 0..2 {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let gate = gate.clone();
|
||||
peers.spawn(async move {
|
||||
let mut request = [0; 2048];
|
||||
let count = socket.read(&mut request).await.unwrap();
|
||||
if String::from_utf8_lossy(&request[..count]).contains("/slow ") {
|
||||
gate.notified().await;
|
||||
}
|
||||
let image = b"P6\n1 1\n255\n\xff\x00\x00";
|
||||
let headers = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
image.len()
|
||||
);
|
||||
socket.write_all(headers.as_bytes()).await.unwrap();
|
||||
socket.write_all(image).await.unwrap();
|
||||
});
|
||||
}
|
||||
while let Some(result) = peers.join_next().await {
|
||||
result.unwrap();
|
||||
}
|
||||
});
|
||||
let fast = format!("{base}/fast");
|
||||
let slow = format!("{base}/slow");
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let urls = vec![slow.clone(), fast.clone(), fast.clone()];
|
||||
let download = tokio::spawn(download_missing_progressive(urls, move |key, path| {
|
||||
tx.send((key, path)).unwrap();
|
||||
}));
|
||||
let first = tokio::time::timeout(std::time::Duration::from_secs(3), rx.recv())
|
||||
.await
|
||||
.expect("fast cover must not wait for slow cover")
|
||||
.unwrap();
|
||||
assert_eq!(first.0, fast);
|
||||
assert!(!first.1.is_empty());
|
||||
assert!(!download.is_finished());
|
||||
release_slow.notify_one();
|
||||
let second = tokio::time::timeout(std::time::Duration::from_secs(3), rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(second.0, slow);
|
||||
download.await.unwrap();
|
||||
server.await.unwrap();
|
||||
assert!(rx.recv().await.is_none(), "duplicate URLs must emit once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_urls_are_fetchable() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -406,6 +406,61 @@ fn fetch_label(generation: u64, id: u64) {
|
||||
crate::artwork_qt::download_missing(missing).await;
|
||||
refresh_label_art(generation);
|
||||
}
|
||||
|
||||
// Label summaries can omit artist portraits entirely. Enrich only
|
||||
// those entries after publishing the page, one request at a time.
|
||||
let missing_artists: std::collections::BTreeSet<u64> = {
|
||||
let Ok(s) = LABEL.lock() else { return };
|
||||
if s.generation != generation {
|
||||
return;
|
||||
}
|
||||
s.doc
|
||||
.artists
|
||||
.iter()
|
||||
.filter(|card| card.art_url.is_empty())
|
||||
.filter_map(|card| card.id.parse().ok())
|
||||
.collect()
|
||||
};
|
||||
for artist_id in missing_artists {
|
||||
if !LABEL
|
||||
.lock()
|
||||
.map(|s| s.generation == generation)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let artist = match runtime.core().get_artist(artist_id).await {
|
||||
Ok(artist) => artist,
|
||||
Err(error) => {
|
||||
log::debug!(
|
||||
"[qbz-qt] label artist portrait lookup failed ({artist_id}): {error}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(url) = artist
|
||||
.image
|
||||
.as_ref()
|
||||
.and_then(|image| image.for_px(400))
|
||||
.filter(|url| !url.is_empty())
|
||||
.cloned()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
{
|
||||
let Ok(mut s) = LABEL.lock() else { return };
|
||||
if s.generation != generation {
|
||||
return;
|
||||
}
|
||||
for card in &mut s.doc.artists {
|
||||
if card.id == artist_id.to_string() && card.art_url.is_empty() {
|
||||
card.art_url = url.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::artwork_qt::download_missing(vec![url]).await;
|
||||
refresh_label_art(generation);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -952,7 +1007,6 @@ fn sort_cards(items: &mut [HomeCard], sort: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// Live language switch
|
||||
// ===========================================================================
|
||||
|
||||
@@ -674,6 +674,12 @@ pub mod qbz_local {
|
||||
/// emits `localArtworkReady` per hit.
|
||||
#[qinvokable]
|
||||
fn artwork_window(self: Pin<&mut QbzLocal>, keys_json: QString);
|
||||
#[qinvokable]
|
||||
fn artwork_timing_enabled(self: &QbzLocal) -> bool;
|
||||
#[qinvokable]
|
||||
fn artwork_immediate_enabled(self: &QbzLocal) -> bool;
|
||||
#[qinvokable]
|
||||
fn artwork_timing(self: &QbzLocal, stage: QString, key: QString, window: QString);
|
||||
/// Derive the same flat tint + blurred atmosphere used by Qobuz
|
||||
/// AlbumView from an already-resolved local cover. Decode work stays
|
||||
/// off the Qt thread; key/path return with the result as generation
|
||||
@@ -1634,6 +1640,31 @@ impl qbz_local::QbzLocal {
|
||||
|
||||
// --- Artwork -----------------------------------------------------------
|
||||
|
||||
pub fn artwork_timing_enabled(&self) -> bool {
|
||||
log::log_enabled!(target: "qbz.nav.timing", log::Level::Debug)
|
||||
}
|
||||
|
||||
pub fn artwork_immediate_enabled(&self) -> bool {
|
||||
// Diagnostic A/B switch: the same binary can exercise the previous
|
||||
// reveal path without changing its compiler, backend or dependencies.
|
||||
std::env::var("QBZ_GRID_IMMEDIATE_ART").as_deref() != Ok("0")
|
||||
}
|
||||
|
||||
pub fn artwork_timing(&self, stage: QString, key: QString, window: QString) {
|
||||
if !self.artwork_timing_enabled() {
|
||||
return;
|
||||
}
|
||||
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||
let elapsed = START
|
||||
.get_or_init(std::time::Instant::now)
|
||||
.elapsed()
|
||||
.as_secs_f64()
|
||||
* 1000.0;
|
||||
log::debug!(target: "qbz.nav.timing",
|
||||
"art ms={elapsed:.3} stage={} window={} key={}",
|
||||
stage.to_string(), window.to_string(), key.to_string());
|
||||
}
|
||||
|
||||
pub fn artwork_window(self: Pin<&mut Self>, keys_json: QString) {
|
||||
let keys: Vec<String> = serde_json::from_str(&keys_json.to_string()).unwrap_or_default();
|
||||
if keys.is_empty() {
|
||||
|
||||
+12
-14
@@ -1378,14 +1378,7 @@ pub(crate) fn sidebar_artwork_window(urls_json: String) {
|
||||
return;
|
||||
}
|
||||
spawn(async move {
|
||||
let urls = missing;
|
||||
artwork_qt::download_missing(urls.clone()).await;
|
||||
for url in urls {
|
||||
let path = artwork_qt::cached_path(&url);
|
||||
if !path.is_empty() {
|
||||
emit_library_artwork(url, path);
|
||||
}
|
||||
}
|
||||
artwork_qt::download_missing_progressive(missing, emit_library_artwork).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3163,14 +3156,19 @@ pub(crate) fn library_artwork_window_at_px(keys_json: String, pixels: Option<i32
|
||||
return;
|
||||
}
|
||||
spawn(async move {
|
||||
let urls: Vec<String> = missing.iter().map(|(_, u)| u.clone()).collect();
|
||||
artwork_qt::download_missing(urls).await;
|
||||
let urls = missing.iter().map(|(_, url)| url.clone()).collect();
|
||||
let mut keys_by_url = std::collections::HashMap::<String, Vec<String>>::new();
|
||||
for (key, url) in missing {
|
||||
let path = artwork_qt::cached_path(&url);
|
||||
if !path.is_empty() {
|
||||
emit_library_artwork(key, path);
|
||||
}
|
||||
keys_by_url.entry(url).or_default().push(key);
|
||||
}
|
||||
artwork_qt::download_missing_progressive(urls, move |url, path| {
|
||||
if let Some(keys) = keys_by_url.get(&url) {
|
||||
for key in keys {
|
||||
emit_library_artwork(key.clone(), path.clone());
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,13 @@ pub mod qbz_tray {
|
||||
#[qinvokable]
|
||||
fn arm_quit_watchdog(self: Pin<&mut QbzTray>);
|
||||
|
||||
#[qinvokable]
|
||||
fn confirm_quit_enabled(self: &QbzTray) -> bool;
|
||||
#[qinvokable]
|
||||
fn disable_quit_confirmation(self: Pin<&mut QbzTray>);
|
||||
#[qsignal]
|
||||
fn close_requested(self: Pin<&mut QbzTray>);
|
||||
|
||||
/// Show + raise + focus the main window -> `Main.qml`'s
|
||||
/// `showFromTray()`.
|
||||
#[qsignal]
|
||||
@@ -166,6 +173,20 @@ impl Default for QbzTrayRust {
|
||||
|
||||
static QT_THREAD: OnceLock<CxxQtThread<QbzTray>> = OnceLock::new();
|
||||
|
||||
unsafe extern "C" {
|
||||
fn qbz_install_lifecycle_filter(request: extern "C" fn(bool));
|
||||
}
|
||||
|
||||
extern "C" fn request_lifecycle(quit: bool) {
|
||||
ui(move |mut tray| {
|
||||
if quit {
|
||||
tray.as_mut().quit_requested();
|
||||
} else {
|
||||
tray.as_mut().close_requested();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Queue a tray-bridge mutation onto the Qt event loop (no-op before boot
|
||||
/// registers the thread). **Every tray verb reaches QML through here** — the
|
||||
/// ksni item runs on its own thread and must never touch a QObject directly.
|
||||
@@ -180,6 +201,9 @@ impl qbz_tray::QbzTray {
|
||||
if QT_THREAD.set(self.qt_thread()).is_err() {
|
||||
log::warn!("[qbz-qt] tray Qt thread already registered");
|
||||
}
|
||||
// SAFETY: boot runs on the GUI thread after QGuiApplication exists;
|
||||
// callback has static lifetime and queues all QML work.
|
||||
unsafe { qbz_install_lifecycle_filter(request_lifecycle) };
|
||||
// If `tray_qt::init` ran before this point its queued closure was
|
||||
// dropped on the floor -- `ui()` above is a no-op until the line
|
||||
// right here. Pick the work up now; we are already on the GUI thread.
|
||||
@@ -198,19 +222,13 @@ impl qbz_tray::QbzTray {
|
||||
/// (`hideToTray` / `showFromTray`), so the macOS Dock-icon policy hangs
|
||||
/// here rather than growing a third visibility owner.
|
||||
///
|
||||
/// Hiding drops the Dock icon only when the user asked for it; showing
|
||||
/// ALWAYS restores it, with no setting check — a user who turns the option
|
||||
/// off while the app sits in the menu bar must still get their icon back,
|
||||
/// and restoring an icon that is already there is free.
|
||||
/// A live tray keeps hidden windows reachable, so hiding removes the Dock
|
||||
/// icon. Showing always restores it. Minimizing does not call this method.
|
||||
pub fn set_window_shown(self: Pin<&mut Self>, shown: bool) {
|
||||
crate::tray_qt::set_window_shown(shown);
|
||||
if shown {
|
||||
crate::tray_qt::set_mac_dock_hidden(false);
|
||||
} else if crate::settings_qt::tray()
|
||||
.get_settings()
|
||||
.map(|t| t.mac_hide_dock)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
} else if *self.as_ref().tray_live() {
|
||||
crate::tray_qt::set_mac_dock_hidden(true);
|
||||
}
|
||||
}
|
||||
@@ -231,4 +249,12 @@ impl qbz_tray::QbzTray {
|
||||
pub fn arm_quit_watchdog(self: Pin<&mut Self>) {
|
||||
crate::arm_hard_exit_watchdog("QML quit arm");
|
||||
}
|
||||
|
||||
pub fn confirm_quit_enabled(&self) -> bool {
|
||||
crate::settings_qt::pref_bool("confirm_quit", true)
|
||||
}
|
||||
|
||||
pub fn disable_quit_confirmation(self: Pin<&mut Self>) {
|
||||
crate::settings_qt::save_pref("confirm_quit", serde_json::json!(false));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Whole-QBZ A/B probe on a private X server, using a read-only metadata copy.
|
||||
Requires QBZ_PROBE_LIBRARY. Run only with no concurrent build or other probe.
|
||||
"""
|
||||
import argparse, json, os, subprocess, sys, time
|
||||
from pathlib import Path
|
||||
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('binary',type=Path)
|
||||
parser.add_argument('--baseline-binary',type=Path,help='Compare two builds with immediate artwork enabled in both')
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--repeats',type=int,default=2)
|
||||
args=parser.parse_args()
|
||||
results=[]
|
||||
for repeat in range(args.repeats):
|
||||
for candidate in ((0,1) if repeat%2==0 else (1,0)):
|
||||
env=dict(os.environ,QBZ_GRID_IMMEDIATE_ART=str(1 if args.baseline_binary else candidate))
|
||||
binary = args.baseline_binary if args.baseline_binary and candidate == 0 else args.binary
|
||||
process=subprocess.Popen([sys.executable,str(Path(__file__).with_name('probe_qt_private_ui.py')),str(binary.resolve())],
|
||||
env=env,stdin=subprocess.PIPE,stdout=subprocess.PIPE,text=True)
|
||||
root=None
|
||||
def command(value):
|
||||
process.stdin.write(value+'\n');process.stdin.flush()
|
||||
while True:
|
||||
line=process.stdout.readline()
|
||||
if not line:raise RuntimeError('probe stopped')
|
||||
if line.strip()=='ready':return
|
||||
print(line.strip(),flush=True)
|
||||
def wait_log(fragment):
|
||||
deadline=time.monotonic()+45
|
||||
while time.monotonic()<deadline:
|
||||
if process.poll() is not None:raise RuntimeError('probe exited')
|
||||
log=(root/'app.log').read_text(errors='replace')
|
||||
if fragment in log or (fragment=='QbzCore initialized' and 'Starting in offline-tolerant mode' in log):return
|
||||
time.sleep(.2)
|
||||
raise RuntimeError('missing startup marker: '+fragment)
|
||||
try:
|
||||
info=json.loads(process.stdout.readline());root=Path(info['root'])
|
||||
print(json.dumps({'repeat':repeat,'candidate':candidate,'profile':str(root)}),flush=True)
|
||||
wait_log('QbzCore initialized')
|
||||
time.sleep(2)
|
||||
command('click 588 520') # Start offline in the fresh profile.
|
||||
wait_log('phase=catch-up-active')
|
||||
time.sleep(2)
|
||||
command('click 464 110') # Local Library > Albums.
|
||||
wait_log('phase=albums-native')
|
||||
time.sleep(5)
|
||||
command('shot') # Baseline evidence only, outside measured interval.
|
||||
command('bench')
|
||||
log=(root/'app.log').read_text(errors='replace')
|
||||
for error in ('ReferenceError', 'TypeError', 'Cannot read', 'is not a type', 'Unable to assign'):
|
||||
if error in log:raise RuntimeError('invalid UI run: '+error+'; '+str(root/'app.log'))
|
||||
result=json.loads((root/'benchmark.json').read_text())
|
||||
result.update(repeat=repeat,profile=str(root),scenario=candidate,binary=str(binary.resolve()))
|
||||
results.append(result)
|
||||
args.output.write_text(json.dumps({'scope':'Whole QBZ, Xvfb/llvmpipe, copied metadata, no playback', 'runs':results},indent=2)+'\n')
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.stdin.write('quit\n');process.stdin.flush()
|
||||
try:process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:process.kill();process.wait()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
P3
|
||||
2 2
|
||||
255
|
||||
0 0 255 0 0 255
|
||||
0 0 255 0 0 255
|
||||
@@ -0,0 +1,5 @@
|
||||
P3
|
||||
2 2
|
||||
255
|
||||
255 0 0 255 0 0
|
||||
255 0 0 255 0 0
|
||||
@@ -0,0 +1,3 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
QtObject { signal libraryArtworkReady(string key, string path); signal pinChanged(string key, bool value); signal libraryFavoriteChanged(string key, bool value) }
|
||||
@@ -1,6 +1,7 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
QtObject {
|
||||
function artworkImmediateEnabled() { return true }
|
||||
property string mediaStatus: "{}"
|
||||
property int pairingCancels: 0
|
||||
function mediaCancelQuickConnect() { pairingCancels++ }
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
QtObject { property int entitlementRev: 0; function isAlbumPurchased(id,rev) { return false } function albumPurchasedQuality(id,rev) { return "" } }
|
||||
@@ -1,3 +1,7 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
QtObject { property int trRev: 0; function tr(text, rev) { return text } }
|
||||
QtObject {
|
||||
signal artScaledReady(string path, string scaled, int w, int h)
|
||||
function artScaledCached(path, w, h) { return "" }
|
||||
function artScaled(path, w, h) {}
|
||||
property int trRev: 0; function tr(text, rev) { return text } }
|
||||
@@ -1,6 +1,9 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
QtObject {
|
||||
signal trackCacheStatusChanged(string trackId, int status, real progress)
|
||||
property bool reduceMotion: false
|
||||
property bool forceCanvasArt: false
|
||||
property string restoreScope: ""
|
||||
property real scrollRestore: 0
|
||||
function reportScroll(scope, y) {}
|
||||
|
||||
@@ -4,3 +4,5 @@ singleton QbzPlayer 1.0 QbzPlayer.qml
|
||||
singleton QbzLocal 1.0 QbzLocal.qml
|
||||
singleton QbzSession 1.0 QbzSession.qml
|
||||
singleton QbzBridge 1.0 QbzBridge.qml
|
||||
singleton QbzLibrary 1.0 QbzLibrary.qml
|
||||
singleton QbzPurchases 1.0 QbzPurchases.qml
|
||||
@@ -0,0 +1,53 @@
|
||||
import QtQuick
|
||||
import QtQuick.Window
|
||||
import QtTest
|
||||
import com.blitzfc.qbz
|
||||
import "../../crates/qbz-qt/qml/theme" as Theme
|
||||
import "../../crates/qbz-qt/qml/controls" as Controls
|
||||
Item {
|
||||
width: 240; height: 240
|
||||
Theme.RoundedImage { id: art; width: 200; height: 200; fadeMs: 0; radius: 8 }
|
||||
Controls.QbzSkeleton {
|
||||
id: skeleton; width: 200; height: 200; animated: false
|
||||
coverReady: art.ready; handoverFadeMs: 0
|
||||
}
|
||||
Theme.RoundedImage { id: gridArt; width: 200; height: 200; gridArtwork: true; visible: false }
|
||||
TestCase {
|
||||
name: "ArtworkHandover"; when: windowShown
|
||||
function init() { art.source = ""; art.fadeMs = 0; QbzShell.forceCanvasArt = false }
|
||||
function test_ready_image_replaces_placeholder_without_fade() {
|
||||
art.source = Qt.resolvedUrl("fixtures/red.ppm")
|
||||
tryCompare(art, "ready", true)
|
||||
tryCompare(art, "revealed", true)
|
||||
compare(skeleton.handedOver, true)
|
||||
tryCompare(skeleton, "opacity", 0, 100)
|
||||
tryCompare(skeleton, "opacity", 0, 100)
|
||||
waitForRendering(art, 100)
|
||||
var shot = grabImage(art)
|
||||
verify(shot.red(100,100) >= 254 && shot.green(100,100) <= 2 && shot.blue(100,100) <= 2)
|
||||
}
|
||||
function test_recycled_source_draws_new_pixels() {
|
||||
art.source = Qt.resolvedUrl("fixtures/red.ppm")
|
||||
tryCompare(art, "revealed", true)
|
||||
art.source = Qt.resolvedUrl("fixtures/blue.ppm")
|
||||
tryCompare(art, "revealed", true)
|
||||
tryCompare(skeleton, "opacity", 0, 100)
|
||||
waitForRendering(art, 100)
|
||||
var shot = grabImage(art)
|
||||
verify(shot.blue(100,100) >= 254 && shot.red(100,100) <= 2 && shot.green(100,100) <= 2)
|
||||
}
|
||||
function test_shared_grid_policy() {
|
||||
compare(gridArt.immediateGridArtwork, gridArt._dpr <= 1)
|
||||
compare(gridArt.fadeMs, gridArt.immediateGridArtwork ? 0 : 200)
|
||||
gridArt.gridArtwork = false
|
||||
compare(gridArt.fadeMs, 200)
|
||||
gridArt.gridArtwork = true
|
||||
}
|
||||
function test_existing_fade_remains_opt_in_default() {
|
||||
art.fadeMs = 200
|
||||
art.source = Qt.resolvedUrl("fixtures/blue.ppm")
|
||||
tryCompare(art, "ready", true)
|
||||
tryCompare(art, "revealed", true, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import QtQuick
|
||||
import QtQuick.Window
|
||||
import QtTest
|
||||
import com.blitzfc.qbz
|
||||
import "../../crates/qbz-qt/qml/views/library" as Library
|
||||
Item {
|
||||
width: 240; height: 270
|
||||
QtObject {
|
||||
id: host
|
||||
property var artMap: ({})
|
||||
property bool showSourceBadges: false
|
||||
property string activeTab: "all"
|
||||
property bool skelPhase: false
|
||||
function askRemoveReleaseFavorites(item) {}
|
||||
}
|
||||
Library.FeedGridCell { id: cell; view: host; item: ({kind: "group-header", id: "", title: ""}) }
|
||||
GridView {
|
||||
id: bufferedGrid
|
||||
width: 880; height: 266; cellWidth: 220; cellHeight: 266
|
||||
cacheBuffer: 2 * cellHeight
|
||||
model: 0
|
||||
delegate: Library.FeedGridCell {
|
||||
required property int index
|
||||
view: host
|
||||
item: ({kind:"album", id:String(index), artKey:"buffer"+index,
|
||||
title:"Buffered", imageUrl:"fixture", artist:"Artist"})
|
||||
}
|
||||
}
|
||||
TestCase {
|
||||
name: "GridCards"; when: windowShown
|
||||
function test_two_rows_decode_before_scrolling() {
|
||||
bufferedGrid.model = 40
|
||||
var paths = {}
|
||||
for (var i=0;i<12;i++) paths["buffer"+i]=Qt.resolvedUrl("fixtures/red.ppm").toString()
|
||||
host.artMap = paths
|
||||
function ready(index) {
|
||||
var delegate=bufferedGrid.itemAtIndex(index)
|
||||
if (!delegate) return false
|
||||
for (var i=0;i<delegate.children.length;i++) {
|
||||
var child=delegate.children[i]
|
||||
if (child.item && child.item.artworkReady === true) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
for (var index=4;index<12;index++) {
|
||||
tryVerify(function() {return ready(index)}, 3000,
|
||||
"buffered row must be decoded before entering the viewport")
|
||||
}
|
||||
compare(bufferedGrid.contentY, 0)
|
||||
bufferedGrid.contentY = 532
|
||||
for (index=8;index<12;index++) verify(ready(index))
|
||||
bufferedGrid.model=0
|
||||
}
|
||||
function test_real_card_handover_data() {
|
||||
return ["album", "track", "artist", "label", "playlist"].map(function(kind) {
|
||||
return {tag: kind, kind: kind}
|
||||
})
|
||||
}
|
||||
function test_real_card_handover(data) {
|
||||
host.artMap = ({})
|
||||
cell.item = {kind:data.kind, id:"test", artKey:"cover", title:"Fixture", artist:"Artist",
|
||||
artistId:"1", genre:"", year:"2000", qualityTier:"", source:"qobuz",
|
||||
imageUrl:"fixture", isFavorite:false, isPinned:false, playlistOwnImage:true}
|
||||
function card() {
|
||||
for (var i=0;i<cell.children.length;i++) {
|
||||
var child=cell.children[i]
|
||||
if (child.item && child.item.artworkReady !== undefined) return child.item
|
||||
}
|
||||
return null
|
||||
}
|
||||
tryVerify(function() { return card() !== null })
|
||||
compare(card().artworkReady, false)
|
||||
host.artMap = {cover:Qt.resolvedUrl("fixtures/red.ppm").toString()}
|
||||
tryVerify(function() { return card().artworkReady })
|
||||
compare(card().artworkImmediate, Screen.devicePixelRatio <= 1)
|
||||
if (Screen.devicePixelRatio > 1) wait(250)
|
||||
waitForRendering(cell, 100)
|
||||
var shot=grabImage(cell)
|
||||
verify(shot.red(100,100)>240 && shot.green(100,100)<10, "cover must replace grey at center")
|
||||
cell.item = {kind:"group-header", id:"", title:""}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import QtQuick
|
||||
import QtTest
|
||||
import "../../crates/qbz-qt/qml/controls" as Controls
|
||||
Item {
|
||||
width: 800
|
||||
height: 600
|
||||
Controls.QbzConfirmModal {
|
||||
id: modal
|
||||
anchors.fill: parent
|
||||
title: "Close QBZ?"
|
||||
confirmLabel: "Yes"
|
||||
cancelLabel: "No"
|
||||
checkboxLabel: "Don't show again"
|
||||
danger: false
|
||||
}
|
||||
SignalSpy { id: confirmed; target: modal; signalName: "confirmed" }
|
||||
SignalSpy { id: cancelled; target: modal; signalName: "cancelled" }
|
||||
TestCase {
|
||||
name: "QuitConfirmation"
|
||||
when: windowShown
|
||||
function init() {
|
||||
confirmed.clear(); cancelled.clear()
|
||||
modal.checkboxChecked = false
|
||||
modal.open()
|
||||
wait(30)
|
||||
}
|
||||
function cleanup() { modal.close() }
|
||||
function test_escape_cancels_without_confirming() {
|
||||
keyClick(Qt.Key_Escape)
|
||||
compare(cancelled.count, 1)
|
||||
compare(confirmed.count, 0)
|
||||
verify(!modal.opened)
|
||||
}
|
||||
function test_initial_enter_is_no() {
|
||||
keyClick(Qt.Key_Return)
|
||||
compare(cancelled.count, 1)
|
||||
compare(confirmed.count, 0)
|
||||
}
|
||||
function test_keyboard_yes_preserves_checkbox_for_handler() {
|
||||
modal.checkboxChecked = true
|
||||
keyClick(Qt.Key_Tab)
|
||||
keyClick(Qt.Key_Return)
|
||||
compare(confirmed.count, 1)
|
||||
compare(cancelled.count, 0)
|
||||
verify(modal.checkboxChecked)
|
||||
verify(!modal.opened)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -0,0 +1,96 @@
|
||||
// Exercise the production range/report callbacks with fake native view geometry.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
const base = new URL('../crates/qbz-qt/qml/views/', import.meta.url);
|
||||
function fn(file, name) {
|
||||
const source=fs.readFileSync(new URL(file,base),'utf8');
|
||||
const begin=source.indexOf('function '+name+'('); assert.ok(begin>=0);
|
||||
const open=source.indexOf('{',begin); let depth=1;
|
||||
for(let i=open+1;i<source.length;i++) {
|
||||
if(source[i]==='{')depth++;
|
||||
if(source[i]==='}'&&!--depth)return '('+source.slice(begin,i+1)+')';
|
||||
}
|
||||
throw Error(name);
|
||||
}
|
||||
function call(file,name,ctx,...args) {return vm.runInNewContext(fn(file,name),ctx)(...args);}
|
||||
let windows=[];
|
||||
const rows=Array.from({length:200},(_,i)=>({kind:'album',artKey:'a'+i,imageUrl:'url'+i}));
|
||||
const root={visibleRows:rows};
|
||||
let grid={visible:true,width:880,cellWidth:220,cellHeight:266,height:532,contentY:2660};
|
||||
call('LibraryView.qml','gridWindowReport',{root,grid,queueWindowReport:(...a)=>windows.push(a)});
|
||||
assert.deepEqual(windows[0].filter((_,i)=>i!==2),[32,55,40]); // visible rows 10/11, ±2
|
||||
let starts=0;
|
||||
const timer={running:false,start(){starts++;this.running=true;}};
|
||||
for(let i=0;i<50;i++)call('LibraryView.qml','queueWindowReport',{windowDebounce:timer},i,i+20);
|
||||
assert.equal(starts,1,'continuous scroll must not postpone the flush');
|
||||
assert.equal(timer.pendingFirst,49,'only the latest pending window survives');
|
||||
Object.assign(root,{artMap:{},_artInbox:{},_artSent:{}});
|
||||
const requests=[];
|
||||
const ctx={root,artMap:root.artMap,QbzLibrary:{libraryArtworkWindow:s=>requests.push(JSON.parse(s))}};
|
||||
call('LibraryView.qml','reportWindow',ctx,rows,32,55,40);
|
||||
assert.equal(requests[0][0],'a40','visible art precedes buffered art');
|
||||
assert.equal(requests[0].length,24);
|
||||
call('LibraryView.qml','reportWindow',ctx,rows,32,55,40);
|
||||
assert.equal(requests.length,1,'pending downloads are not duplicated every tick');
|
||||
root._artSent.a40 -= 30000;
|
||||
call('LibraryView.qml','reportWindow',ctx,rows,32,55,40);
|
||||
assert.deepEqual(requests[1],['a40'],'a failed request can be retried on a later report');
|
||||
root.artMap.a40='cached.png'; root._artSent.a40 -= 30000;
|
||||
call('LibraryView.qml','reportWindow',ctx,rows,32,55,40);
|
||||
assert.equal(requests.length,2,'resolved covers do not re-enter the bridge');
|
||||
call('LibraryView.qml','reportWindow',ctx,[],0,-1);
|
||||
assert.equal(Object.keys(root._artSent).length,0,'empty model releases request state');
|
||||
// Native Local Library uses entry rows, not album indices. Two buffered rows
|
||||
// of four albums must be requested even though they are outside the viewport.
|
||||
let captured;
|
||||
const entries=Array.from({length:40},(_,i)=>({t:1,items:Array.from({length:4},(_,j)=>({id:i*4+j}))}));
|
||||
const local={viewMode:'grid',surface:'albums'};
|
||||
const view={releaseWindow(){throw Error('unexpected release');},queueWindowReport:r=>captured=r};
|
||||
const list={visible:true,contentY:1000,height:200,indexAt:(x,y)=>y<1100?10:11};
|
||||
call('local/LocalAlbumCollection.qml','report',{root:local,view,list,nativeActive:true,nativeModel:{totalCount:40,rowAt:i=>entries[i]},width:800});
|
||||
assert.equal(captured.length,24);
|
||||
assert.equal(captured[0].id,40);
|
||||
assert.deepEqual([...captured.map(x=>x.id)].sort((a,b)=>a-b),Array.from({length:24},(_,i)=>32+i));
|
||||
// Scene must request the current band after row 48, not keep walking the head.
|
||||
let urls;
|
||||
const scene={isReady:true,visible:true,cardH:246,rowGap:11,rowModel:entries.map((e,i)=>({artists:[{artUrl:'u'+i}]})),requestCovers:u=>urls=u};
|
||||
call('ArtistSceneView.qml','requestVisibleCovers',{root:scene},list);
|
||||
assert.deepEqual([...urls],['u10','u11','u12','u13','u8','u9']);
|
||||
console.log('Grid prefetch: two rows, visible priority, continuous-scroll flush, dedup and native ranges passed');
|
||||
|
||||
// Motion within the same band must not rebuild native QVariant rows or keep
|
||||
// scheduling Library reports. Late data/layout/artwork callbacks bypass it.
|
||||
let clock = 10000;
|
||||
const fakeDate = {now: () => clock};
|
||||
let reads = 0, sends = 0;
|
||||
const motionCtx = {root:local,view:{releaseWindow(){},queueWindowReport(){sends++;}},
|
||||
list,nativeActive:true,nativeModel:{totalCount:40,rowAt:i=>{reads++;return entries[i];}},
|
||||
width:800,Date:fakeDate};
|
||||
local._motionBand = '';
|
||||
call('local/LocalAlbumCollection.qml','report',motionCtx,true);
|
||||
for(let i=0;i<100;i++)call('local/LocalAlbumCollection.qml','report',motionCtx,true);
|
||||
assert.equal(reads,6,'100 motion events in one band read native rows only once');
|
||||
assert.equal(sends,1);
|
||||
call('local/LocalAlbumCollection.qml','report',motionCtx);
|
||||
assert.equal(sends,2,'late page/artwork reports bypass motion dedup');
|
||||
clock += 1001;
|
||||
call('local/LocalAlbumCollection.qml','report',motionCtx,true);
|
||||
assert.equal(sends,3,'continued movement permits a retry refresh');
|
||||
list.indexAt=(x,y)=>y<1100?11:12;
|
||||
call('local/LocalAlbumCollection.qml','report',motionCtx,true);
|
||||
assert.equal(sends,4,'crossing a row boundary immediately advances prefetch');
|
||||
let gridSends=0;
|
||||
const gridCtx={root,grid,Date:fakeDate,queueWindowReport(){gridSends++;}};
|
||||
root._gridMotionBand='';
|
||||
for(let i=0;i<100;i++)call('LibraryView.qml','gridWindowReport',gridCtx,true);
|
||||
assert.equal(gridSends,1);
|
||||
call('LibraryView.qml','gridWindowReport',gridCtx);
|
||||
assert.equal(gridSends,2,'a filter/model replacement cannot be suppressed');
|
||||
grid.contentY += grid.cellHeight;
|
||||
call('LibraryView.qml','gridWindowReport',gridCtx,true);
|
||||
assert.equal(gridSends,3);
|
||||
clock += 1001;
|
||||
call('LibraryView.qml','gridWindowReport',gridCtx,true);
|
||||
assert.equal(gridSends,4);
|
||||
console.log('Motion dedup: native reads, late pages, model refresh, row changes and retries passed');
|
||||
@@ -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');
|
||||
Reference in new issue
Block a user