diff options
Diffstat (limited to 'ui_common.py')
| -rw-r--r-- | ui_common.py | 396 |
1 files changed, 396 insertions, 0 deletions
diff --git a/ui_common.py b/ui_common.py new file mode 100644 index 0000000..f85a7cb --- /dev/null +++ b/ui_common.py @@ -0,0 +1,396 @@ +import shutil +import subprocess +from concurrent.futures import ThreadPoolExecutor, as_completed + +from PyQt6.QtCore import ( + QAbstractTableModel, + QElapsedTimer, + QModelIndex, + QSortFilterProxyModel, + Qt, + QThread, + QTimer, + pyqtSignal, +) +from PyQt6.QtGui import QAction, QBrush, QColor, QIcon +from PyQt6.QtWidgets import ( + QAbstractItemView, + QApplication, + QFrame, + QHBoxLayout, + QInputDialog, + QLabel, + QMenu, + QMessageBox, + QProgressBar, + QSizePolicy, + QStyle, + QTableView, + QToolButton, +) + +from models import ART_COL, FIELDS, HEADERS, LRC_COL, Track + +DIRTY_BRUSH = QBrush(QColor(255, 190, 0, 70)) +MISSING_BRUSH = QBrush(QColor(220, 60, 60)) +EDIT_TRIGGERS = ( + QAbstractItemView.EditTrigger.DoubleClicked + | QAbstractItemView.EditTrigger.EditKeyPressed + | QAbstractItemView.EditTrigger.AnyKeyPressed +) +REQUIRED_TOOLS = ("ffmpeg", "flac", "ffmpeg-normalize") + + +def tools_ok(parent) -> bool: + missing = [tool for tool in REQUIRED_TOOLS if not shutil.which(tool)] + if missing: + QMessageBox.critical(parent, "Missing tools", f"Not found on PATH: {', '.join(missing)}\nSee README.md.") + return not missing + + +def std_icon(theme: str, fallback: QStyle.StandardPixmap) -> QIcon: + return QIcon.fromTheme(theme, QApplication.style().standardIcon(fallback)) + + +class Cancelled(Exception): + pass + + +class Worker(QThread): + """Runs fn(item) for every item on a thread pool, reporting log lines and progress.""" + + log = pyqtSignal(str) + progress = pyqtSignal(int, int) + current = pyqtSignal(str) + + def __init__(self, items, fn, workers: int = 1, parent=None): + super().__init__(parent) + self.items, self.fn, self.workers = list(items), fn, max(1, workers) + self.results = [] + self._stop = False + + def stop(self): + self._stop = True + + def _run_one(self, item): + if self._stop: + raise Cancelled() + name = getattr(item, "name", None) + if name: + self.current.emit(name) + return self.fn(item) + + def run(self): + total, done = len(self.items), 0 + self.progress.emit(0, total) + with ThreadPoolExecutor(max_workers=self.workers) as executor: + futures = {executor.submit(self._run_one, item): item for item in self.items} + for future in as_completed(futures): + item = futures[future] + try: + result = future.result() + self.results.append((item, result)) + if isinstance(result, str) and result.strip(): + self.log.emit(result.strip()) + except Cancelled: + pass + except subprocess.CalledProcessError as e: + self.log.emit(f"ERROR {item.name}: {e}\n{(e.stderr or '')[-800:]}") + except Exception as e: + self.log.emit(f"ERROR {getattr(item, 'name', item)}: {e}") + done += 1 + self.progress.emit(done, total) + if self._stop: + self.log.emit("Stopped.") + + +# Background threads not parented to a widget, kept alive until they finish so closing a dialog can't +# destroy a QThread that is still blocked on the network. +_live_threads: set[QThread] = set() + + +class Task(QThread): + progress = pyqtSignal(object) + done = pyqtSignal(object) + failed = pyqtSignal(str) + + def __init__(self, fn): + super().__init__() + self.fn = fn + + def run(self): + try: + self.done.emit(self.fn(self)) + except Exception as e: + self.failed.emit(str(e)) + + def start(self): + _live_threads.add(self) + self.finished.connect(lambda: _live_threads.discard(self)) + super().start() + + +def format_duration(seconds: float) -> str: + seconds = int(seconds) + return f"{seconds // 3600}:{seconds // 60 % 60:02d}:{seconds % 60:02d}" if seconds >= 3600 else f"{seconds // 60}:{seconds % 60:02d}" + + +class ProgressStrip(QFrame): + def __init__(self, stop_action: QAction | None = None): + super().__init__() + self.setFrameShape(QFrame.Shape.StyledPanel) + self.title = QLabel() + self.title.setStyleSheet("font-weight: bold;") + self.bar = QProgressBar() + self.bar.setMinimumWidth(200) + self.detail = QLabel() + self.detail.setStyleSheet("color: palette(placeholder-text);") + # Ignored width so long filenames get clipped instead of stretching the window + self.detail.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + self.clock = QLabel() + self._elapsed = QElapsedTimer() + self._timer = QTimer(self) + self._timer.setInterval(1000) + self._timer.timeout.connect(self._tick) + self._done = self._total = 0 + + row = QHBoxLayout(self) + row.setContentsMargins(8, 4, 8, 4) + row.addWidget(self.title) + row.addWidget(self.bar, 2) + row.addWidget(self.detail, 3) + row.addWidget(self.clock) + if stop_action: + stop = QToolButton() + stop.setDefaultAction(stop_action) + stop.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + row.addWidget(stop) + self.hide() + + def start(self, title: str, total: int = 0): + self.title.setText(title) + self.detail.clear() + self._elapsed.start() + self._timer.start() + self.set_progress(0, total) + self.show() + + def set_title(self, title: str): + self.title.setText(title) + + def set_progress(self, done: int, total: int): + self._done, self._total = done, total + if total == 0 or (total == 1 and done == 0): + self.bar.setRange(0, 0) # unknown / single long step: show a busy animation instead of 0% + else: + self.bar.setRange(0, total) + self.bar.setValue(done) + self.bar.setFormat(f"{done} / {total} (%p%)") + self._tick() + + def set_detail(self, text: str): + self.detail.setText(text) + self.detail.setToolTip(text) + + def _tick(self): + seconds = self._elapsed.elapsed() / 1000 if self._elapsed.isValid() else 0 + text = format_duration(seconds) + if 0 < self._done < self._total: + text += f" · ~{format_duration(seconds / self._done * (self._total - self._done))} left" + self.clock.setText(text) + + def finish(self): + self._timer.stop() + self.hide() + + +class TrackModel(QAbstractTableModel): + def __init__(self): + super().__init__() + self.tracks: list[Track] = [] + + def set_tracks(self, tracks): + self.beginResetModel() + self.tracks = list(tracks) + self.endResetModel() + + def add_tracks(self, tracks): + if not tracks: + return + start = len(self.tracks) + self.beginInsertRows(QModelIndex(), start, start + len(tracks) - 1) + self.tracks += tracks + self.endInsertRows() + + def remove_rows(self, rows): + for row in sorted(rows, reverse=True): + self.beginRemoveRows(QModelIndex(), row, row) + del self.tracks[row] + self.endRemoveRows() + + def refresh(self): + if self.tracks: + self.dataChanged.emit(self.index(0, 0), self.index(len(self.tracks) - 1, ART_COL)) + + def rowCount(self, parent=QModelIndex()): + return 0 if parent.isValid() else len(self.tracks) + + def columnCount(self, parent=QModelIndex()): + return 0 if parent.isValid() else len(HEADERS) + + def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: + return HEADERS[section] + return super().headerData(section, orientation, role) + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + track = self.tracks[index.row()] + col = index.column() + if col == LRC_COL: + if role == Qt.ItemDataRole.DisplayRole: + return track.lrc + if role == Qt.ItemDataRole.ForegroundRole and track.lrc != "OK": + return MISSING_BRUSH + if role == Qt.ItemDataRole.ToolTipRole: + return str(track.path.with_suffix(".lrc")) + return None + if col == ART_COL: + if role == Qt.ItemDataRole.DisplayRole: + return "OK" if track.art else "Missing" + if role == Qt.ItemDataRole.ForegroundRole and not track.art: + return MISSING_BRUSH + return None + + key = FIELDS[col][0] + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): + return track.tags[key] + if role == Qt.ItemDataRole.BackgroundRole and track.is_dirty(key): + return DIRTY_BRUSH + if role == Qt.ItemDataRole.ToolTipRole: + if track.is_dirty(key): + return f"Original: {track.orig[key]}" + return str(track.path) if key == "FILE" else None + return None + + def flags(self, index): + flags = super().flags(index) + if index.column() < LRC_COL: + flags |= Qt.ItemFlag.ItemIsEditable + return flags + + def setData(self, index, value, role=Qt.ItemDataRole.EditRole): + if role != Qt.ItemDataRole.EditRole or index.column() >= LRC_COL: + return False + self.tracks[index.row()].tags[FIELDS[index.column()][0]] = str(value) + self.dataChanged.emit(index, index) + return True + + +class FilterProxy(QSortFilterProxyModel): + def __init__(self): + super().__init__() + self.text = "" + self.missing_lrc = False + self.missing_art = False + self.setSortCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + + def set_filter(self, text=None, missing_lrc=None, missing_art=None): + if text is not None: + self.text = text.lower() + if missing_lrc is not None: + self.missing_lrc = missing_lrc + if missing_art is not None: + self.missing_art = missing_art + self.invalidate() + + def filterAcceptsRow(self, row, parent): + track = self.sourceModel().tracks[row] + if self.missing_lrc and track.lrc == "OK": + return False + if self.missing_art and track.art: + return False + return not self.text or any(self.text in v.lower() for v in track.tags.values()) + + +class TrackTable(QTableView): + def __init__(self, source: TrackModel): + super().__init__() + self.source = source + self.proxy = FilterProxy() + self.proxy.setSourceModel(source) + self.setModel(self.proxy) + self.setSortingEnabled(True) + self.sortByColumn(-1, Qt.SortOrder.AscendingOrder) + self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.setEditTriggers(EDIT_TRIGGERS) + self.setAlternatingRowColors(True) + self.setWordWrap(False) + self.verticalHeader().setVisible(False) + self.horizontalHeader().setStretchLastSection(True) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._context_menu) + self.extra_actions: list[QAction] = [] + + def fit_columns(self): + self.resizeColumnsToContents() + for col in range(len(HEADERS)): + self.setColumnWidth(col, min(self.columnWidth(col), 350)) + + def selected_rows(self) -> list[int]: + return sorted({self.proxy.mapToSource(i).row() for i in self.selectionModel().selectedRows()}) + + def selected_tracks(self) -> list[Track]: + """Selected rows, falling back to the current row.""" + rows = self.selected_rows() + if not rows and self.currentIndex().isValid(): + rows = [self.proxy.mapToSource(self.currentIndex()).row()] + return [self.source.tracks[r] for r in rows] + + def current_track(self) -> Track | None: + index = self.currentIndex() + if not index.isValid(): + rows = self.selected_rows() + return self.source.tracks[rows[0]] if rows else None + return self.source.tracks[self.proxy.mapToSource(index).row()] + + def target_tracks(self) -> list[Track]: + rows = self.selected_rows() or [ + self.proxy.mapToSource(self.proxy.index(r, 0)).row() for r in range(self.proxy.rowCount()) + ] + return [self.source.tracks[r] for r in rows] + + def _context_menu(self, pos): + index = self.indexAt(pos) + rows = self.selected_rows() + if not index.isValid() or not rows: + return + col = index.column() + menu = QMenu(self) + if col < LRC_COL and not (col == 0 and len(rows) > 1): + menu.addAction(f"Set {HEADERS[col]} for {len(rows)} row(s)…").triggered.connect( + lambda: self._bulk_edit(rows, col, index.data()) + ) + menu.addAction("Revert changes").triggered.connect(lambda: self._revert(rows)) + if self.extra_actions: + menu.addSeparator() + menu.addActions(self.extra_actions) + menu.exec(self.viewport().mapToGlobal(pos)) + + def _bulk_edit(self, rows, col, current): + value, ok = QInputDialog.getText(self, "Bulk edit", f"{HEADERS[col]}:", text=current or "") + if ok: + for row in rows: + self.source.tracks[row].tags[FIELDS[col][0]] = value + self.source.refresh() + + def _revert(self, rows): + for row in rows: + track = self.source.tracks[row] + track.tags = dict(track.orig) + self.source.refresh() + + +def section_label(text: str) -> QLabel: + label = QLabel(text.upper()) + label.setStyleSheet("font-weight: bold; font-size: 11px; color: palette(placeholder-text); padding: 2px 0;") + return label |
