""" library_panel.py — Musikbibliotek med søgning og drag-and-drop til danseliste. """ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLineEdit, QLabel, QHBoxLayout, QPushButton, QAbstractItemView, QStyledItemDelegate, ) from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray, QRect from PyQt6.QtGui import QColor, QDrag, QPainter, QBrush, QPen, QFont class DanseButtonDelegate(QStyledItemDelegate): """Tegner en orange 'Danse' label i højre side af hvert list-item.""" BTN_W = 54 BTN_H = 22 BTN_MARGIN = 8 def paint(self, painter: QPainter, option, index): super().paint(painter, option, index) rect = option.rect btn_rect = QRect( rect.right() - self.BTN_W - self.BTN_MARGIN, rect.top() + (rect.height() - self.BTN_H) // 2, self.BTN_W, self.BTN_H, ) painter.save() painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setBrush(QBrush(QColor("#e8a020"))) painter.setPen(Qt.PenStyle.NoPen) painter.drawRoundedRect(btn_rect, 4, 4) painter.setPen(QPen(QColor("#111111"))) font = QFont() font.setPointSize(8) font.setBold(True) painter.setFont(font) painter.drawText(btn_rect, Qt.AlignmentFlag.AlignCenter, "Danse") painter.restore() class DraggableLibraryList(QListWidget): """QListWidget med drag, dobbeltklik og klik på højre side for dans-tags.""" danse_clicked = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(dict) def __init__(self, parent=None): super().__init__(parent) self.setDragEnabled(True) self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly) self.setDefaultDropAction(Qt.DropAction.CopyAction) def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: item = self.itemAt(event.pos()) if item and event.pos().x() > self.viewport().width() - 75: song = item.data(Qt.ItemDataRole.UserRole) if song: self.danse_clicked.emit(song) return super().mousePressEvent(event) def mouseDoubleClickEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: item = self.itemAt(event.pos()) if item: # Dobbeltklik i højre 75px = Danse, ellers song_selected if event.pos().x() > self.viewport().width() - 75: song = item.data(Qt.ItemDataRole.UserRole) if song: self.danse_clicked.emit(song) return super().mouseDoubleClickEvent(event) def startDrag(self, supported_actions): item = self.currentItem() if not item: return song = item.data(Qt.ItemDataRole.UserRole) if not song: return import json data = json.dumps(song).encode("utf-8") mime = QMimeData() mime.setData("application/x-linedance-song", QByteArray(data)) mime.setText(song.get("title", "")) drag = QDrag(self) drag.setMimeData(mime) drag.exec(Qt.DropAction.CopyAction) class LibraryPanel(QWidget): song_selected = pyqtSignal(dict) add_to_playlist = pyqtSignal(dict) scan_requested = pyqtSignal() edit_tags_requested = pyqtSignal(dict) send_mail_requested = pyqtSignal(dict) def __init__(self, parent=None): super().__init__(parent) self._all_songs: list[dict] = [] self._filtered: list[dict] = [] self._bpm_scan_running = False self._search_timer = QTimer(self) self._search_timer.setSingleShot(True) self._search_timer.setInterval(150) self._search_timer.timeout.connect(self._do_search) self._build_ui() def _build_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # Header header = QHBoxLayout() header.setContentsMargins(10, 6, 10, 6) lbl = QLabel("BIBLIOTEK") lbl.setObjectName("section_title") header.addWidget(lbl) header.addStretch() btn_manage = QPushButton("⚙ Mapper") btn_manage.setFixedHeight(28) btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker") btn_manage.clicked.connect(self._manage_libraries) header.addWidget(btn_manage) layout.addLayout(header) # Søgefelt self._search = QLineEdit() self._search.setPlaceholderText("Søg i titel, artist, album, dans...") self._search.textChanged.connect(self._on_search_changed) layout.addWidget(self._search) # Resultat-tæller + drag-hint hint_row = QHBoxLayout() hint_row.setContentsMargins(8, 2, 8, 2) self._count_label = QLabel("0 sange") self._count_label.setObjectName("result_count") hint_row.addWidget(self._count_label) hint_row.addStretch() drag_hint = QLabel("træk til danseliste →") drag_hint.setObjectName("result_count") hint_row.addWidget(drag_hint) layout.addLayout(hint_row) # Liste — draggable self._list = DraggableLibraryList() self._list.setObjectName("library_list") self._list.itemDoubleClicked.connect(self._on_double_click) self._list.danse_clicked.connect(self.edit_tags_requested) self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self._list.customContextMenuRequested.connect(self._show_context_menu) self._list.setItemDelegate(DanseButtonDelegate(self._list)) layout.addWidget(self._list) # ── Scanning ────────────────────────────────────────────────────────────── def _on_scan_clicked(self): self.scan_requested.emit() def set_scanning(self, scanning: bool, status_text: str = ""): pass # Status vises i statuslinjen def update_scan_status(self, text: str): pass # Status vises i statuslinjen # ── Sange ───────────────────────────────────────────────────────────────── def load_songs(self, songs: list[dict]): self._all_songs = songs self._do_search() # ── Søgning ─────────────────────────────────────────────────────────────── def _on_search_changed(self): self._search_timer.start() def _do_search(self): q = self._search.text().strip().lower() self._filtered = [s for s in self._all_songs if self._matches(s, q)] if q else list(self._all_songs) total = len(self._all_songs) found = len(self._filtered) q_text = self._search.text().strip() self._count_label.setText( f"{found} resultat{'er' if found != 1 else ''} for \"{q_text}\"" if q_text else f"{total} sang{'e' if total != 1 else ''}" ) self._render() def _matches(self, song: dict, q: str) -> bool: return any(q in f.lower() for f in [ song.get("title", ""), song.get("artist", ""), song.get("album", ""), song.get("file_format", ""), ] + song.get("dances", [])) def _render(self): self._list.clear() q = self._search.text().strip().lower() for song in self._filtered: dances = song.get("dances", []) dance_levels = song.get("dance_levels", []) missing = song.get("file_missing", False) dance_parts = [] for i, d in enumerate(dances): lvl = dance_levels[i] if i < len(dance_levels) else "" dance_parts.append(f"{d} / {lvl}" if lvl else d) dance_str = " · " + " | ".join(dance_parts) if dance_parts else "" def _render(self): self._list.clear() q = self._search.text().strip().lower() for song in self._filtered: dances = song.get("dances", []) dance_levels = song.get("dance_levels", []) missing = song.get("file_missing", False) dance_parts = [] for i, d in enumerate(dances): lvl = dance_levels[i] if i < len(dance_levels) else "" dance_parts.append(f"{d} / {lvl}" if lvl else d) dance_str = " · " + " | ".join(dance_parts) if dance_parts else "" prefix = "⚠ " if missing else "" bpm = song.get("bpm", 0) bpm_str = f"{bpm} BPM" if bpm else "? BPM" line1 = prefix + song.get("title", "—") line2 = f" {song.get('artist','—')} · {bpm_str} · {song.get('file_format','').upper()}{dance_str}" item = QListWidgetItem(f"{line1}\n{line2}") item.setData(Qt.ItemDataRole.UserRole, song) item.setSizeHint(__import__('PyQt6.QtCore', fromlist=['QSize']).QSize(0, 52)) if missing: item.setForeground(QColor("#5a6070")) elif q and any(q in d.lower() for d in dances): item.setForeground(QColor("#e8a020")) self._list.addItem(item) def _start_bulk_bpm_scan(self): """Start BPM-analyse på alle sange uden BPM i baggrundstråd med lav prioritet.""" if self._bpm_scan_running: return songs_without_bpm = [s for s in self._all_songs if not s.get("bpm") and not s.get("file_missing")] if not songs_without_bpm: self._btn_bpm_scan.setText("♩ Alle har BPM") return self._bpm_scan_running = True self._btn_bpm_scan.setText(f"♩ Scanner 0/{len(songs_without_bpm)}...") self._btn_bpm_scan.setEnabled(False) from PyQt6.QtCore import QThread, pyqtSignal as _sig class BulkBpmWorker(QThread): progress = _sig(int, int, str) # done, total, title finished = _sig() def __init__(self, songs): super().__init__() self._songs = songs def run(self): from local.tag_reader import analyze_and_save_bpm total = len(self._songs) for i, song in enumerate(self._songs, start=1): if self.isInterruptionRequested(): break try: bpm = analyze_and_save_bpm(song["local_path"], song["id"]) if bpm: song["bpm"] = int(round(bpm)) except Exception: pass self.progress.emit(i, total, song.get("title", "")) self.finished.emit() self._bulk_bpm_worker = BulkBpmWorker(songs_without_bpm) def on_progress(done, total, title): self._btn_bpm_scan.setText(f"♩ {done}/{total}...") # Opdater sangen i listen for s in self._all_songs: if s.get("title") == title and s.get("bpm"): break self._do_search() def on_finished(): self._bpm_scan_running = False self._btn_bpm_scan.setEnabled(True) self._btn_bpm_scan.setText("♩ BPM alle") self._do_search() self._bulk_bpm_worker.progress.connect(on_progress) self._bulk_bpm_worker.finished.connect(on_finished) self._bulk_bpm_worker.start() self._bulk_bpm_worker.setPriority(QThread.Priority.LowestPriority) # ── Handlinger ──────────────────────────────────────────────────────────── def _on_double_click(self, item: QListWidgetItem): song = item.data(Qt.ItemDataRole.UserRole) if song: self.song_selected.emit(song) def _show_context_menu(self, pos): from PyQt6.QtWidgets import QMenu item = self._list.itemAt(pos) if not item: return song = item.data(Qt.ItemDataRole.UserRole) if not song: return menu = QMenu(self) act_add = menu.addAction("Tilføj til danseliste") act_play = menu.addAction("Afspil") menu.addSeparator() act_tags = menu.addAction("✎ Rediger dans-tags...") act_info = menu.addAction("ℹ Dans-info...") act_bpm = menu.addAction("♩ Analysér BPM") menu.addSeparator() send_menu = menu.addMenu("Send til") act_mail = send_menu.addAction("✉ Send som mail") action = menu.exec(self._list.mapToGlobal(pos)) if action == act_add: self.add_to_playlist.emit(song) elif action == act_play: self.song_selected.emit(song) elif action == act_tags: self.edit_tags_requested.emit(song) elif action == act_info: from ui.dance_info_dialog import DanceInfoDialog dlg = DanceInfoDialog(song, parent=self.window()) dlg.exec() elif action == act_bpm: self._analyze_bpm(song) elif action == act_mail: self.send_mail_requested.emit(song) def _analyze_bpm(self, song: dict): """Analysér BPM i baggrundstråd og opdater biblioteket.""" path = song.get("local_path", "") song_id = song.get("id", "") if not path or not song_id: return from PyQt6.QtCore import QThread, pyqtSignal as _sig class BpmWorker(QThread): done = _sig(float) def __init__(self, p, sid): super().__init__() self._p, self._sid = p, sid def run(self): from local.tag_reader import analyze_and_save_bpm bpm = analyze_and_save_bpm(self._p, self._sid) if bpm: self.done.emit(bpm) self._bpm_worker = BpmWorker(path, song_id) def on_bpm_done(bpm): # Opdater sangen i _all_songs listen direkte for s in self._all_songs: if s.get("id") == song_id: s["bpm"] = int(round(bpm)) break self._do_search() self._bpm_worker.done.connect(on_bpm_done) self._bpm_worker.start() def _manage_libraries(self): from ui.library_manager import LibraryManagerDialog dialog = LibraryManagerDialog(parent=self.window()) dialog.library_removed.connect(lambda _: self.scan_requested.emit()) dialog.exec() def _add_folder(self): from PyQt6.QtWidgets import QFileDialog folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe") if folder: mw = self.window() if hasattr(mw, "add_library_path"): mw.add_library_path(folder)