diff --git a/app/covers.py b/app/covers.py index 4c077cd..b75442c 100644 --- a/app/covers.py +++ b/app/covers.py @@ -937,25 +937,28 @@ def _make_pic0(title, description, esrb, QImage, QPainter, QPainterPath, QColor, QRectF, Qt, _save): W, H = 310, 180 img = QImage(W, H, QImage.Format_ARGB32) + img.fill(QColor(0, 0, 0, 0)) - # Dark gradient backdrop. + # Dark gradient backdrop clipped to a rounded card (transparent corners). grad = QLinearGradient(0, 0, 0, H) grad.setColorAt(0.0, QColor(38, 38, 40)) grad.setColorAt(1.0, QColor(12, 12, 14)) + card = QPainterPath() + card.addRoundedRect(QRectF(0, 0, W, H), 12, 12) p = QPainter(img) p.setRenderHint(QPainter.Antialiasing, True) p.setRenderHint(QPainter.TextAntialiasing, True) - p.fillRect(0, 0, W, H, grad) + p.fillPath(card, grad) - # Rounded border. + # Rounded card border. p.setPen(QPen(QColor(110, 110, 115), 1)) p.setBrush(Qt.NoBrush) - p.drawRoundedRect(QRectF(4, 4, W - 8, H - 8), 9, 9) + p.drawPath(card) # PlayStation button glyphs, top-right. - _draw_ps_glyphs(p, W - 34, 12, 22, QColor(200, 200, 205), QRectF, QPointF, - QPolygonF, QPen, Qt) + _draw_ps_glyphs(p, W - 34, 12, 22, QRectF, QPointF, QPolygonF, QPen, Qt, + QColor) # Title (bold, upper-left). Shrink the font until the whole title fits, so # long names are shown in full instead of being cut with an ellipsis. @@ -977,6 +980,10 @@ def _make_pic0(title, description, esrb, QImage, QPainter, QPainterPath, QColor, p.drawText(QRectF(18, 16, title_width, 28), Qt.AlignLeft | Qt.AlignVCenter, title_text) + # Thin divider under the title (like the original PSN info board). + p.setPen(QPen(QColor(255, 255, 255, 55), 1)) + p.drawLine(QPointF(18, 46), QPointF(W - 18, 46)) + # Description (wrapped to at most 3 lines, last line elided). if description: f_desc = QFont("DejaVu Sans") @@ -985,7 +992,7 @@ def _make_pic0(title, description, esrb, QImage, QPainter, QPainterPath, QColor, p.setPen(QColor(215, 215, 215)) fm = QFontMetrics(f_desc) lines = _wrap_elided(fm, description.strip(), W - 36, 3, Qt) - y = 46 + y = 52 for line in lines: p.drawText(QRectF(18, y, W - 36, 16), Qt.AlignLeft | Qt.AlignVCenter, line) y += 16 @@ -1027,23 +1034,31 @@ def _wrap_elided(fm, text, width, max_lines, Qt): return lines[:max_lines] -def _draw_ps_glyphs(p, x, y, size, color, QRectF, QPointF, QPolygonF, QPen, Qt): - """Draw the four PlayStation face-button glyphs in a 2x2 grid.""" +def _draw_ps_glyphs(p, x, y, size, QRectF, QPointF, QPolygonF, QPen, Qt, QColor): + """Draw the four PlayStation face-button glyphs in a 2x2 grid, using the + official button colours (green triangle, red circle, blue cross, pink square).""" cell = size / 2.0 - pen = QPen(color, max(1.0, size / 14.0)) - pen.setCapStyle(Qt.RoundCap) - pen.setJoinStyle(Qt.RoundJoin) - p.setPen(pen) p.setBrush(Qt.NoBrush) - # Triangle (top-left). + + def _pen(color): + pen = QPen(color, max(1.0, size / 14.0)) + pen.setCapStyle(Qt.RoundCap) + pen.setJoinStyle(Qt.RoundJoin) + return pen + + # Triangle (top-left, green). + p.setPen(_pen(QColor("#3F9B0F"))) tri = QPolygonF([QPointF(x, y + cell), QPointF(x + cell, y), QPointF(x + cell, y + cell)]) p.drawPolygon(tri) - # Circle (top-right). + # Circle (top-right, red). + p.setPen(_pen(QColor("#DE1C18"))) p.drawEllipse(QRectF(x + cell, y, cell, cell)) - # Cross (bottom-left). + # Cross (bottom-left, blue). + p.setPen(_pen(QColor("#287FC1"))) p.drawLine(QPointF(x + 1, y + cell + 1), QPointF(x + cell - 1, y + size - 1)) p.drawLine(QPointF(x + cell - 1, y + cell + 1), QPointF(x + 1, y + size - 1)) - # Square (bottom-right). + # Square (bottom-right, pink). + p.setPen(_pen(QColor("#C94E9B"))) p.drawRect(QRectF(x + cell, y + cell, cell, cell)) diff --git a/app/gui.py b/app/gui.py index 8e2ccae..7e5c249 100644 --- a/app/gui.py +++ b/app/gui.py @@ -17,7 +17,7 @@ from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGridLayout, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, - QProgressBar, QProgressDialog, QPushButton, QScrollArea, QSpinBox, QSplitter, QStackedWidget, + QProgressBar, QPushButton, QScrollArea, QSpinBox, QSplitter, QStackedWidget, QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -1663,6 +1663,8 @@ class StorePage(QWidget): """Browse the Redump 7z store and queue games for download.""" download_requested = Signal(list) # list of store item dicts + scrape_status = Signal(str) # progress text for the window status bar + scrape_complete = Signal() # fired when a background scrape finishes ICON_W, ICON_H = 150, 220 GRID_W, GRID_H = 182, 262 @@ -1692,7 +1694,6 @@ class StorePage(QWidget): self._dirty = False self._fill_timer = None self._scraping = False - self._scrape_dialog = None self._scrape_worker = None self._scrape_thread = None @@ -2392,12 +2393,8 @@ class StorePage(QWidget): QMessageBox.information(self, "Nothing to scrape", "The store is empty.") return self._scraping = True - dlg = QProgressDialog("Fetching metadata and covers for every game…", "Cancel", 0, len(tasks), self) - dlg.setWindowTitle("Scrape store") - dlg.setWindowModality(Qt.WindowModal) - dlg.setMinimumDuration(0) - dlg.setValue(0) - self._scrape_dialog = dlg + self.scrape_btn.setEnabled(False) + self.scrape_status.emit("Scraping store…") thread = QThread(self) worker = StoreScrapeAllWorker(tasks) @@ -2405,7 +2402,6 @@ class StorePage(QWidget): thread.started.connect(worker.run) worker.progress.connect(self._on_scrape_progress) worker.finished.connect(thread.quit) - dlg.canceled.connect(self._on_scrape_cancelled) worker.finished.connect(self._on_scrape_done) self._scrape_thread = thread self._scrape_worker = worker @@ -2413,31 +2409,12 @@ class StorePage(QWidget): self._workers.append(worker) thread.start() - def _on_scrape_cancelled(self): - worker = self._scrape_worker - if worker is not None: - worker.cancel() - dlg = self._scrape_dialog - if dlg is not None: - dlg.close() - # Drop the dialog reference so queued progress signals can't re-show it - # (QProgressDialog re-appears on setValue while a value is pending). - # Keep _scraping True until the worker actually finishes (_on_scrape_done) - # so a re-click doesn't spawn a second scrape while this one winds down. - self._scrape_dialog = None - def _on_scrape_progress(self, done, total): - dlg = self._scrape_dialog - if dlg is not None: - dlg.setLabelText(f"Fetching metadata + covers… {done}/{total}") - dlg.setValue(done) + self.scrape_status.emit(f"Scraping store… {done}/{total}") def _on_scrape_done(self): self._scraping = False - dlg = self._scrape_dialog - if dlg is not None: - dlg.close() - self._scrape_dialog = None + self.scrape_btn.setEnabled(True) thread = self._scrape_thread worker = self._scrape_worker if thread is not None and thread in self._threads: @@ -2447,7 +2424,9 @@ class StorePage(QWidget): self._scrape_thread = None self._scrape_worker = None if not self._closing: + self.scrape_status.emit("Store scrape complete") self._apply_filter() + self.scrape_complete.emit() def shutdown(self): self._closing = True @@ -2457,9 +2436,6 @@ class StorePage(QWidget): loader.cancel() if self._scrape_worker is not None: self._scrape_worker.cancel() - dlg = self._scrape_dialog - if dlg is not None: - dlg.close() for t in list(self._threads): try: if t.isRunning(): @@ -2649,6 +2625,61 @@ class PspInventoryWorker(QObject): self.finished.emit() +class PspEjectWorker(QObject): + """Unmount a PSP off the GUI thread (udisksctl can block for seconds).""" + result = Signal(bool, str) # ok, message + finished = Signal() + + def __init__(self, root): + super().__init__() + self.root = root + + def run(self): + try: + ok, msg = psp.eject(self.root) + except Exception as exc: # noqa: BLE001 + ok, msg = False, f"{type(exc).__name__}: {exc}" + self.result.emit(ok, msg) + self.finished.emit() + + +class PspMountsWorker(QObject): + """Detect mounted PSP sticks + UMD volumes off the GUI thread.""" + result = Signal(int, list, list) # gen, mounts, umd_mounts + finished = Signal() + + def __init__(self, gen): + super().__init__() + self.gen = gen + + def run(self): + try: + mounts = psp.detect_mounts() + umd = psp.umd_mounts() + except Exception: # noqa: BLE001 + mounts, umd = [], [] + self.result.emit(self.gen, mounts, umd) + self.finished.emit() + + +class PspUmdScanWorker(QObject): + """Detect mounted UMD volumes off the GUI thread.""" + result = Signal(int, list) # gen, umd_mounts + finished = Signal() + + def __init__(self, gen): + super().__init__() + self.gen = gen + + def run(self): + try: + umd = psp.umd_mounts() + except Exception: # noqa: BLE001 + umd = [] + self.result.emit(self.gen, umd) + self.finished.emit() + + class PspSyncPage(QWidget): """Show what is installed on a connected PSP and read a mounted UMD.""" @@ -2666,6 +2697,7 @@ class PspSyncPage(QWidget): self._lib_path_name = {} self._gen = 0 self._umd_gen = 0 + self._mounts_gen = 0 self._umd_iso = None self._umd_serial = None self._umd_kind = None @@ -2686,10 +2718,10 @@ class PspSyncPage(QWidget): refresh_btn = QPushButton("Refresh") refresh_btn.clicked.connect(self.refresh) device_row.addWidget(refresh_btn) - eject_btn = QPushButton("Eject") - eject_btn.setToolTip("Unmount the PSP so it is safe to unplug") - eject_btn.clicked.connect(self._eject) - device_row.addWidget(eject_btn) + self.eject_btn = QPushButton("Eject") + self.eject_btn.setToolTip("Unmount the PSP so it is safe to unplug") + self.eject_btn.clicked.connect(self._eject) + device_row.addWidget(self.eject_btn) layout.addLayout(device_row) self.status = QLabel("No PSP connected") @@ -2846,10 +2878,25 @@ class PspSyncPage(QWidget): layout.addWidget(self.umd_table, 1) return page - def refresh_umd(self): + def refresh_umd(self, umd_mounts=None): self._umd_gen += 1 gen = self._umd_gen - mounts = psp.umd_mounts() + if umd_mounts is None: + thread = QThread(self) + worker = PspUmdScanWorker(gen) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.result.connect(self._on_umd_mounts) + worker.finished.connect(thread.quit) + self._threads.append(thread) + self._workers.append(worker) + thread.start() + return + self._on_umd_mounts(gen, umd_mounts) + + def _on_umd_mounts(self, gen, mounts): + if gen != self._umd_gen: + return log.info("umd: %d mount(s) detected", len(mounts)) if not mounts: self._clear_umd() @@ -3006,10 +3053,24 @@ class PspSyncPage(QWidget): self.install_requested.emit(items) def refresh(self): - current = self.device_combo.currentText() - mounts = psp.detect_mounts() - umd = psp.umd_mounts() + self._mounts_gen += 1 + gen = self._mounts_gen + log.info("psp sync: detecting mounts…") + thread = QThread(self) + worker = PspMountsWorker(gen) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.result.connect(self._on_mounts) + worker.finished.connect(thread.quit) + self._threads.append(thread) + self._workers.append(worker) + thread.start() + + def _on_mounts(self, gen, mounts, umd): + if gen != self._mounts_gen: + return log.info("psp sync: %d mount(s), %d umd volume(s) detected", len(mounts), len(umd)) + current = self.device_combo.currentText() self.device_combo.blockSignals(True) self.device_combo.clear() self.device_combo.addItem("") @@ -3024,7 +3085,7 @@ class PspSyncPage(QWidget): self.device_combo.blockSignals(False) self._root = self._selected_root() self.refresh_inventory() - self.refresh_umd() + self.refresh_umd(umd) self.device_changed.emit(self._root) def _selected_root(self): @@ -3249,7 +3310,21 @@ class PspSyncPage(QWidget): if not self._root: QMessageBox.information(self, "Eject", "No PSP selected.") return - ok, msg = psp.eject(self._root) + log.info("ejecting %s…", self._root) + self.status.setText("Ejecting…") + self.eject_btn.setEnabled(False) + thread = QThread(self) + worker = PspEjectWorker(self._root) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.result.connect(self._on_eject_result) + worker.finished.connect(thread.quit) + self._threads.append(thread) + self._workers.append(worker) + thread.start() + + def _on_eject_result(self, ok, msg): + self.eject_btn.setEnabled(True) log.info("eject %s -> %s", self._root, "ok" if ok else f"failed: {msg}") self.refresh() if ok: @@ -4095,6 +4170,18 @@ class MainWindow(QMainWindow): self.tabs = QTabWidget() self.tabs.setStyleSheet("QTabWidget::pane { border: 0px; padding: 10px 0 0 0; }") layout.addWidget(self.tabs) + + # App logo in the top-right corner of the tab bar. + logo = QLabel() + logo.setStyleSheet("padding-right: 10px;") + icon_path = config.PROJECT_ROOT / "img" / "app-icon.png" + if icon_path.is_file(): + pm = QPixmap(str(icon_path)) + pm = pm.scaled(28, 28, Qt.KeepAspectRatio, Qt.SmoothTransformation) + logo.setPixmap(pm) + logo.setToolTip("PSP Preservation Tool") + self.tabs.setCornerWidget(logo, Qt.TopRightCorner) + self.queue = queue.TransferQueue(self) self.queue.job_finished.connect(self._on_queue_job_done) self.tabs.addTab(self._build_library_tab(), "Library") @@ -4103,6 +4190,8 @@ class MainWindow(QMainWindow): self._store_archive_id, self._store_archive_sig, self._store_archive_user, self._nps_dir, self._pkg2zip) self.store_page.download_requested.connect(self._on_store_download) + self.store_page.scrape_status.connect(self._on_store_scrape_status) + self.store_page.scrape_complete.connect(self.load_library) self.tabs.addTab(self.store_page, "Store") self.queue_page = QueuePage() self.queue_page.set_queue(self.queue) @@ -4619,6 +4708,9 @@ class MainWindow(QMainWindow): self.queue.enqueue(src, dest_dir, it, store.display_name(it["name"]), kind=kind) self.tabs.setCurrentWidget(self.queue_page) + def _on_store_scrape_status(self, msg): + self.statusBar().showMessage(msg) + # -- PSP sync ---------------------------------------------------------- # def _on_psp_device_changed(self, root): self._psp_root = root or "" diff --git a/img/qt-gui.png b/img/qt-gui.png index 0cc6b86..3128719 100644 Binary files a/img/qt-gui.png and b/img/qt-gui.png differ