Files
LinedanceAfspiller/linedance-app/ui/library_panel.py

575 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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", [])
if incl_alt:
fields += song.get("alt_dances", [])
return any(q in f.lower() for f in fields)
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)