Fix UI freeze on first cover load (decode+scale off the GUI thread)
build / Linux x86_64 (push) Failing after 8m22s
build / Windows x64 (Wine) (push) Canceled after 17m46s

This commit is contained in:
2026-09-13 23:20:08 -04:00
parent dc7d6c9720
commit 733e3c9089
+41 -12
View File
@@ -117,14 +117,16 @@ def _thread_running(thread):
class CoverLoader(QObject): 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) failed = Signal(int, int, str) # gen, item index, name (no cover found)
finished = Signal() finished = Signal()
def __init__(self, items, gen): def __init__(self, items, gen, icon_w=150, icon_h=220):
super().__init__() super().__init__()
self.items = items self.items = items
self.gen = gen self.gen = gen
self.icon_w = icon_w
self.icon_h = icon_h
self._cancel = threading.Event() self._cancel = threading.Event()
def cancel(self): def cancel(self):
@@ -148,7 +150,11 @@ class CoverLoader(QObject):
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
path = None path = None
if path is not 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: else:
self.failed.emit(self.gen, i, self.items[i]["name"]) self.failed.emit(self.gen, i, self.items[i]["name"])
finally: finally:
@@ -276,6 +282,25 @@ def _fit_pixmap(pm, w, h):
return canvas 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): def _cover_pixmap(rid, system, max_w, max_h):
"""Return a scaled cover pixmap for an item, or None if unavailable.""" """Return a scaled cover pixmap for an item, or None if unavailable."""
if rid is None: if rid is None:
@@ -429,7 +454,7 @@ class CoverShowcase(QWidget):
for loader in self._loaders: for loader in self._loaders:
loader.cancel() loader.cancel()
self.thread = QThread(self) 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.loader.moveToThread(self.thread)
self.thread.started.connect(self.loader.run) self.thread.started.connect(self.loader.run)
self.loader.loaded.connect(self._on_loaded) self.loader.loaded.connect(self._on_loaded)
@@ -454,12 +479,12 @@ class CoverShowcase(QWidget):
painter.end() painter.end()
return QIcon(pm) return QIcon(pm)
def _on_loaded(self, gen, index, path): def _on_loaded(self, gen, index, image):
if gen != self._generation: if gen != self._generation:
return return
item = self.list.item(index) item = self.list.item(index)
if item is not None: if item is not None:
item.setIcon(self._fit_icon(QPixmap(path))) item.setIcon(QIcon(QPixmap.fromImage(image)))
if 0 <= index < len(self.items): if 0 <= index < len(self.items):
self.cover_loaded.emit(self.items[index]) self.cover_loaded.emit(self.items[index])
@@ -1479,14 +1504,16 @@ class StoreScanWorker(QObject):
class StoreCoverLoader(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() finished = Signal()
def __init__(self, tasks, gen, quick=True): def __init__(self, tasks, gen, quick=True, icon_w=150, icon_h=220):
super().__init__() super().__init__()
self.tasks = tasks # list of (grid_row, item_dict) self.tasks = tasks # list of (grid_row, item_dict)
self.gen = gen self.gen = gen
self.quick = quick self.quick = quick
self.icon_w = icon_w
self.icon_h = icon_h
self._cancel = threading.Event() self._cancel = threading.Event()
def cancel(self): def cancel(self):
@@ -1510,7 +1537,9 @@ class StoreCoverLoader(QObject):
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
path = None path = None
if path is not 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: finally:
pool.shutdown(wait=True, cancel_futures=True) pool.shutdown(wait=True, cancel_futures=True)
self.finished.emit() self.finished.emit()
@@ -2336,7 +2365,7 @@ class StorePage(QWidget):
if self._closing or not tasks: if self._closing or not tasks:
return return
thread = QThread(self) 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) loader.moveToThread(thread)
thread.started.connect(loader.run) thread.started.connect(loader.run)
loader.loaded.connect(self._on_cover_loaded) loader.loaded.connect(self._on_cover_loaded)
@@ -2346,12 +2375,12 @@ class StorePage(QWidget):
self._loaders.append(loader) self._loaders.append(loader)
thread.start() thread.start()
def _on_cover_loaded(self, gen, row, path): def _on_cover_loaded(self, gen, row, image):
if gen != self._generation: if gen != self._generation:
return return
item = self.grid.item(row) item = self.grid.item(row)
if item is not None: 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) it = item.data(Qt.UserRole)
shown = getattr(self.details, "_item", None) shown = getattr(self.details, "_item", None)
if it is not None and shown is not None and it.get("path") == shown.get("path"): if it is not None and shown is not None and it.get("path") == shown.get("path"):