Start
This commit is contained in:
364
linedance-app/ui/library_panel.py
Normal file
364
linedance-app/ui/library_panel.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
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, QProgressBar,
|
||||
QAbstractItemView,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray
|
||||
from PyQt6.QtGui import QColor, QDrag
|
||||
|
||||
|
||||
class DraggableLibraryList(QListWidget):
|
||||
"""QListWidget der understøtter drag-start med sang-data som mime."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setDragEnabled(True)
|
||||
self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly)
|
||||
self.setDefaultDropAction(Qt.DropAction.CopyAction)
|
||||
|
||||
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()
|
||||
|
||||
self._btn_bpm_scan = QPushButton("♩ BPM alle")
|
||||
self._btn_bpm_scan.setFixedHeight(24)
|
||||
self._btn_bpm_scan.setToolTip("Analysér BPM på alle sange uden BPM (kører i baggrunden)")
|
||||
self._btn_bpm_scan.clicked.connect(self._start_bulk_bpm_scan)
|
||||
header.addWidget(self._btn_bpm_scan)
|
||||
|
||||
btn_manage = QPushButton("⚙ Mapper")
|
||||
btn_manage.setFixedHeight(24)
|
||||
btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker")
|
||||
btn_manage.clicked.connect(self._manage_libraries)
|
||||
header.addWidget(btn_manage)
|
||||
layout.addLayout(header)
|
||||
|
||||
# Scan status
|
||||
self._scan_bar = QProgressBar()
|
||||
self._scan_bar.setObjectName("scan_bar")
|
||||
self._scan_bar.setTextVisible(True)
|
||||
self._scan_bar.setFormat("Scanner...")
|
||||
self._scan_bar.setFixedHeight(16)
|
||||
self._scan_bar.setRange(0, 0)
|
||||
self._scan_bar.hide()
|
||||
layout.addWidget(self._scan_bar)
|
||||
|
||||
self._scan_label = QLabel("")
|
||||
self._scan_label.setObjectName("result_count")
|
||||
self._scan_label.hide()
|
||||
layout.addWidget(self._scan_label)
|
||||
|
||||
# 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.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self._list.customContextMenuRequested.connect(self._show_context_menu)
|
||||
layout.addWidget(self._list)
|
||||
|
||||
# ── Scanning ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_scan_clicked(self):
|
||||
self.scan_requested.emit()
|
||||
|
||||
def set_scanning(self, scanning: bool, status_text: str = ""):
|
||||
if scanning:
|
||||
self._scan_bar.show()
|
||||
self._scan_label.setText(status_text or "Starter...")
|
||||
self._scan_label.show()
|
||||
else:
|
||||
self._scan_bar.hide()
|
||||
self._scan_label.hide()
|
||||
|
||||
def update_scan_status(self, text: str):
|
||||
self._scan_label.setText(text)
|
||||
|
||||
# ── 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 ""
|
||||
|
||||
line1 = ("⚠ " if missing else "") + song.get("title", "—")
|
||||
bpm = song.get("bpm", 0)
|
||||
bpm_str = f"{bpm} BPM" if bpm else "? BPM"
|
||||
line2 = f" {song.get('artist','—')} · {bpm_str} · {song.get('file_format','').upper()}{dance_str}"
|
||||
|
||||
row_widget = QWidget()
|
||||
row_widget.setStyleSheet("background: transparent;")
|
||||
row_layout = QHBoxLayout(row_widget)
|
||||
row_layout.setContentsMargins(2, 2, 2, 2)
|
||||
row_layout.setSpacing(8)
|
||||
|
||||
lbl = QLabel(f"{line1}\n{line2}")
|
||||
lbl.setWordWrap(False)
|
||||
row_layout.addWidget(lbl, stretch=1)
|
||||
|
||||
btn_danse = QPushButton("Danse")
|
||||
btn_danse.setFixedHeight(30)
|
||||
btn_danse.setFixedWidth(70)
|
||||
btn_danse.setToolTip("Rediger dans-tags")
|
||||
btn_danse.setStyleSheet(
|
||||
"QPushButton { background: #e8a020; color: #111; border-radius: 4px; "
|
||||
"font-weight: bold; font-size: 12px; border: none; }"
|
||||
"QPushButton:hover { background: #f0b030; }"
|
||||
)
|
||||
btn_danse.clicked.connect(lambda _, s=song: self.edit_tags_requested.emit(s))
|
||||
row_layout.addWidget(btn_danse)
|
||||
|
||||
item = QListWidgetItem()
|
||||
item.setData(Qt.ItemDataRole.UserRole, song)
|
||||
row_widget.adjustSize()
|
||||
hint = row_widget.sizeHint()
|
||||
hint.setHeight(max(hint.height(), 52))
|
||||
item.setSizeHint(hint)
|
||||
self._list.addItem(item)
|
||||
self._list.setItemWidget(item, row_widget)
|
||||
|
||||
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_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_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)
|
||||
Reference in New Issue
Block a user