""" 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, QFrame, QSlider, QCheckBox, 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() sync_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_refresh = QPushButton("↻ Opdater") btn_refresh.setFixedHeight(28) btn_refresh.setToolTip("Opdater bibliotek fra database") btn_refresh.clicked.connect(self._refresh_library) header.addWidget(btn_refresh) 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 + checkbox search_row = QHBoxLayout() search_row.setSpacing(6) self._search = QLineEdit() self._search.setPlaceholderText("Søg i titel, artist, album, dans...") self._search.textChanged.connect(self._on_search_changed) search_row.addWidget(self._search) from PyQt6.QtWidgets import QCheckBox self._chk_alt = QCheckBox("Inkl. alt.") self._chk_alt.setToolTip("Søg også i alternativ-danse") self._chk_alt.setChecked(False) self._chk_alt.toggled.connect(self._on_search_changed) search_row.addWidget(self._chk_alt) layout.addLayout(search_row) # 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) # Preview-afspiller bar self._preview_bar = self._build_preview_bar() self._preview_bar.setVisible(False) layout.addWidget(self._preview_bar) # Timer til preview progress opdatering self._preview_timer = QTimer(self) self._preview_timer.setInterval(200) self._preview_timer.timeout.connect(self._update_preview_progress) def _build_preview_bar(self) -> QWidget: bar = QFrame() bar.setObjectName("track_display") bar.setFixedHeight(62) row = QHBoxLayout(bar) row.setContentsMargins(10, 8, 10, 8) row.setSpacing(10) # Play/pause knap — orange som hoved-afspiller self._btn_preview_play = QPushButton("▶") self._btn_preview_play.setFixedSize(36, 36) self._btn_preview_play.setObjectName("btn_play_small") self._btn_preview_play.setToolTip("Afspil / Pause") self._btn_preview_play.clicked.connect(self._toggle_preview_playback) row.addWidget(self._btn_preview_play) # Stop knap self._btn_preview_stop = QPushButton("■") self._btn_preview_stop.setFixedSize(30, 30) self._btn_preview_stop.setObjectName("btn_stop_small") self._btn_preview_stop.setToolTip("Stop preview") self._btn_preview_stop.clicked.connect(self._stop_preview) row.addWidget(self._btn_preview_stop) # Titel + progress i midten info = QVBoxLayout() info.setSpacing(4) info.setContentsMargins(0, 0, 0, 0) self._lbl_preview_title = QLabel("—") self._lbl_preview_title.setObjectName("track_meta") info.addWidget(self._lbl_preview_title) self._preview_progress = QSlider(Qt.Orientation.Horizontal) self._preview_progress.setRange(0, 1000) self._preview_progress.sliderMoved.connect(self._seek_preview) info.addWidget(self._preview_progress) row.addLayout(info, stretch=1) # Tid self._lbl_preview_time = QLabel("0:00") self._lbl_preview_time.setObjectName("track_meta") self._lbl_preview_time.setFixedWidth(70) self._lbl_preview_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) row.addWidget(self._lbl_preview_time) # Volumen self._preview_vol = QSlider(Qt.Orientation.Horizontal) self._preview_vol.setRange(0, 100) self._preview_vol.setValue(78) self._preview_vol.setFixedWidth(70) self._preview_vol.setToolTip("Volumen preview") self._preview_vol.valueChanged.connect(self._on_preview_volume) row.addWidget(self._preview_vol) return bar # ── Scanning ────────────────────────────────────────────────────────────── def _on_scan_clicked(self): self.scan_requested.emit() def _on_sync_clicked(self): self._btn_sync.setText("⇅ ...") self._btn_sync.setEnabled(False) self.sync_requested.emit() QTimer.singleShot(3000, lambda: ( self._btn_sync.setText("⇅ Sync"), self._btn_sync.setEnabled(True), )) 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() incl_alt = self._chk_alt.isChecked() self._filtered = [ s for s in self._all_songs if self._matches(s, q, incl_alt) ] 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, incl_alt: bool = False) -> bool: fields = [ song.get("title", ""), song.get("artist", ""), song.get("album", ""), song.get("file_format", ""), ] + song.get("dances", []) \ + song.get("dance_choreographers", []) \ + song.get("dance_levels", []) if incl_alt: fields += song.get("alt_dances", []) return any(q in f.lower() for f in fields if f) 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 _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") # Preview kun hvis preview player er sat op is_previewing = ( hasattr(self, "_preview_player") and self._preview_player and self._preview_player.is_playing() ) act_preview = menu.addAction( "⏹ Stop preview" if is_previewing else "▶ Preview (høretelefoner)" ) 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_preview: self._toggle_preview(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 set_preview_player(self, preview_player): """Sæt preview-afspilleren fra main_window.""" self._preview_player = preview_player def _on_double_click(self, item: QListWidgetItem): song = item.data(Qt.ItemDataRole.UserRole) if song: self._start_preview(song) def _start_preview(self, song: dict): if not hasattr(self, "_preview_player") or not self._preview_player: self.song_selected.emit(song) return path = song.get("local_path", "") if not path: return self._preview_song = song self._preview_player.play(path) title = song.get("title", "—") artist = song.get("artist", "") self._lbl_preview_title.setText(f"{title} · {artist}") self._btn_preview_play.setText("⏸") self._preview_bar.setVisible(True) self._preview_timer.start() def _toggle_preview_playback(self): if not hasattr(self, "_preview_player") or not self._preview_player: return if self._preview_player.is_playing(): self._preview_player.pause() self._btn_preview_play.setText("▶") else: self._preview_player.resume() self._btn_preview_play.setText("⏸") def _stop_preview(self): if hasattr(self, "_preview_player") and self._preview_player: self._preview_player.stop() self._preview_timer.stop() self._preview_bar.setVisible(False) self._btn_preview_play.setText("▶") def _seek_preview(self, value: int): if hasattr(self, "_preview_player") and self._preview_player: self._preview_player.seek(value / 1000.0) def _on_preview_volume(self, value: int): if hasattr(self, "_preview_player") and self._preview_player: self._preview_player.set_volume(value) def _update_preview_progress(self): if not hasattr(self, "_preview_player") or not self._preview_player: return if not self._preview_player.is_playing(): self._btn_preview_play.setText("▶") return pos = self._preview_player.get_position() cur = self._preview_player.get_time() dur = self._preview_player.get_duration() self._preview_progress.setValue(int(pos * 1000)) def fmt(s): return f"{s//60}:{s%60:02d}" self._lbl_preview_time.setText(f"{fmt(cur)} / {fmt(dur)}") def _toggle_preview(self, song: dict): """Start/stop preview af en sang.""" if not hasattr(self, "_preview_player") or not self._preview_player: return if self._preview_player.is_playing(): self._stop_preview() else: self._start_preview(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 _refresh_library(self): """Genindlæs bibliotek fra database.""" mw = self.window() if hasattr(mw, "_reload_library"): mw._reload_library() def _manage_libraries(self): from ui.library_manager import LibraryManagerDialog from local.local_db import DB_PATH dialog = LibraryManagerDialog(db_path=str(DB_PATH), parent=self.window()) dialog.libraries_changed.connect(self._on_libraries_changed) dialog.exec() # Reload øjeblikkeligt når dialog lukkes mw = self.window() if hasattr(mw, "_reload_library"): mw._reload_library() # Start scanning if hasattr(mw, "start_background_scan"): QTimer.singleShot(1000, mw.start_background_scan) def _on_libraries_changed(self): """Kaldes ved tilføj/fjern — reload øjeblikkeligt.""" mw = self.window() if hasattr(mw, "_reload_library"): mw._reload_library() 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)