This commit is contained in:
2026-04-09 21:54:18 +02:00
commit ad33255b88
8906 changed files with 1437726 additions and 0 deletions

0
ui/__init__.py Normal file
View File

221
ui/library_panel.py Normal file
View File

@@ -0,0 +1,221 @@
"""
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()
def __init__(self, parent=None):
super().__init__(parent)
self._all_songs: list[dict] = []
self._filtered: list[dict] = []
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_scan = QPushButton("⟳ SCAN")
self._btn_scan.setFixedHeight(24)
self._btn_scan.setToolTip("Scan alle biblioteksmapper for nye og ændrede filer")
self._btn_scan.clicked.connect(self._on_scan_clicked)
header.addWidget(self._btn_scan)
btn_add = QPushButton("+ MAPPE")
btn_add.setFixedHeight(24)
btn_add.clicked.connect(self._add_folder)
header.addWidget(btn_add)
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._btn_scan.setEnabled(False)
self._btn_scan.setText("⟳ SCANNER...")
self._scan_bar.show()
self._scan_label.setText(status_text or "Starter...")
self._scan_label.show()
else:
self._btn_scan.setEnabled(True)
self._btn_scan.setText("⟳ SCAN")
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_str = " · " + " / ".join(dances) if dances else ""
missing = song.get("file_missing", False)
line1 = ("" if missing else "") + song.get("title", "")
line2 = f" {song.get('artist','')} · {song.get('bpm',0)} BPM · {song.get('file_format','').upper()}{dance_str}"
item = QListWidgetItem(f"{line1}\n{line2}")
item.setData(Qt.ItemDataRole.UserRole, song)
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)
# ── 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")
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)
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)

139
ui/login_dialog.py Normal file
View File

@@ -0,0 +1,139 @@
"""
login_dialog.py — Login-dialog til at gå online.
Server-URL er hardcodet i config.
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QFrame, QCheckBox,
)
from PyQt6.QtCore import Qt, QSettings
# ── Hardcodet server-URL ──────────────────────────────────────────────────────
API_URL = "http://din-server:8000"
# ─────────────────────────────────────────────────────────────────────────────
class LoginDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Gå online")
self.setFixedWidth(340)
self.setModal(True)
self._token: str | None = None
self._username: str | None = None
self._api_url = API_URL
self._build_ui()
self._load_saved_settings()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(10)
layout.setContentsMargins(20, 20, 20, 20)
title = QLabel("Log ind på LineDance")
title.setObjectName("track_title")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title)
sub = QLabel("Synkroniser projekter og alternativ-danse med andre brugere")
sub.setObjectName("track_meta")
sub.setWordWrap(True)
sub.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(sub)
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
layout.addWidget(line)
layout.addWidget(QLabel("Brugernavn:"))
self._user_input = QLineEdit()
self._user_input.setPlaceholderText("dit-brugernavn")
layout.addWidget(self._user_input)
layout.addWidget(QLabel("Kodeord:"))
self._pass_input = QLineEdit()
self._pass_input.setEchoMode(QLineEdit.EchoMode.Password)
self._pass_input.setPlaceholderText("••••••••")
self._pass_input.returnPressed.connect(self._on_login)
layout.addWidget(self._pass_input)
self._remember = QCheckBox("Husk brugernavn")
self._remember.setChecked(True)
layout.addWidget(self._remember)
self._status_label = QLabel("")
self._status_label.setObjectName("track_meta")
self._status_label.setWordWrap(True)
layout.addWidget(self._status_label)
btn_row = QHBoxLayout()
btn_cancel = QPushButton("Annuller")
btn_cancel.clicked.connect(self.reject)
btn_row.addWidget(btn_cancel)
self._btn_login = QPushButton("Log ind")
self._btn_login.setObjectName("btn_play")
self._btn_login.setDefault(True)
self._btn_login.clicked.connect(self._on_login)
btn_row.addWidget(self._btn_login)
layout.addLayout(btn_row)
def _load_saved_settings(self):
settings = QSettings("LineDance", "Player")
self._user_input.setText(settings.value("username", ""))
def _save_settings(self):
if self._remember.isChecked():
settings = QSettings("LineDance", "Player")
settings.setValue("username", self._user_input.text().strip())
def _on_login(self):
username = self._user_input.text().strip()
password = self._pass_input.text()
if not username or not password:
self._set_status("Udfyld brugernavn og kodeord", error=True)
return
self._btn_login.setEnabled(False)
self._set_status("Forbinder...")
try:
import urllib.request, urllib.parse, json
data = urllib.parse.urlencode({
"username": username,
"password": password,
}).encode()
req = urllib.request.Request(
f"{API_URL}/auth/login",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with urllib.request.urlopen(req, timeout=8) as resp:
body = json.loads(resp.read())
self._token = body.get("access_token")
self._username = username
self._save_settings()
self._set_status("Logget ind!", error=False)
self.accept()
except Exception as e:
self._set_status(f"Fejl: {e}", error=True)
self._btn_login.setEnabled(True)
def _set_status(self, text: str, error: bool = False):
self._status_label.setText(text)
color = "#e74c3c" if error else "#2ecc71"
self._status_label.setStyleSheet(f"color: {color};")
def get_credentials(self) -> tuple[str, str, str]:
"""Returnerer (api_url, username, token) efter succesfuldt login."""
return self._api_url, self._username, self._token

683
ui/main_window.py Normal file
View File

@@ -0,0 +1,683 @@
"""
main_window.py — Linedance afspiller hovedvindue.
"""
from PyQt6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QSlider, QLabel, QFrame, QSplitter,
QSizePolicy, QMenuBar, QMenu, QStatusBar, QFileDialog,
QMessageBox,
)
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QAction
from ui.vu_meter import VUMeter
from ui.playlist_panel import PlaylistPanel
from ui.library_panel import LibraryPanel
from ui.next_up_bar import NextUpBar
from ui.themes import apply_theme
from ui.scan_worker import ScanWorker
from ui.login_dialog import LoginDialog
from ui.playlist_manager import PlaylistManagerDialog
from player.player import Player
class ProgressBar(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._fraction = 0.0
self._demo_fraction = 0.0
self.setFixedHeight(10)
self.setCursor(Qt.CursorShape.PointingHandCursor)
def set_fraction(self, f: float):
self._fraction = max(0.0, min(1.0, f))
self.update()
def set_demo_marker(self, f: float):
self._demo_fraction = max(0.0, min(1.0, f))
self.update()
def paintEvent(self, event):
from PyQt6.QtGui import QPainter, QColor
p = QPainter(self)
w, h = self.width(), self.height()
p.fillRect(0, 0, w, h, QColor("#2c3038"))
fill_w = int(w * self._fraction)
if fill_w > 0:
p.fillRect(0, 0, fill_w, h, QColor("#e8a020"))
if self._demo_fraction > 0:
mx = int(w * self._demo_fraction)
p.fillRect(mx - 1, 0, 2, h, QColor("#3b8fd4"))
p.end()
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
fraction = event.position().x() / self.width()
mw = self.window()
if hasattr(mw, "_on_seek"):
mw._on_seek(fraction)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("LineDance Player")
self.setMinimumSize(860, 680)
self.resize(960, 760)
self._dark_theme = True
self._player = Player(self)
self._current_idx = -1
self._song_ended = False
self._demo_active = False
self._watcher = None
self._scan_worker = None
self._api_url: str | None = None
self._api_token: str | None = None
self._api_username: str | None = None
self._connect_player_signals()
self._build_menu()
self._build_ui()
self._build_statusbar()
apply_theme(self._app_ref(), dark=True)
# Start DB og scanning ved opstart
QTimer.singleShot(200, self._init_local_db)
def _app_ref(self):
from PyQt6.QtWidgets import QApplication
return QApplication.instance()
def _connect_player_signals(self):
self._player.position_changed.connect(self._on_position)
self._player.time_changed.connect(self._on_time)
self._player.levels_changed.connect(self._on_levels)
self._player.song_ended.connect(self._on_song_ended)
self._player.state_changed.connect(self._on_state_changed)
# ── Menu ──────────────────────────────────────────────────────────────────
def _build_menu(self):
menubar = self.menuBar()
# Filer
file_menu = menubar.addMenu("Filer")
file_menu.addSeparator()
self._act_go_online = QAction("Gå online...", self)
self._act_go_online.setShortcut("Ctrl+L")
self._act_go_online.setToolTip("Log ind og synkroniser med server")
self._act_go_online.triggered.connect(self._go_online)
file_menu.addAction(self._act_go_online)
self._act_go_offline = QAction("Gå offline", self)
self._act_go_offline.setToolTip("Log ud og arbejd lokalt")
self._act_go_offline.triggered.connect(self._go_offline)
self._act_go_offline.setEnabled(False) # kun aktiv når man er online
file_menu.addAction(self._act_go_offline)
file_menu.addSeparator()
act_add_folder = QAction("Tilføj musikmappe...", self)
act_add_folder.setShortcut("Ctrl+O")
act_add_folder.triggered.connect(self._menu_add_folder)
file_menu.addAction(act_add_folder)
file_menu.addSeparator()
act_scan = QAction("Scan biblioteker", self)
act_scan.setShortcut("Ctrl+R")
act_scan.setToolTip("Gennemgå alle biblioteksmapper for nye og ændrede filer")
act_scan.triggered.connect(self.start_scan)
file_menu.addAction(act_scan)
self._act_scan = act_scan
file_menu.addSeparator()
act_quit = QAction("Afslut", self)
act_quit.setShortcut("Ctrl+Q")
act_quit.triggered.connect(self.close)
file_menu.addAction(act_quit)
# Danseliste
pl_menu = menubar.addMenu("Danseliste")
act_new_pl = QAction("Ny tom liste", self)
act_new_pl.setShortcut("Ctrl+N")
act_new_pl.triggered.connect(self._new_playlist)
pl_menu.addAction(act_new_pl)
act_manage = QAction("Gem / Indlæs / Importer...", self)
act_manage.setShortcut("Ctrl+M")
act_manage.triggered.connect(self._open_playlist_manager)
pl_menu.addAction(act_manage)
# Visning
view_menu = menubar.addMenu("Visning")
act_theme = QAction("Skift tema (lyst/mørkt)", self)
act_theme.setShortcut("Ctrl+T")
act_theme.triggered.connect(self._toggle_theme)
view_menu.addAction(act_theme)
# ── Statuslinje ───────────────────────────────────────────────────────────
def _build_statusbar(self):
self._statusbar = QStatusBar()
self.setStatusBar(self._statusbar)
self._statusbar.showMessage("Klar")
def _set_status(self, text: str, timeout_ms: int = 0):
"""Vis besked i statuslinjen. timeout_ms=0 = permanent."""
self._statusbar.showMessage(text, timeout_ms)
# ── UI byggeri ────────────────────────────────────────────────────────────
def _build_ui(self):
root = QWidget()
root.setObjectName("root")
self.setCentralWidget(root)
main_layout = QVBoxLayout(root)
main_layout.setContentsMargins(10, 6, 10, 10)
main_layout.setSpacing(4)
main_layout.addWidget(self._build_topbar())
main_layout.addWidget(self._build_now_playing())
main_layout.addWidget(self._build_progress())
main_layout.addWidget(self._build_next_up())
main_layout.addWidget(self._build_transport())
main_layout.addWidget(self._build_panels(), stretch=1)
def _build_topbar(self) -> QFrame:
bar = QFrame()
bar.setObjectName("topbar")
layout = QHBoxLayout(bar)
layout.setContentsMargins(12, 6, 12, 6)
logo = QLabel("LINE<span style='color:#9aa0b0;font-weight:400'>DANCE</span> PLAYER")
logo.setObjectName("logo")
logo.setTextFormat(Qt.TextFormat.RichText)
layout.addWidget(logo)
layout.addStretch()
self._conn_label = QLabel("● OFFLINE")
self._conn_label.setObjectName("conn_label")
layout.addWidget(self._conn_label)
self._theme_btn = QPushButton("☀ LYS TEMA")
self._theme_btn.setFixedHeight(26)
self._theme_btn.clicked.connect(self._toggle_theme)
layout.addWidget(self._theme_btn)
return bar
def _build_now_playing(self) -> QFrame:
frame = QFrame()
frame.setObjectName("now_playing_frame")
layout = QHBoxLayout(frame)
layout.setContentsMargins(12, 10, 12, 10)
track_frame = QFrame()
track_frame.setObjectName("track_display")
track_layout = QVBoxLayout(track_frame)
track_layout.setContentsMargins(10, 8, 10, 8)
track_layout.setSpacing(3)
self._lbl_title = QLabel("")
self._lbl_title.setObjectName("track_title")
track_layout.addWidget(self._lbl_title)
self._lbl_meta = QLabel("")
self._lbl_meta.setObjectName("track_meta")
track_layout.addWidget(self._lbl_meta)
self._lbl_dances = QLabel("")
self._lbl_dances.setObjectName("track_meta")
self._lbl_dances.setWordWrap(True)
track_layout.addWidget(self._lbl_dances)
layout.addWidget(track_frame, stretch=1)
self._vu = VUMeter()
layout.addWidget(self._vu)
return frame
def _build_progress(self) -> QFrame:
frame = QFrame()
frame.setObjectName("progress_frame")
layout = QHBoxLayout(frame)
layout.setContentsMargins(12, 6, 12, 6)
layout.setSpacing(8)
self._lbl_cur = QLabel("0:00")
self._lbl_cur.setObjectName("track_meta")
self._lbl_cur.setFixedWidth(36)
layout.addWidget(self._lbl_cur)
self._progress = ProgressBar(self)
self._progress.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
layout.addWidget(self._progress, stretch=1)
self._lbl_tot = QLabel("0:00")
self._lbl_tot.setObjectName("track_meta")
self._lbl_tot.setFixedWidth(36)
self._lbl_tot.setAlignment(Qt.AlignmentFlag.AlignRight)
layout.addWidget(self._lbl_tot)
return frame
def _build_next_up(self) -> NextUpBar:
self._next_up = NextUpBar()
self._next_up.play_next_clicked.connect(self._play_next)
return self._next_up
def _build_transport(self) -> QFrame:
frame = QFrame()
frame.setObjectName("transport_frame")
layout = QHBoxLayout(frame)
layout.setContentsMargins(14, 10, 14, 10)
layout.setSpacing(8)
def btn(text, name=None, size=52, checkable=False):
b = QPushButton(text)
if name:
b.setObjectName(name)
b.setFixedSize(size, size)
if checkable:
b.setCheckable(True)
return b
self._btn_prev = btn("", size=52)
self._btn_play = btn("", "btn_play", size=72)
self._btn_stop = btn("", "btn_stop", size=52)
self._btn_next = btn("", size=52)
self._btn_demo = btn("\n10 SEK", "btn_demo", size=64, checkable=True)
self._btn_prev.clicked.connect(self._prev_song)
self._btn_play.clicked.connect(self._toggle_play)
self._btn_stop.clicked.connect(self._stop)
self._btn_next.clicked.connect(self._next_song)
self._btn_demo.clicked.connect(self._toggle_demo)
layout.addWidget(self._btn_prev)
layout.addWidget(self._btn_play)
layout.addWidget(self._btn_stop)
layout.addWidget(self._btn_next)
sep1 = QFrame()
sep1.setFrameShape(QFrame.Shape.VLine)
sep1.setFixedWidth(1)
layout.addWidget(sep1)
layout.addWidget(self._btn_demo)
layout.addStretch()
lbl_vol = QLabel("VOL")
lbl_vol.setObjectName("vol_label")
layout.addWidget(lbl_vol)
self._vol_slider = QSlider(Qt.Orientation.Horizontal)
self._vol_slider.setRange(0, 100)
self._vol_slider.setValue(78)
self._vol_slider.setFixedWidth(100)
self._vol_slider.valueChanged.connect(self._on_volume)
layout.addWidget(self._vol_slider)
self._lbl_vol = QLabel("78")
self._lbl_vol.setObjectName("vol_val")
layout.addWidget(self._lbl_vol)
return frame
def _build_panels(self) -> QSplitter:
splitter = QSplitter(Qt.Orientation.Horizontal)
self._playlist_panel = PlaylistPanel()
self._playlist_panel.song_selected.connect(self._load_song_by_idx)
self._playlist_panel.song_dropped.connect(self._on_song_dropped)
self._library_panel = LibraryPanel()
self._library_panel.song_selected.connect(self._on_library_song_selected)
self._library_panel.add_to_playlist.connect(self._add_song_to_playlist)
self._library_panel.scan_requested.connect(self.start_scan)
splitter.addWidget(self._playlist_panel)
splitter.addWidget(self._library_panel)
splitter.setSizes([480, 480])
return splitter
# ── Lokal DB + scanning ───────────────────────────────────────────────────
def _init_local_db(self):
try:
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from local.local_db import init_db
from local.file_watcher import get_watcher
init_db()
def on_file_change(event_type, path, song_id):
QTimer.singleShot(500, self._reload_library)
self._watcher = get_watcher(on_change=on_file_change)
self._watcher.start()
# Indlæs hvad vi allerede kender fra SQLite
self._reload_library()
# Kør automatisk scanning ved opstart
self._set_status("Starter scanning af biblioteker...")
QTimer.singleShot(100, self.start_scan)
except Exception as e:
self._set_status(f"DB fejl: {e}")
print(f"DB init fejl: {e}")
def start_scan(self):
"""Start fuld scanning af alle biblioteker i baggrundstråd."""
if self._scan_worker and self._scan_worker.isRunning():
return # Scanning kører allerede
if not self._watcher:
self._set_status("Ingen biblioteker at scanne — tilføj en mappe først")
return
self._library_panel.set_scanning(True, "Forbereder scanning...")
self._act_scan.setEnabled(False)
self._scan_worker = ScanWorker(self._watcher, parent=self)
self._scan_worker.status_update.connect(self._on_scan_status)
self._scan_worker.scan_done.connect(self._on_scan_done)
self._scan_worker.start()
def _on_scan_status(self, text: str):
self._set_status(text)
self._library_panel.update_scan_status(text)
def _on_scan_done(self, count: int):
self._library_panel.set_scanning(False)
self._act_scan.setEnabled(True)
msg = f"Scanning færdig — {count} filer gennemgået"
self._set_status(msg, timeout_ms=5000)
# Genindlæs biblioteket
QTimer.singleShot(200, self._reload_library)
def _reload_library(self):
try:
from local.local_db import search_songs, get_db
songs_raw = search_songs("", limit=5000)
songs = []
for row in songs_raw:
with get_db() as conn:
dances = conn.execute(
"SELECT dance_name FROM song_dances "
"WHERE song_id=? ORDER BY dance_order",
(row["id"],)
).fetchall()
songs.append({
"id": row["id"],
"title": row["title"],
"artist": row["artist"],
"album": row["album"],
"bpm": row["bpm"],
"duration_sec": row["duration_sec"],
"local_path": row["local_path"],
"file_format": row["file_format"],
"file_missing": bool(row["file_missing"]),
"dances": [d["dance_name"] for d in dances],
})
self._library_panel.load_songs(songs)
count = len(songs)
self._set_status(f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000)
except Exception as e:
print(f"Bibliotek reload fejl: {e}")
def add_library_path(self, path: str):
try:
self._watcher.add_library(path)
self._set_status(f"Tilføjet: {path} — scanner...")
except Exception as e:
self._set_status(f"Fejl: {e}")
def _go_online(self):
dialog = LoginDialog(self)
if dialog.exec():
url, username, token = dialog.get_credentials()
self._api_url = url
self._api_token = token
self._api_username = username
self._set_online_state(True)
self._set_status(f"Online som {username}", 5000)
def _go_offline(self):
self._api_url = self._api_token = self._api_username = None
self._set_online_state(False)
self._set_status("Offline — arbejder lokalt", 3000)
def _set_online_state(self, online: bool):
self._act_go_online.setEnabled(not online)
self._act_go_offline.setEnabled(online)
if online:
name = self._api_username or "?"
self._conn_label.setText(f"● ONLINE ({name})")
self._conn_label.setStyleSheet("color: #2ecc71;")
else:
self._conn_label.setText("● OFFLINE")
self._conn_label.setStyleSheet("color: #5a6070;")
def _new_playlist(self):
self._stop()
self._playlist_panel.load_songs([])
self._playlist_panel.set_playlist_name("Ny liste")
self._set_status("Ny danseliste oprettet", 2000)
def _open_playlist_manager(self):
dialog = PlaylistManagerDialog(
current_songs=self._playlist_panel.get_songs(),
parent=self,
)
dialog.playlist_loaded.connect(self._on_playlist_loaded)
dialog.exec()
def _on_playlist_loaded(self, name: str, songs: list[dict]):
self._stop()
self._playlist_panel.load_songs(songs)
self._playlist_panel.set_playlist_name(name)
self._set_status(f"Indlæst: {name} ({len(songs)} sange)", 3000)
def _on_song_dropped(self, song: dict):
self._set_status(f"Tilføjet: {song.get('title','')}", 2000)
def _menu_add_folder(self):
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
if folder:
self.add_library_path(folder)
# ── Afspilning ────────────────────────────────────────────────────────────
def _load_song(self, song: dict):
self._current_song = song
self._song_ended = False
self._demo_active = False
self._btn_demo.setChecked(False)
self._next_up.hide_bar()
dur = song.get("duration_sec", 0)
self._player.load(song.get("local_path", ""), dur)
self._lbl_title.setText(song.get("title", ""))
bpm = song.get("bpm", 0)
fmt_dur = f"{dur//60}:{dur%60:02d}"
self._lbl_meta.setText(f"{song.get('artist','')} · {bpm} BPM · {fmt_dur}")
dances = song.get("dances", [])
self._lbl_dances.setText(
" · ".join(f"[{d}]" for d in dances) if dances else "ingen danse tagget"
)
if dur > 0:
self._progress.set_demo_marker(min(10 / dur, 1.0))
self._set_status(f"Indlæst: {song.get('title','')}", 3000)
def _load_song_by_idx(self, idx: int):
song = self._playlist_panel.get_song(idx)
if not song:
return
self._current_idx = idx
self._load_song(song)
self._playlist_panel.set_current(idx)
def _toggle_play(self):
if self._song_ended:
self._play_next()
return
if self._demo_active:
self._player.stop()
self._demo_active = False
self._btn_demo.setChecked(False)
self._btn_play.setText("")
return
if self._player.is_playing():
self._player.pause()
else:
self._player.play()
def _stop(self):
self._player.stop()
self._song_ended = False
self._demo_active = False
self._btn_demo.setChecked(False)
self._next_up.hide_bar()
self._btn_play.setText("")
self._vu.reset()
def _toggle_demo(self):
if self._demo_active:
self._player.stop()
self._demo_active = False
self._btn_demo.setChecked(False)
self._btn_play.setText("")
else:
self._demo_active = True
self._btn_demo.setChecked(True)
self._player.play_demo(stop_at_sec=10)
self._btn_play.setText("")
def _prev_song(self):
if self._current_idx > 0:
self._stop()
self._load_song_by_idx(self._current_idx - 1)
def _next_song(self):
if self._current_idx < self._playlist_panel.count() - 1:
self._stop()
self._playlist_panel.mark_played(self._current_idx)
self._load_song_by_idx(self._current_idx + 1)
def _play_next(self):
ni = self._current_idx + 1
if ni < self._playlist_panel.count():
self._song_ended = False
self._next_up.hide_bar()
self._load_song_by_idx(ni)
self._player.play()
self._btn_play.setText("")
def _on_library_song_selected(self, song: dict):
self._load_song(song)
self._player.play()
self._btn_play.setText("")
def _add_song_to_playlist(self, song: dict):
songs = [self._playlist_panel.get_song(i)
for i in range(self._playlist_panel.count())]
songs = [s for s in songs if s]
songs.append(song)
self._playlist_panel.load_songs(songs)
self._set_status(f"Tilføjet til danseliste: {song.get('title','')}", 2000)
# ── Player signals ────────────────────────────────────────────────────────
def _on_position(self, fraction: float):
self._progress.set_fraction(fraction)
def _on_time(self, cur: int, tot: int):
self._lbl_cur.setText(f"{cur//60}:{cur%60:02d}")
self._lbl_tot.setText(f"{tot//60}:{tot%60:02d}")
def _on_levels(self, left: float, right: float):
self._vu.set_levels(left, right)
def _on_song_ended(self):
self._song_ended = True
self._demo_active = False
self._btn_demo.setChecked(False)
self._btn_play.setText("")
self._vu.reset()
self._playlist_panel.mark_played(self._current_idx)
ni = self._current_idx + 1
next_song = self._playlist_panel.get_song(ni)
if next_song:
self._next_up.show_next(
next_song.get("title", ""),
next_song.get("artist", ""),
next_song.get("dances", []),
)
self._playlist_panel.set_current(self._current_idx, song_ended=True)
else:
self._lbl_title.setText("— Danseliste afsluttet —")
self._set_status("Danselisten er afsluttet")
def _on_state_changed(self, state: str):
if state == "playing":
self._btn_play.setText("")
elif state in ("paused", "stopped"):
self._btn_play.setText("")
if state == "stopped" and not self._song_ended:
self._vu.reset()
elif state == "demo_ended":
self._demo_active = False
self._btn_demo.setChecked(False)
self._btn_play.setText("")
self._vu.reset()
def _on_seek(self, fraction: float):
self._player.set_position(fraction)
def _on_volume(self, value: int):
self._lbl_vol.setText(str(value))
self._player.set_volume(value)
# ── Tema ──────────────────────────────────────────────────────────────────
def _toggle_theme(self):
self._dark_theme = not self._dark_theme
apply_theme(self._app_ref(), dark=self._dark_theme)
self._theme_btn.setText(
"● MØRKT TEMA" if not self._dark_theme else "☀ LYS TEMA"
)
self._vu.set_dark(self._dark_theme)
# ── Luk ───────────────────────────────────────────────────────────────────
def closeEvent(self, event):
self._player.stop()
if self._scan_worker and self._scan_worker.isRunning():
self._scan_worker.quit()
self._scan_worker.wait(2000)
try:
if self._watcher:
self._watcher.stop()
except Exception:
pass
event.accept()

59
ui/next_up_bar.py Normal file
View File

@@ -0,0 +1,59 @@
"""
next_up_bar.py — Banner der vises når en sang er færdig.
"""
from PyQt6.QtWidgets import (
QFrame, QHBoxLayout, QVBoxLayout, QLabel, QPushButton,
)
from PyQt6.QtCore import pyqtSignal
class NextUpBar(QFrame):
play_next_clicked = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("next_up_frame")
self.hide()
self._build_ui()
def _build_ui(self):
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 10, 16, 10)
# Tekst
text_layout = QVBoxLayout()
text_layout.setSpacing(2)
self._label = QLabel("NÆSTE SANG KLAR")
self._label.setObjectName("next_up_label")
text_layout.addWidget(self._label)
self._title = QLabel("")
self._title.setObjectName("next_up_title")
text_layout.addWidget(self._title)
self._sub = QLabel("")
self._sub.setObjectName("next_up_sub")
text_layout.addWidget(self._sub)
layout.addLayout(text_layout)
layout.addStretch()
# Knap
self._btn = QPushButton("▶ AFSPIL NÆSTE")
self._btn.setObjectName("btn_play_next")
self._btn.setFixedHeight(44)
self._btn.setMinimumWidth(160)
self._btn.clicked.connect(self.play_next_clicked.emit)
layout.addWidget(self._btn)
def show_next(self, title: str, artist: str, dances: list[str]):
dance_str = "Dans: " + ", ".join(dances) if dances else ""
sub = f"{artist}{' · ' + dance_str if dance_str else ''}"
self._title.setText(title)
self._sub.setText(sub)
self.show()
def hide_bar(self):
self.hide()

324
ui/playlist_manager.py Normal file
View File

@@ -0,0 +1,324 @@
"""
playlist_manager.py — Dialog til danseliste-administration.
Ny liste, gem, load og importer M3U/M3U8/tekst.
"""
import os
from pathlib import Path
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QListWidget, QListWidgetItem, QFileDialog,
QMessageBox, QTabWidget, QWidget, QTextEdit,
)
from PyQt6.QtCore import Qt, pyqtSignal
class PlaylistManagerDialog(QDialog):
"""
Fanebaseret dialog med tre faner:
1. Gem aktuel liste
2. Indlæs gemt liste
3. Importer fra fil (M3U / M3U8 / tekst)
"""
playlist_loaded = pyqtSignal(str, list) # (navn, liste af dict)
def __init__(self, current_songs: list[dict], parent=None):
super().__init__(parent)
self.setWindowTitle("Danseliste-administration")
self.setMinimumWidth(500)
self.setMinimumHeight(460)
self._current_songs = current_songs
self._build_ui()
self._load_saved_playlists()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
tabs = QTabWidget()
tabs.addTab(self._build_save_tab(), "💾 Gem liste")
tabs.addTab(self._build_load_tab(), "📂 Indlæs liste")
tabs.addTab(self._build_import_tab(), "📥 Importer")
layout.addWidget(tabs)
btn_close = QPushButton("Luk")
btn_close.clicked.connect(self.accept)
row = QHBoxLayout()
row.addStretch()
row.addWidget(btn_close)
layout.addLayout(row)
# ── Fane 1: Gem ───────────────────────────────────────────────────────────
def _build_save_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setSpacing(10)
layout.addWidget(QLabel(f"Aktuel liste har {len(self._current_songs)} sange."))
layout.addWidget(QLabel("Navn på danselisten:"))
self._save_name = QLineEdit()
self._save_name.setPlaceholderText("f.eks. Sommer Event 2025")
layout.addWidget(self._save_name)
btn_save = QPushButton("💾 Gem")
btn_save.clicked.connect(self._save_playlist)
layout.addWidget(btn_save)
self._save_status = QLabel("")
self._save_status.setObjectName("result_count")
layout.addWidget(self._save_status)
layout.addStretch()
return tab
def _save_playlist(self):
name = self._save_name.text().strip()
if not name:
self._save_status.setText("Angiv et navn")
return
if not self._current_songs:
self._save_status.setText("Danselisten er tom")
return
try:
from local.local_db import create_playlist, add_song_to_playlist, get_db
pl_id = create_playlist(name)
for i, song in enumerate(self._current_songs, start=1):
add_song_to_playlist(pl_id, song["id"], position=i)
self._save_status.setText(f"✓ Gemt som \"{name}\"")
self._load_saved_playlists()
except Exception as e:
self._save_status.setText(f"Fejl: {e}")
# ── Fane 2: Indlæs ────────────────────────────────────────────────────────
def _build_load_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.addWidget(QLabel("Gemte danselister:"))
self._pl_list = QListWidget()
self._pl_list.itemDoubleClicked.connect(self._load_selected)
layout.addWidget(self._pl_list)
btn_row = QHBoxLayout()
btn_load = QPushButton("📂 Indlæs valgte")
btn_load.clicked.connect(self._load_selected_btn)
btn_delete = QPushButton("🗑 Slet valgte")
btn_delete.clicked.connect(self._delete_selected)
btn_row.addWidget(btn_load)
btn_row.addWidget(btn_delete)
layout.addLayout(btn_row)
self._load_status = QLabel("")
self._load_status.setObjectName("result_count")
layout.addWidget(self._load_status)
return tab
def _load_saved_playlists(self):
if not hasattr(self, "_pl_list"):
return
self._pl_list.clear()
try:
from local.local_db import get_playlists
for pl in get_playlists():
item = QListWidgetItem(pl["name"])
item.setData(Qt.ItemDataRole.UserRole, dict(pl))
self._pl_list.addItem(item)
except Exception:
pass
def _load_selected_btn(self):
item = self._pl_list.currentItem()
if item:
self._load_selected(item)
def _load_selected(self, item: QListWidgetItem):
pl = item.data(Qt.ItemDataRole.UserRole)
if not pl:
return
try:
from local.local_db import get_playlist_with_songs, get_db
data = get_playlist_with_songs(pl["id"])
songs = []
for row in data.get("songs", []):
with get_db() as conn:
dances = conn.execute(
"SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
(row["id"],)
).fetchall()
songs.append({
"id": row["id"],
"title": row.get("title", ""),
"artist": row.get("artist", ""),
"album": row.get("album", ""),
"bpm": row.get("bpm", 0),
"duration_sec": row.get("duration_sec", 0),
"local_path": row.get("local_path", ""),
"file_format": row.get("file_format", ""),
"file_missing": bool(row.get("file_missing", False)),
"dances": [d["dance_name"] for d in dances],
})
self.playlist_loaded.emit(pl["name"], songs)
self._load_status.setText(f"✓ Indlæst: {pl['name']} ({len(songs)} sange)")
except Exception as e:
self._load_status.setText(f"Fejl: {e}")
def _delete_selected(self):
item = self._pl_list.currentItem()
if not item:
return
pl = item.data(Qt.ItemDataRole.UserRole)
reply = QMessageBox.question(
self, "Slet liste",
f"Slet danselisten \"{pl['name']}\"?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
try:
from local.local_db import get_db
with get_db() as conn:
conn.execute("DELETE FROM playlists WHERE id=?", (pl["id"],))
self._load_saved_playlists()
except Exception as e:
self._load_status.setText(f"Fejl: {e}")
# ── Fane 3: Importer ──────────────────────────────────────────────────────
def _build_import_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setSpacing(8)
lbl = QLabel(
"Importer fra M3U, M3U8 eller en tekstfil med én filsti per linje.\n"
"Sange der ikke er i biblioteket forsøges tilføjet automatisk."
)
lbl.setWordWrap(True)
lbl.setObjectName("result_count")
layout.addWidget(lbl)
btn_browse = QPushButton("📂 Vælg fil...")
btn_browse.clicked.connect(self._browse_import)
layout.addWidget(btn_browse)
layout.addWidget(QLabel("Eller indsæt filstier direkte (én per linje):"))
self._import_text = QTextEdit()
self._import_text.setPlaceholderText(
"/sti/til/sang1.mp3\n/sti/til/sang2.flac\n..."
)
self._import_text.setMaximumHeight(120)
layout.addWidget(self._import_text)
layout.addWidget(QLabel("Navn på den importerede liste:"))
self._import_name = QLineEdit()
self._import_name.setPlaceholderText("Importeret liste")
layout.addWidget(self._import_name)
btn_import = QPushButton("📥 Importer")
btn_import.clicked.connect(self._do_import)
layout.addWidget(btn_import)
self._import_status = QLabel("")
self._import_status.setObjectName("result_count")
self._import_status.setWordWrap(True)
layout.addWidget(self._import_status)
layout.addStretch()
return tab
def _browse_import(self):
path, _ = QFileDialog.getOpenFileName(
self, "Vælg afspilningsliste",
filter="Afspilningslister (*.m3u *.m3u8 *.txt);;Alle filer (*)"
)
if path:
self._import_name.setText(Path(path).stem)
paths = self._parse_playlist_file(path)
self._import_text.setPlainText("\n".join(paths))
def _parse_playlist_file(self, path: str) -> list[str]:
"""Parser M3U, M3U8 og tekst — returnerer liste af filstier."""
paths = []
base_dir = str(Path(path).parent)
try:
enc = "utf-8-sig" if path.lower().endswith(".m3u8") else "latin-1"
with open(path, encoding=enc, errors="replace") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
# Gør relativ sti absolut
if not os.path.isabs(line):
line = os.path.join(base_dir, line)
paths.append(line)
except Exception as e:
self._import_status.setText(f"Læsefejl: {e}")
return paths
def _do_import(self):
raw = self._import_text.toPlainText().strip()
if not raw:
self._import_status.setText("Ingen filstier angivet")
return
name = self._import_name.text().strip() or "Importeret liste"
paths = [line.strip() for line in raw.splitlines() if line.strip()]
found = []
missing = []
try:
from local.local_db import get_song_by_path, upsert_song, get_db
from local.tag_reader import read_tags, is_supported
for p in paths:
row = get_song_by_path(p)
if row:
# Hent danse
with get_db() as conn:
dances = conn.execute(
"SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
(row["id"],)
).fetchall()
found.append({
"id": row["id"],
"title": row["title"],
"artist": row["artist"],
"album": row["album"],
"bpm": row["bpm"],
"duration_sec": row["duration_sec"],
"local_path": row["local_path"],
"file_format": row["file_format"],
"file_missing": bool(row["file_missing"]),
"dances": [d["dance_name"] for d in dances],
})
elif os.path.exists(p) and is_supported(p):
# Filen er ikke scannet endnu — høst tags og tilføj
tags = read_tags(p)
song_id = upsert_song(tags)
found.append({
"id": song_id,
"title": tags.get("title", Path(p).stem),
"artist": tags.get("artist", ""),
"album": tags.get("album", ""),
"bpm": tags.get("bpm", 0),
"duration_sec": tags.get("duration_sec", 0),
"local_path": p,
"file_format": tags.get("file_format", ""),
"file_missing": False,
"dances": tags.get("dances", []),
})
else:
missing.append(p)
if found:
self.playlist_loaded.emit(name, found)
status = f"✓ Importeret {len(found)} sange som \"{name}\""
if missing:
status += f"\n{len(missing)} filer ikke fundet"
self._import_status.setText(status)
else:
self._import_status.setText("Ingen filer fundet — tjek stierne")
except Exception as e:
self._import_status.setText(f"Importfejl: {e}")

278
ui/playlist_panel.py Normal file
View File

@@ -0,0 +1,278 @@
"""
playlist_panel.py — Danseliste med event-overblik, drag-and-drop og højreklik.
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView,
QMessageBox,
)
from PyQt6.QtCore import Qt, pyqtSignal, QMimeData
from PyQt6.QtGui import QColor, QFont, QDragEnterEvent, QDropEvent
class PlaylistPanel(QWidget):
song_selected = pyqtSignal(int) # dobbeltklik → indlæs sang
status_changed = pyqtSignal(int, str) # (indeks, ny_status)
song_dropped = pyqtSignal(dict) # sang droppet fra bibliotek
STATUS_ICON = {
"pending": " ",
"playing": "",
"played": "",
"skipped": "",
"next": "",
}
STATUS_COLOR = {
"pending": "#5a6070",
"playing": "#e8a020",
"played": "#2ecc71",
"skipped": "#e74c3c",
"next": "#3b8fd4",
}
def __init__(self, parent=None):
super().__init__(parent)
self._songs: list[dict] = []
self._statuses: list[str] = []
self._current_idx = -1
self._song_ended = False
self._build_ui()
self.setAcceptDrops(True)
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)
self._title_label = QLabel("DANSELISTE")
self._title_label.setObjectName("section_title")
header.addWidget(self._title_label)
header.addStretch()
layout.addLayout(header)
# Event-kontrol-linje
ctrl = QHBoxLayout()
ctrl.setContentsMargins(8, 4, 8, 4)
ctrl.setSpacing(6)
self._btn_start = QPushButton("▶ START EVENT")
self._btn_start.setObjectName("btn_start_event")
self._btn_start.setFixedHeight(28)
self._btn_start.setToolTip("Nulstil alle statusser og start eventet fra top")
self._btn_start.clicked.connect(self._start_event)
ctrl.addWidget(self._btn_start)
ctrl.addStretch()
self._lbl_progress = QLabel("0 / 0")
self._lbl_progress.setObjectName("result_count")
ctrl.addWidget(self._lbl_progress)
layout.addLayout(ctrl)
# Kolonneheader
col_header = QHBoxLayout()
col_header.setContentsMargins(10, 2, 10, 2)
for text, stretch in [("#", 0), ("Titel / Dans", 1), ("Status", 0)]:
lbl = QLabel(text)
lbl.setObjectName("result_count")
if stretch:
col_header.addWidget(lbl, stretch=1)
else:
lbl.setFixedWidth(30 if text == "#" else 50)
col_header.addWidget(lbl)
layout.addLayout(col_header)
# Liste
self._list = QListWidget()
self._list.setObjectName("playlist_list")
self._list.setDragDropMode(QAbstractItemView.DragDropMode.DropOnly)
self._list.setAcceptDrops(True)
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)
# ── Drag & drop ───────────────────────────────────────────────────────────
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasFormat("application/x-linedance-song"):
event.acceptProposedAction()
else:
event.ignore()
def dropEvent(self, event: QDropEvent):
mime = event.mimeData()
if mime.hasFormat("application/x-linedance-song"):
import json
data = mime.data("application/x-linedance-song").data()
song = json.loads(data.decode("utf-8"))
self._append_song(song)
self.song_dropped.emit(song)
event.acceptProposedAction()
def _append_song(self, song: dict):
self._songs.append(song)
self._statuses.append("pending")
self._refresh()
# ── Data ──────────────────────────────────────────────────────────────────
def load_songs(self, songs: list[dict], reset_statuses: bool = True):
self._songs = list(songs)
if reset_statuses:
self._statuses = ["pending"] * len(songs)
self._current_idx = -1
self._song_ended = False
self._refresh()
def set_current(self, idx: int, song_ended: bool = False):
self._current_idx = idx
self._song_ended = song_ended
if 0 <= idx < len(self._statuses) and not song_ended:
self._statuses[idx] = "playing"
self._refresh()
self._scroll_to(idx)
def mark_played(self, idx: int):
if 0 <= idx < len(self._statuses):
self._statuses[idx] = "played"
self._refresh()
def get_song(self, idx: int) -> dict | None:
return self._songs[idx] if 0 <= idx < len(self._songs) else None
def get_songs(self) -> list[dict]:
return list(self._songs)
def get_statuses(self) -> list[str]:
return list(self._statuses)
def count(self) -> int:
return len(self._songs)
# ── Event-styring ─────────────────────────────────────────────────────────
def _start_event(self):
if not self._songs:
return
reply = QMessageBox.question(
self, "Start event",
"Dette nulstiller alle statusser i danselisten.\nFortsæt?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
self._statuses = ["pending"] * len(self._songs)
self._current_idx = -1
self._song_ended = False
self._refresh()
# ── Højreklik-menu ────────────────────────────────────────────────────────
def _show_context_menu(self, pos):
item = self._list.itemAt(pos)
if not item:
return
idx = item.data(Qt.ItemDataRole.UserRole)
if idx is None:
return
menu = QMenu(self)
menu.setStyleSheet("QMenu { padding: 4px; } QMenu::item { padding: 6px 20px; }")
act_play = menu.addAction("▶ Afspil denne")
menu.addSeparator()
act_skip = menu.addAction("— Spring over")
act_unplay = menu.addAction("↺ Sæt til ikke afspillet")
act_played = menu.addAction("✓ Sæt til afspillet")
menu.addSeparator()
act_remove = menu.addAction("✕ Fjern fra liste")
action = menu.exec(self._list.mapToGlobal(pos))
if action == act_play:
self.song_selected.emit(idx)
elif action == act_skip:
self._statuses[idx] = "skipped"
self.status_changed.emit(idx, "skipped")
self._refresh()
elif action == act_unplay:
self._statuses[idx] = "pending"
self.status_changed.emit(idx, "pending")
self._refresh()
elif action == act_played:
self._statuses[idx] = "played"
self.status_changed.emit(idx, "played")
self._refresh()
elif action == act_remove:
self._songs.pop(idx)
self._statuses.pop(idx)
if self._current_idx >= idx:
self._current_idx = max(-1, self._current_idx - 1)
self._refresh()
# ── Render ────────────────────────────────────────────────────────────────
def _refresh(self):
self._list.clear()
played_count = sum(1 for s in self._statuses if s == "played")
self._lbl_progress.setText(f"{played_count} / {len(self._songs)} afspillet")
for i, song in enumerate(self._songs):
is_current = (i == self._current_idx and not self._song_ended)
is_next = (self._song_ended and i == self._current_idx + 1)
if is_current:
status = "playing"
elif is_next:
status = "next"
else:
status = self._statuses[i]
icon = self.STATUS_ICON.get(status, " ")
color = self.STATUS_COLOR.get(status, "#5a6070")
dances = " / ".join(song.get("dances", [])) or "ingen dans tagget"
text = f"{i+1:>2}. {song.get('title','')}\n {song.get('artist','')} · {dances}"
item = QListWidgetItem(f"{icon} {text}")
item.setData(Qt.ItemDataRole.UserRole, i)
# Farver
if status == "playing":
item.setForeground(QColor("#e8a020"))
font = item.font()
font.setBold(True)
item.setFont(font)
elif status == "next":
item.setForeground(QColor("#3b8fd4"))
font = item.font()
font.setBold(True)
item.setFont(font)
elif status == "played":
item.setForeground(QColor("#2ecc71"))
elif status == "skipped":
item.setForeground(QColor("#e74c3c"))
else:
item.setForeground(QColor("#9aa0b0"))
self._list.addItem(item)
def set_playlist_name(self, name: str):
self._title_label.setText(f"DANSELISTE — {name.upper()}")
def _scroll_to(self, idx: int):
if 0 <= idx < self._list.count():
self._list.scrollToItem(
self._list.item(idx),
QListWidget.ScrollHint.PositionAtCenter,
)
def _on_double_click(self, item: QListWidgetItem):
idx = item.data(Qt.ItemDataRole.UserRole)
if idx is not None:
self.song_selected.emit(idx)

54
ui/scan_worker.py Normal file
View File

@@ -0,0 +1,54 @@
"""
scan_worker.py — Kører fuld biblioteks-scanning i en baggrundstråd
så GUI ikke fryser.
"""
from PyQt6.QtCore import QThread, pyqtSignal
class ScanWorker(QThread):
"""
Kører _full_scan_all() i en baggrundstråd.
Sender status-opdateringer undervejs.
"""
status_update = pyqtSignal(str) # løbende statusbeskeder
scan_done = pyqtSignal(int) # antal behandlede filer
def __init__(self, watcher, parent=None):
super().__init__(parent)
self._watcher = watcher
self._total = 0
def run(self):
try:
from local.local_db import get_libraries
libraries = get_libraries(active_only=True)
if not libraries:
self.status_update.emit("Ingen biblioteker konfigureret")
self.scan_done.emit(0)
return
total_processed = 0
for lib in libraries:
from pathlib import Path
path = Path(lib["path"])
name = path.name
self.status_update.emit(f"Scanner: {name}...")
# Tæl filer først så vi kan vise fremgang
from local.tag_reader import is_supported
files = [f for f in path.rglob("*") if f.is_file() and is_supported(f)]
count = len(files)
self.status_update.emit(f"Scanner: {name} ({count} filer)...")
# Kør scanning
self._watcher._full_scan_library(lib["id"], str(path))
total_processed += count
self.status_update.emit(f"Scan færdig — {total_processed} filer gennemgået")
self.scan_done.emit(total_processed)
except Exception as e:
self.status_update.emit(f"Scan fejl: {e}")
self.scan_done.emit(0)

293
ui/themes.py Normal file
View File

@@ -0,0 +1,293 @@
"""
themes.py — Lyst og mørkt tema til PyQt6.
"""
DARK = """
QWidget {
background-color: #1a1c1f;
color: #e8eaf0;
font-family: 'Barlow', 'Segoe UI', sans-serif;
font-size: 13px;
}
QMainWindow, #root {
background-color: #111214;
}
/* Knapper */
QPushButton {
background-color: #30343c;
color: #9aa0b0;
border: 1px solid #4a5060;
border-radius: 4px;
padding: 6px 14px;
}
QPushButton:hover {
background-color: #454a56;
color: #e8eaf0;
border-color: #e8a020;
}
QPushButton:pressed {
background-color: #22252a;
}
QPushButton:checked {
background-color: #e8a020;
color: #111214;
border-color: #c47a10;
}
QPushButton#btn_play {
background-color: #e8a020;
color: #111214;
border-color: #c47a10;
font-size: 22px;
font-weight: bold;
}
QPushButton#btn_play:hover {
background-color: #c47a10;
}
QPushButton#btn_stop {
color: #e74c3c;
}
QPushButton#btn_stop:hover {
border-color: #e74c3c;
}
QPushButton#btn_demo {
color: #3b8fd4;
border-color: #3b8fd4;
font-size: 11px;
}
QPushButton#btn_demo:hover, QPushButton#btn_demo:checked {
background-color: #3b8fd4;
color: #111214;
border-color: #3b8fd4;
}
/* Slider */
QSlider::groove:horizontal {
height: 4px;
background: #2c3038;
border-radius: 2px;
}
QSlider::sub-page:horizontal {
background: #e8a020;
border-radius: 2px;
}
QSlider::handle:horizontal {
background: #e8a020;
width: 12px;
height: 12px;
margin: -4px 0;
border-radius: 6px;
}
/* Lister */
QListWidget {
background-color: #1a1c1f;
border: none;
outline: none;
}
QListWidget::item {
padding: 6px 10px;
border-bottom: 1px solid #22252a;
}
QListWidget::item:selected {
background-color: #2c3038;
color: #e8eaf0;
border-left: 2px solid #e8a020;
}
QListWidget::item:hover {
background-color: #22252a;
}
/* Søgefelt */
QLineEdit {
background-color: #111214;
border: 1px solid #3a3e46;
border-radius: 3px;
padding: 5px 8px;
color: #e8eaf0;
}
QLineEdit:focus {
border-color: #e8a020;
}
/* Labels */
QLabel#track_title {
font-size: 20px;
font-weight: bold;
color: #e8eaf0;
font-family: 'Rajdhani', 'Segoe UI', sans-serif;
}
QLabel#track_meta {
font-size: 11px;
color: #9aa0b0;
font-family: 'Courier New', monospace;
}
QLabel#section_title {
font-size: 11px;
font-weight: bold;
color: #5a6070;
letter-spacing: 2px;
font-family: 'Courier New', monospace;
padding: 6px 10px;
background-color: #22252a;
border-bottom: 1px solid #3a3e46;
}
QLabel#next_up_label {
color: #e8a020;
font-family: 'Courier New', monospace;
font-size: 11px;
letter-spacing: 2px;
}
QLabel#next_up_title {
font-size: 17px;
font-weight: bold;
color: #e8eaf0;
}
QLabel#next_up_sub {
font-size: 11px;
color: #9aa0b0;
font-family: 'Courier New', monospace;
}
QLabel#vol_label {
font-size: 10px;
color: #5a6070;
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
QLabel#vol_val {
font-size: 11px;
color: #9aa0b0;
font-family: 'Courier New', monospace;
min-width: 28px;
}
QLabel#result_count {
font-size: 10px;
color: #5a6070;
font-family: 'Courier New', monospace;
padding: 3px 10px;
}
/* Frames / paneler */
QFrame#panel {
background-color: #1a1c1f;
border: 1px solid #3a3e46;
border-radius: 4px;
}
QFrame#now_playing_frame {
background-color: #1a1c1f;
border: 1px solid #3a3e46;
border-radius: 4px 4px 0 0;
}
QFrame#track_display {
background-color: #111214;
border: 1px solid #3a3e46;
border-radius: 3px;
padding: 4px;
}
QFrame#transport_frame {
background-color: #1a1c1f;
border: 1px solid #3a3e46;
border-top: none;
border-radius: 0 0 4px 4px;
}
QFrame#next_up_frame {
background-color: #22252a;
border: 1px solid #e8a020;
border-top: none;
border-bottom: none;
}
QFrame#progress_frame {
background-color: #1a1c1f;
border: 1px solid #3a3e46;
border-top: none;
border-bottom: none;
}
/* Scrollbar */
QScrollBar:vertical {
background: #1a1c1f;
width: 6px;
border-radius: 3px;
}
QScrollBar::handle:vertical {
background: #4a5060;
border-radius: 3px;
min-height: 20px;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
/* Topbar */
QFrame#topbar {
background-color: #1a1c1f;
border: 1px solid #3a3e46;
border-radius: 4px;
}
QLabel#logo {
font-size: 16px;
font-weight: bold;
letter-spacing: 3px;
color: #e8a020;
font-family: 'Rajdhani', 'Segoe UI', sans-serif;
}
QLabel#conn_label {
font-size: 11px;
color: #5a6070;
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
"""
LIGHT = DARK + """
QWidget {
background-color: #d8dae0;
color: #1a1c22;
}
QMainWindow, #root {
background-color: #c8cad0;
}
QPushButton {
background-color: #b0b4bc;
color: #4a5060;
border-color: #8890a0;
}
QPushButton:hover {
background-color: #c8ccd4;
color: #1a1c22;
border-color: #c07010;
}
QPushButton#btn_play {
background-color: #c07010;
color: #fff;
border-color: #a05808;
}
QListWidget {
background-color: #d8dae0;
}
QListWidget::item:selected {
background-color: #eef0f4;
border-left: 2px solid #c07010;
}
QLineEdit {
background-color: #c8cad0;
border-color: #aab0bc;
color: #1a1c22;
}
QLineEdit:focus { border-color: #c07010; }
QFrame#panel, QFrame#now_playing_frame,
QFrame#transport_frame, QFrame#progress_frame {
background-color: #d8dae0;
border-color: #aab0bc;
}
QFrame#track_display { background-color: #c8cad0; border-color: #aab0bc; }
QFrame#topbar { background-color: #d8dae0; border-color: #aab0bc; }
QLabel#section_title { background-color: #e4e6ec; color: #8890a0; border-color: #aab0bc; }
QLabel#track_title { color: #1a1c22; }
QLabel#track_meta { color: #4a5060; }
QSlider::groove:horizontal { background: #b0b4bc; }
QScrollBar:vertical { background: #d8dae0; }
QScrollBar::handle:vertical { background: #8890a0; }
"""
def apply_theme(app, dark: bool = True):
app.setStyleSheet(DARK if dark else LIGHT)

96
ui/vu_meter.py Normal file
View File

@@ -0,0 +1,96 @@
"""
vu_meter.py — VU-meter widget der tegner L og R kanaler.
Opdateres via set_levels(left, right) med værdier 0.01.0.
"""
from PyQt6.QtWidgets import QWidget
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QPainter, QColor
import random
NUM_BARS = 14
BAR_W = 14
BAR_H = 4
BAR_GAP = 2
CHAN_GAP = 6
PADDING = 4
COLOR_OFF = QColor("#1a2218")
COLOR_GREEN = QColor("#28a050")
COLOR_YELLOW = QColor("#c8a020")
COLOR_RED = QColor("#c83020")
# Grænser for farver (bar-indeks fra bunden)
YELLOW_FROM = NUM_BARS - 4
RED_FROM = NUM_BARS - 2
class VUMeter(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._left = 0.0
self._right = 0.0
self._peak_l = 0.0
self._peak_r = 0.0
self._dark = True
total_h = NUM_BARS * (BAR_H + BAR_GAP) + PADDING * 2 + 16 # +16 til label
total_w = (BAR_W + CHAN_GAP) * 2 + PADDING * 2
self.setFixedSize(total_w, total_h)
def set_dark(self, dark: bool):
self._dark = dark
self.update()
def set_levels(self, left: float, right: float):
"""Sæt niveauer 0.01.0. Kaldes fra afspiller-tråden via signal."""
self._left = max(0.0, min(1.0, left))
self._right = max(0.0, min(1.0, right))
self._peak_l = max(self._peak_l * 0.92, self._left)
self._peak_r = max(self._peak_r * 0.92, self._right)
self.update()
def reset(self):
self._left = self._right = self._peak_l = self._peak_r = 0.0
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
off_color = QColor("#d0d8cc") if not self._dark else COLOR_OFF
for ch_idx, level in enumerate([self._left, self._right]):
x = PADDING + ch_idx * (BAR_W + CHAN_GAP)
active_bars = int(level * NUM_BARS)
for bar_idx in range(NUM_BARS):
y = PADDING + (NUM_BARS - 1 - bar_idx) * (BAR_H + BAR_GAP)
if bar_idx < active_bars:
if bar_idx >= RED_FROM:
color = COLOR_RED
elif bar_idx >= YELLOW_FROM:
color = COLOR_YELLOW
else:
color = COLOR_GREEN
else:
color = off_color
painter.fillRect(x, y, BAR_W, BAR_H,
QColor(color.red(), color.green(), color.blue(), 220))
# Kanal-labels
label_y = PADDING + NUM_BARS * (BAR_H + BAR_GAP) + 4
painter.setPen(QColor("#5a6070"))
font = painter.font()
font.setPointSize(8)
font.setFamily("Courier New")
painter.setFont(font)
for ch_idx, label in enumerate(["L", "R"]):
x = PADDING + ch_idx * (BAR_W + CHAN_GAP) + BAR_W // 2
painter.drawText(x - 4, label_y + 10, label)
painter.end()