From 733e3c908912be3a6006255ac9f6a35cd1f43a08 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sun, 13 Sep 2026 23:20:08 -0400 Subject: [PATCH] Fix UI freeze on first cover load (decode+scale off the GUI thread) --- app/gui.py | 53 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/app/gui.py b/app/gui.py index eca7d8d..3d3850c 100644 --- a/app/gui.py +++ b/app/gui.py @@ -117,14 +117,16 @@ def _thread_running(thread): class CoverLoader(QObject): - loaded = Signal(int, int, str) # gen, item index, cached image path + loaded = Signal(int, int, QImage) # gen, item index, fitted cover image failed = Signal(int, int, str) # gen, item index, name (no cover found) finished = Signal() - def __init__(self, items, gen): + def __init__(self, items, gen, icon_w=150, icon_h=220): super().__init__() self.items = items self.gen = gen + self.icon_w = icon_w + self.icon_h = icon_h self._cancel = threading.Event() def cancel(self): @@ -148,7 +150,11 @@ class CoverLoader(QObject): except Exception: # noqa: BLE001 path = None if path is not None: - self.loaded.emit(self.gen, i, str(path)) + img = QImage(str(path)) + if img.isNull(): + self.failed.emit(self.gen, i, self.items[i]["name"]) + else: + self.loaded.emit(self.gen, i, _fit_image(img, self.icon_w, self.icon_h)) else: self.failed.emit(self.gen, i, self.items[i]["name"]) finally: @@ -276,6 +282,25 @@ def _fit_pixmap(pm, w, h): return canvas +def _fit_image(img, w, h): + """Scale a QImage into a letterboxed ``w x h`` ARGB image (thread-safe). + + Decoding + scaling happen off the GUI thread in the cover loaders; the + main thread only converts the result to a QPixmap (cheap). + """ + canvas = QImage(w, h, QImage.Format_ARGB32) + canvas.fill(Qt.transparent) + if img.isNull(): + return canvas + scaled = img.scaled(w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation) + x = (w - scaled.width()) // 2 + y = (h - scaled.height()) // 2 + painter = QPainter(canvas) + painter.drawImage(x, y, scaled) + painter.end() + return canvas + + def _cover_pixmap(rid, system, max_w, max_h): """Return a scaled cover pixmap for an item, or None if unavailable.""" if rid is None: @@ -429,7 +454,7 @@ class CoverShowcase(QWidget): for loader in self._loaders: loader.cancel() self.thread = QThread(self) - self.loader = CoverLoader(self.items, gen) + self.loader = CoverLoader(self.items, gen, self.ICON_W, self.ICON_H) self.loader.moveToThread(self.thread) self.thread.started.connect(self.loader.run) self.loader.loaded.connect(self._on_loaded) @@ -454,12 +479,12 @@ class CoverShowcase(QWidget): painter.end() return QIcon(pm) - def _on_loaded(self, gen, index, path): + def _on_loaded(self, gen, index, image): if gen != self._generation: return item = self.list.item(index) if item is not None: - item.setIcon(self._fit_icon(QPixmap(path))) + item.setIcon(QIcon(QPixmap.fromImage(image))) if 0 <= index < len(self.items): self.cover_loaded.emit(self.items[index]) @@ -1479,14 +1504,16 @@ class StoreScanWorker(QObject): class StoreCoverLoader(QObject): - loaded = Signal(int, int, str) # gen, grid row, cached image path + loaded = Signal(int, int, QImage) # gen, grid row, fitted cover image finished = Signal() - def __init__(self, tasks, gen, quick=True): + def __init__(self, tasks, gen, quick=True, icon_w=150, icon_h=220): super().__init__() self.tasks = tasks # list of (grid_row, item_dict) self.gen = gen self.quick = quick + self.icon_w = icon_w + self.icon_h = icon_h self._cancel = threading.Event() def cancel(self): @@ -1510,7 +1537,9 @@ class StoreCoverLoader(QObject): except Exception: # noqa: BLE001 path = None if path is not None: - self.loaded.emit(self.gen, row, str(path)) + img = QImage(str(path)) + if not img.isNull(): + self.loaded.emit(self.gen, row, _fit_image(img, self.icon_w, self.icon_h)) finally: pool.shutdown(wait=True, cancel_futures=True) self.finished.emit() @@ -2336,7 +2365,7 @@ class StorePage(QWidget): if self._closing or not tasks: return thread = QThread(self) - loader = StoreCoverLoader(tasks, gen, quick=True) + loader = StoreCoverLoader(tasks, gen, quick=True, icon_w=self.ICON_W, icon_h=self.ICON_H) loader.moveToThread(thread) thread.started.connect(loader.run) loader.loaded.connect(self._on_cover_loaded) @@ -2346,12 +2375,12 @@ class StorePage(QWidget): self._loaders.append(loader) thread.start() - def _on_cover_loaded(self, gen, row, path): + def _on_cover_loaded(self, gen, row, image): if gen != self._generation: return item = self.grid.item(row) if item is not None: - item.setIcon(QIcon(_fit_pixmap(QPixmap(path), self.ICON_W, self.ICON_H))) + item.setIcon(QIcon(QPixmap.fromImage(image))) it = item.data(Qt.UserRole) shown = getattr(self.details, "_item", None) if it is not None and shown is not None and it.get("path") == shown.get("path"):