Files
LinedanceAfspiller/linedance-app/ui/main_window.py
2026-04-14 20:20:46 +02:00

1384 lines
56 KiB
Python

"""
main_window.py — Linedance afspiller hovedvindue.
"""
import logging
logger = logging.getLogger(__name__)
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, QThread
from PyQt6.QtGui import QAction
from pathlib import Path
from ui.vu_meter import VUMeter
from ui.playlist_panel import PlaylistPanel
from ui.library_panel import LibraryPanel
from ui.themes import apply_theme
from ui.scan_worker import ScanWorker
from ui.login_dialog import LoginDialog, API_URL
from ui.playlist_manager import PlaylistManagerDialog
from ui.settings_dialog import SettingsDialog, load_settings
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 # hvor musikken stopper (blå)
self._demo_fade_fraction = 0.0 # hvor fade slutter (grå)
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, demo_f: float, fade_f: float = 0.0):
self._demo_fraction = max(0.0, min(1.0, demo_f))
self._demo_fade_fraction = max(0.0, min(1.0, fade_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"))
# Fade-slut markør (grå) — vises bag demo-markøren
if self._demo_fade_fraction > 0:
fx = int(w * self._demo_fade_fraction)
p.fillRect(fx - 1, 0, 2, h, QColor("#6a7080"))
# Demo-stop markør (blå)
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(1000, 680)
self.resize(1600, 820)
self._dark_theme = True
self._player = Player(self)
# Preview-afspiller til bibliotek (høretelefoner)
from player.player import PreviewPlayer
self._preview_player = PreviewPlayer(self)
self._current_idx = -1
self._song_ended = False
self._demo_active = False
self._watcher = None
self._scan_workers = [] # Hold referencer til aktive scan-tråde
self._scan_worker = None
self._api_url: str | None = None
self._api_token: str | None = None
self._api_username: str | None = None
# Indlæs indstillinger
self._settings = load_settings()
self._dark_theme = self._settings.get("dark_theme", True)
self._demo_seconds = self._settings.get("demo_seconds", 10)
self._demo_fade_seconds = self._settings.get("demo_fade_seconds", 5)
self._connect_player_signals()
self._library_loaded.connect(self._apply_library)
self._db_ready.connect(self._on_db_ready)
self._login_success_signal.connect(self._on_login_success)
self._login_fail_signal.connect(self._on_login_fail)
self._status_signal.connect(self._set_status)
self._build_menu()
self._build_ui()
self._build_statusbar()
apply_theme(self._app_ref(), dark=self._dark_theme)
self._theme_btn.setText("☀ LYS TEMA" if self._dark_theme else "● MØRKT TEMA")
# Gendan gemt vinduestørrelse og splitter-position
self._restore_window_state()
# Start DB og scanning ved opstart
QTimer.singleShot(200, self._init_local_db)
# Auto-login hvis aktiveret i indstillinger
if self._settings.get("auto_login") and self._settings.get("password"):
QTimer.singleShot(800, self._auto_login)
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")
self._act_go_online = QAction("● Gå online", self)
self._act_go_online.setShortcut("Ctrl+L")
self._act_go_online.triggered.connect(self._go_online)
file_menu.addAction(self._act_go_online)
self._act_sync = QAction("↕ Synkroniser nu", self)
self._act_sync.setShortcut("Ctrl+Shift+S")
self._act_sync.triggered.connect(self._manual_sync)
file_menu.addAction(self._act_sync)
file_menu.addSeparator()
act_settings = QAction("Indstillinger...", self)
act_settings.setShortcut("Ctrl+,")
act_settings.triggered.connect(self._open_settings)
file_menu.addAction(act_settings)
file_menu.addSeparator()
act_quit = QAction("Afslut", self)
act_quit.setShortcut("Ctrl+Q")
act_quit.triggered.connect(self.close)
file_menu.addAction(act_quit)
# ── Ingen Danseliste- eller Visning-menu ──────────────────────────────
# Ny/Gem/Hent ligger direkte i danseliste-panelet
# Tema-skift ligger i topbar-knappen
# Mapper og scan ligger i ⚙ Mapper dialogen
# Gem reference til scan-action (bruges stadig internt)
self._act_scan = QAction("Scan", self)
self._act_scan.triggered.connect(self.start_scan)
# ── 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_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_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_play = btn("", "btn_play", size=72)
self._btn_stop = btn("", "btn_stop", size=72)
self._btn_demo = btn(f"\n{self._demo_seconds} SEK", "btn_demo", size=64, checkable=True)
self._btn_play.clicked.connect(self._toggle_play)
self._btn_stop.clicked.connect(self._stop)
self._btn_demo.clicked.connect(self._toggle_demo)
layout.addWidget(self._btn_play)
layout.addWidget(self._btn_stop)
layout.addSpacing(24)
sep1 = QFrame()
sep1.setFrameShape(QFrame.Shape.VLine)
sep1.setFixedWidth(1)
layout.addWidget(sep1)
layout.addSpacing(24)
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(self._settings.get("volume", 78))
self._vol_slider.setFixedWidth(160)
self._vol_slider.setFixedHeight(36)
self._vol_slider.setObjectName("vol_slider")
self._vol_slider.valueChanged.connect(self._on_volume)
layout.addWidget(self._vol_slider)
self._lbl_vol = QLabel(str(self._settings.get("volume", 78)))
self._lbl_vol.setObjectName("vol_val")
layout.addWidget(self._lbl_vol)
return frame
def _build_panels(self) -> QSplitter:
self._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._playlist_panel.event_started.connect(self._on_event_started)
self._playlist_panel.next_song_ready.connect(self._on_next_song_ready)
self._playlist_panel.playlist_changed.connect(self._on_playlist_changed)
# Debounce-timer til auto-sync — starter sync 5 sek efter sidst ændring
self._sync_debounce = QTimer(self)
self._sync_debounce.setSingleShot(True)
self._sync_debounce.setInterval(5000)
self._sync_debounce.timeout.connect(self._auto_sync)
self._library_panel = LibraryPanel()
self._library_panel.set_preview_player(self._preview_player)
# Sæt audio devices fra indstillinger
main_device = self._settings.get("audio_device_main", "")
preview_device = self._settings.get("audio_device_preview", "")
if main_device:
self._player.set_audio_device(main_device)
if preview_device:
self._preview_player.set_audio_device(preview_device)
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)
self._library_panel.sync_requested.connect(self._manual_sync)
self._library_panel.edit_tags_requested.connect(self._open_tag_editor)
self._library_panel.send_mail_requested.connect(self._send_mail)
self._splitter.addWidget(self._playlist_panel)
self._splitter.addWidget(self._library_panel)
self._splitter.setSizes([700, 900])
return self._splitter
def _restore_window_state(self):
from PyQt6.QtCore import QSettings, QByteArray
settings = QSettings("LineDance", "Player")
geom = settings.value("window/geometry")
if geom:
self.restoreGeometry(geom)
splitter_state = settings.value("window/splitter")
if splitter_state and hasattr(self, "_splitter"):
self._splitter.restoreState(splitter_state)
def _save_window_state(self):
from PyQt6.QtCore import QSettings
settings = QSettings("LineDance", "Player")
settings.setValue("window/geometry", self.saveGeometry())
if hasattr(self, "_splitter"):
settings.setValue("window/splitter", self._splitter.saveState())
# ── Lokal DB + scanning ───────────────────────────────────────────────────
def _init_local_db(self):
# Debounce-timer til reload (skal oprettes i GUI-tråden)
self._reload_timer = QTimer(self)
self._reload_timer.setSingleShot(True)
self._reload_timer.setInterval(2000)
self._reload_timer.timeout.connect(self._reload_library)
# Kør init_db i baggrundstråd — blokerer ikke GUI
import threading
threading.Thread(target=self._init_db_background, daemon=True).start()
def _init_db_background(self):
"""Kører i baggrundstråd — initialiserer DB og loader bibliotek."""
try:
from local.local_db import init_db
init_db()
self._db_ready.emit()
except Exception as e:
pass
def _start_watcher(self):
"""Start fil-watcher i baggrundstråd — blokerer aldrig GUI."""
import threading
def _start():
try:
from local.file_watcher import get_watcher
def on_file_change(event_type, path, song_id):
# Brug signal — den eneste 100% thread-safe metode på Windows
self._file_changed_signal.emit()
watcher = get_watcher(on_change=on_file_change)
watcher.start()
self._watcher = watcher # sæt først når den er klar
except Exception:
pass
threading.Thread(target=_start, daemon=True).start()
def start_scan(self):
"""Start fuld scanning af alle biblioteker — watcher kører i egne baggrundstråde."""
if not self._watcher:
self._set_status("Ingen biblioteker at scanne — tilføj en mappe først")
return
self._set_status("Scanner biblioteker i baggrunden...")
self._watcher._full_scan_all()
# Genindlæs bibliotekslisten efter et øjeblik
QTimer.singleShot(3000, self._reload_library)
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)
# Signal til at opdatere biblioteket fra baggrundstråd
_library_loaded = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(list)
_db_ready = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal()
_file_changed_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal()
_login_success_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(str)
_login_fail_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(str)
_status_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(str, int)
def _reload_library(self):
"""Hent sange fra DB i baggrundstråd — thread-safe via signal."""
import threading
threading.Thread(target=self._fetch_library, daemon=True).start()
def _fetch_library(self):
"""Kører i baggrundstråd — henter sange og sender til GUI via signal."""
try:
import sqlite3
from local.local_db import DB_PATH
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
rows = conn.execute("""
SELECT s.id, s.title, s.artist, s.album, s.bpm,
s.duration_sec, s.local_path, s.file_format,
s.file_missing,
GROUP_CONCAT(d.name, ',') AS dance_names,
GROUP_CONCAT(COALESCE(dl.name,''), ',') AS dance_levels,
GROUP_CONCAT(DISTINCT ad.name) AS alt_dance_names
FROM songs s
LEFT JOIN song_dances sd ON sd.song_id = s.id
LEFT JOIN dances d ON d.id = sd.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
LEFT JOIN song_alt_dances sad ON sad.song_id = s.id
LEFT JOIN dances ad ON ad.id = sad.dance_id
WHERE s.file_missing = 0
GROUP BY s.id
ORDER BY s.artist, s.title
""").fetchall()
conn.close()
songs = []
for row in rows:
dances = row["dance_names"].split(",") if row["dance_names"] else []
levels = row["dance_levels"].split(",") if row["dance_levels"] else []
alt_dances = row["alt_dance_names"].split(",") if row["alt_dance_names"] else []
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": dances,
"dance_levels": levels,
"alt_dances": alt_dances,
})
self._library_loaded.emit(songs)
except Exception:
pass
def _on_db_ready(self):
"""DB er initialiseret — indlæs bibliotek og start post-init."""
self._reload_library()
self._post_init()
def _apply_library(self, songs: list):
self._library_panel.load_songs(songs)
count = len(songs)
self._set_status(
f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000
)
def _post_init(self):
"""Kør efter DB er initialiseret — gendan state og start scan."""
try:
restored = self._playlist_panel.restore_active_playlist()
if restored:
# Hent den sang der er klar (current_idx sat af restore)
idx = self._playlist_panel._current_idx
song = self._playlist_panel.get_song(idx)
if self._playlist_panel.restore_event_state():
# Event var i gang — genoptag
idx = self._playlist_panel._current_idx
song = self._playlist_panel.get_song(idx)
if song:
self._current_idx = idx
self._song_ended = False
self._load_song(song)
self._playlist_panel.set_current(idx)
self._set_status(
f"Event genoptaget ved: {song.get('title','')} — tryk ▶",
6000,
)
elif song:
# Normal opstart — load første sang klar
self._current_idx = idx
self._song_ended = False
self._load_song(song)
self._playlist_panel.set_current(idx)
self._set_status(
f"Klar: {song.get('title','')} — tryk ▶ for at starte",
4000,
)
except Exception:
pass
QTimer.singleShot(5000, self.start_background_scan)
# Start AcoustID fingerprinting efter 10 sekunder hvis aktiveret
acoustid_on = self._settings.get("acoustid_enabled", False)
logger.info(f"AcoustID indstilling: {acoustid_on}")
if acoustid_on:
QTimer.singleShot(10000, self._start_acoustid)
def _start_acoustid(self):
"""Start AcoustID fingerprinting i baggrunden."""
from local.acoustid_worker import AcoustIDWorker, find_fpcalc
from local.local_db import DB_PATH
if not find_fpcalc():
logger.info("AcoustID: fpcalc ikke fundet — springer over")
return
if not hasattr(self, "_acoustid_worker"):
self._acoustid_worker = AcoustIDWorker(str(DB_PATH))
if self._acoustid_worker.is_running():
return
def on_progress(done, total, title):
if total > 0 and done % 10 == 0:
self._set_status(
f"AcoustID: {done}/{total}{title}", 3000
)
self._acoustid_worker.start(
api_key=self._settings.get("acoustid_api_key", ""),
on_progress=on_progress,
)
self._set_status("AcoustID fingerprinting startet i baggrunden", 4000)
def start_background_scan(self):
"""Start scanning af alle aktive biblioteker i baggrunden."""
try:
import sqlite3
from local.local_db import DB_PATH
from ui.scan_worker import ScanWorker
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
libs = conn.execute(
"SELECT id, path FROM libraries WHERE is_active=1"
).fetchall()
conn.close()
pending = [lib for lib in libs if Path(lib["path"]).exists()]
if not pending:
return
self._set_status("Scanner biblioteker i baggrunden...", 4000)
self._scan_workers = []
finished_count = [0]
def on_one_finished(count, p):
finished_count[0] += 1
self._set_status(f"Scanning færdig — {count} filer", 4000)
# Ryd færdige workers ud
self._scan_workers = [w for w in self._scan_workers
if w.isRunning()]
for lib in pending:
worker = ScanWorker(lib["id"], lib["path"], str(DB_PATH),
overwrite_bpm=False)
worker.finished.connect(on_one_finished)
worker.start()
worker.setPriority(QThread.Priority.LowestPriority)
self._scan_workers.append(worker)
except Exception:
pass
def add_library_path(self, path: str):
try:
if not self._watcher:
self._set_status("Watcher ikke klar endnu — prøv igen om et øjeblik", 3000)
return
self._watcher.add_library(path)
self._set_status(f"Tilføjet: {path} — scanner i baggrunden...")
# Genindlæs bibliotekslisten efter kort pause
QTimer.singleShot(800, self._reload_library)
except Exception as e:
self._set_status(f"Fejl ved tilføjelse: {e}")
def _open_settings(self):
dialog = SettingsDialog(parent=self)
if dialog.exec():
self._settings = dialog.get_values()
self._demo_seconds = self._settings.get("demo_seconds", 10)
self._demo_fade_seconds = self._settings.get("demo_fade_seconds", 5)
# Opdater tema hvis ændret
new_dark = self._settings.get("dark_theme", True)
if new_dark != self._dark_theme:
self._dark_theme = new_dark
apply_theme(self._app_ref(), dark=self._dark_theme)
self._theme_btn.setText(
"☀ LYS TEMA" if self._dark_theme else "● MØRKT TEMA"
)
self._vu.set_dark(self._dark_theme)
# Opdater demo-knap tekst
self._btn_demo.setText(f"\n{self._demo_seconds} SEK")
# Opdater demo-markør hvis en sang er indlæst
if hasattr(self, "_current_song") and self._current_song:
dur = self._current_song.get("duration_sec", 0)
if dur > 0:
self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0), min((self._demo_seconds + self._demo_fade_seconds) / dur, 1.0))
self._set_status("Indstillinger gemt", 2000)
# Anvend lydenheder med det samme
main_device = self._settings.get("audio_device_main", "")
preview_device = self._settings.get("audio_device_preview", "")
if main_device:
self._player.set_audio_device(main_device)
if preview_device:
self._preview_player.set_audio_device(preview_device)
def _auto_login(self):
"""Forsøg automatisk login med gemte oplysninger."""
username = self._settings.get("username", "")
password = self._settings.get("password", "")
server_url = self._settings.get("server_url", "http://localhost:8000").rstrip("/")
if not username or not password:
return
def _run():
try:
import urllib.request, urllib.parse, json
data = urllib.parse.urlencode({"username": username, "password": password}).encode()
req = urllib.request.Request(
f"{server_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._api_token = body.get("access_token")
self._api_url = server_url
self._api_username = username
# Kald GUI-opdatering via signal — thread-safe
self._login_success_signal.emit(username)
except Exception as e:
self._login_fail_signal.emit(str(e))
import threading
threading.Thread(target=_run, daemon=True).start()
def _on_playlist_changed(self):
"""Danseliste ændret — start debounce-timer til auto-sync."""
if hasattr(self, "_sync_debounce"):
self._sync_debounce.start()
def _auto_sync(self):
"""Kør sync hvis vi er online — kaldes af debounce-timer."""
if not self._api_token:
return
if not hasattr(self, "_sync_manager") or not self._sync_manager:
return
self._sync_manager.push(
on_done=lambda r: self._status_signal.emit(
f"↑ Synkroniseret — {r.get('songs_synced', 0)} sange", 3000
),
on_error=lambda e: self._status_signal.emit(
f"⚠ Sync fejl: {e}", 8000
),
)
def _on_next_song_ready(self, song: dict):
"""Næste sang er klar — load den i afspilleren og markér orange."""
idx = self._playlist_panel._current_idx
self._current_idx = idx
self._song_ended = False
self._playlist_panel._song_ended = False
self._load_song(song)
self._playlist_panel.set_current(idx)
def _on_login_success(self, username: str):
"""Kaldes i GUI-tråden når login lykkes."""
self._set_online_state(True)
self._set_status(f"Logget ind som {username}", 4000)
def _on_login_fail(self, error: str):
"""Kaldes i GUI-tråden når login fejler."""
self._set_status(f"Login fejlede: {error}", 5000)
def _go_online(self):
"""Log ind/ud med gemte credentials."""
if self._api_token:
self._go_offline()
return
username = self._settings.get("username", "")
password = self._settings.get("password", "")
server_url = self._settings.get("server_url", "http://localhost:8000").rstrip("/")
if not username or not password:
self._set_status("Udfyld brugernavn og kodeord i Indstillinger → Online", 5000)
return
def _run():
try:
import urllib.request, urllib.parse, json
data = urllib.parse.urlencode({"username": username, "password": password}).encode()
req = urllib.request.Request(
f"{server_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._api_token = body.get("access_token")
self._api_url = server_url
self._api_username = username
self._login_success_signal.emit(username)
except Exception as e:
self._login_fail_signal.emit(str(e))
import threading
threading.Thread(target=_run, daemon=True).start()
def _sync_dance_data(self):
"""Synkroniser dans-niveauer og navne fra API."""
if not self._api_token:
return
try:
import urllib.request, json
headers = {"Authorization": f"Bearer {self._api_token}"}
# Hent niveauer
req = urllib.request.Request(f"{API_URL}/dances/levels", headers=headers)
with urllib.request.urlopen(req, timeout=8) as resp:
levels = json.loads(resp.read())
from local.local_db import sync_dance_levels_from_api
sync_dance_levels_from_api(levels)
# Hent populære dans-navne
req = urllib.request.Request(f"{API_URL}/dances/names?limit=500", headers=headers)
with urllib.request.urlopen(req, timeout=8) as resp:
names = json.loads(resp.read())
from local.local_db import sync_dance_names_from_api
sync_dance_names_from_api(names)
self._set_status(f"Synkroniseret {len(levels)} niveauer og {len(names)} dans-navne", 4000)
except Exception as e:
pass
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):
if online:
name = self._api_username or "?"
self._conn_label.setText(f"● ONLINE ({name})")
self._conn_label.setStyleSheet("color: #2ecc71;")
self._act_go_online.setText("● Gå offline")
self._init_sync()
else:
self._conn_label.setText("● OFFLINE")
self._conn_label.setStyleSheet("color: #5a6070;")
self._act_go_online.setText("● Gå online")
self._sync_manager = None
def _init_sync(self):
"""Opret SyncManager og kør initial push+pull."""
try:
from local.local_db import DB_PATH
from local.sync_manager import SyncManager
server_url = self._settings.get("server_url", "http://localhost:8000")
self._sync_manager = SyncManager(
db_path=str(DB_PATH),
server_url=server_url,
token=self._api_token,
)
self._sync_manager.push_and_pull(
on_done=lambda r: self._status_signal.emit(
f"✓ Synkroniseret — {r['push']['songs_synced']} sange", 5000
),
on_error=lambda e: self._status_signal.emit(
f"⚠ Sync fejl: {e}", 5000
),
)
except Exception as e:
self._set_status(f"⚠ Sync fejl: {e}", 5000)
def _manual_sync(self):
import logging
log = logging.getLogger(__name__)
log.info(f"Manuel sync — token: {'ja' if self._api_token else 'NEJ'}, manager: {'ja' if hasattr(self, '_sync_manager') and self._sync_manager else 'NEJ'}")
if not self._api_token:
self._set_status("Log ind for at synkronisere", 3000)
return
if not hasattr(self, "_sync_manager") or not self._sync_manager:
log.info("Ingen sync_manager — kalder _init_sync")
self._init_sync()
return
self._set_status("Synkroniserer...", 2000)
self._sync_manager.push_and_pull(
on_done=lambda r: self._status_signal.emit(
f"✓ Synkroniseret — {r['push']['songs_synced']} sange", 4000
),
on_error=lambda e: self._status_signal.emit(
f"⚠ Sync fejl: {e}", 5000
),
)
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 _open_tag_editor(self, song: dict):
from ui.tag_editor import TagEditorDialog
dialog = TagEditorDialog(song, parent=self)
if dialog.exec():
# Genindlæs biblioteket så ændringer vises
QTimer.singleShot(200, self._reload_library)
def _send_mail(self, song: dict):
import subprocess, sys, shutil, urllib.parse
from pathlib import Path
path = song.get("local_path", "")
title = song.get("title", "")
artist = song.get("artist", "")
if not path or not Path(path).exists():
self._set_status("Filen blev ikke fundet — kan ikke sende mail", 4000)
return
# ── Auto-detekter mailklient ───────────────────────────────────────────
def try_thunderbird() -> bool:
"""Thunderbird: thunderbird -compose attachment='file:///sti'"""
candidates = []
if sys.platform == "win32":
import winreg
for base in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
try:
key = winreg.OpenKey(base,
r"SOFTWARE\Mozilla\Mozilla Thunderbird")
inst, _ = winreg.QueryValueEx(key, "Install Directory")
candidates.append(str(Path(inst) / "thunderbird.exe"))
except Exception:
pass
candidates += [
r"C:\Program Files\Mozilla Thunderbird\thunderbird.exe",
r"C:\Program Files (x86)\Mozilla Thunderbird\thunderbird.exe",
]
elif sys.platform == "darwin":
candidates = [
"/Applications/Thunderbird.app/Contents/MacOS/thunderbird",
]
else:
candidates = [shutil.which("thunderbird") or "",
"/usr/bin/thunderbird",
"/usr/local/bin/thunderbird",
"/snap/bin/thunderbird"]
tb = next((c for c in candidates if c and Path(c).exists()), None)
if not tb:
return False
file_uri = Path(path).as_uri()
subject = f"Linedance sang: {title}{artist}"
compose = (
f"subject='{subject}',"
f"attachment='{file_uri}'"
)
subprocess.Popen([tb, "-compose", compose])
return True
def try_outlook() -> bool:
"""Outlook: outlook.exe /a 'filsti' (kun Windows)"""
if sys.platform != "win32":
return False
candidates = [
shutil.which("outlook") or "",
r"C:\Program Files\Microsoft Office\root\Office16\OUTLOOK.EXE",
r"C:\Program Files (x86)\Microsoft Office\root\Office16\OUTLOOK.EXE",
r"C:\Program Files\Microsoft Office\Office16\OUTLOOK.EXE",
]
ol = next((c for c in candidates if c and Path(c).exists()), None)
if not ol:
return False
subprocess.Popen([ol, "/a", path])
return True
def fallback_mailto():
"""Ingen vedhæftning — åbn standard-mailprogram via mailto:"""
subject = urllib.parse.quote(f"Linedance sang: {title}{artist}")
body = urllib.parse.quote(
f"Sang: {title}\nArtist: {artist}\nFil: {path}\n\n"
f"(Vedhæft filen manuelt fra ovenstående sti)"
)
mailto = f"mailto:?subject={subject}&body={body}"
if sys.platform == "win32":
import os; os.startfile(mailto)
elif sys.platform == "darwin":
subprocess.Popen(["open", mailto])
else:
subprocess.Popen(["xdg-open", mailto])
# ── Prøv i rækkefølge ─────────────────────────────────────────────────
if try_thunderbird():
self._set_status(f"Thunderbird åbnet med {Path(path).name} vedh.", 4000)
elif try_outlook():
self._set_status(f"Outlook åbnet med {Path(path).name} vedh.", 4000)
else:
fallback_mailto()
self._set_status(
f"Ingen kendt mailklient fundet — åbnet mailto: (uden vedhæftning)", 5000
)
def _on_event_started(self):
"""Start event — indlæs første sang i afspilleren klar til afspilning."""
first = self._playlist_panel.get_song(0)
if not first:
return
self._stop()
self._current_idx = 0
self._song_ended = False
self._load_song(first)
self._set_status("Event klar — tryk ▶ for at starte", 5000)
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)
dur = song.get("duration_sec", 0)
self._player.load(song.get("local_path", ""), dur)
# Stop preview hvis den kører
if hasattr(self, "_preview_player") and self._preview_player.is_playing():
self._library_panel._stop_preview()
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(self._demo_seconds / dur, 1.0), min((self._demo_seconds + self._demo_fade_seconds) / 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
# Nulstil gammel markering
old_idx = self._playlist_panel._current_idx
if old_idx is not None and old_idx != idx:
if 0 <= old_idx < len(self._playlist_panel._statuses):
if self._playlist_panel._statuses[old_idx] == "playing":
self._playlist_panel._statuses[old_idx] = "pending"
self._current_idx = idx
self._load_song(song)
self._playlist_panel.set_current(idx)
def _toggle_play(self):
if self._demo_active:
self._player.stop()
self._demo_active = False
self._btn_demo.setChecked(False)
self._btn_play.setText("")
return
self._waiting_for_auto = False # annuller evt. auto-timer
if self._player.is_playing():
self._player.pause()
else:
self._song_ended = False
self._player.play()
self._btn_play.setText("")
def _stop(self):
# Annuller evt. igangværende demo_then_play
self._demo_then_play_pending = False
# Annuller evt. igangværende countdown
if getattr(self, "_waiting_for_auto", False):
self._waiting_for_auto = False
if hasattr(self, "_countdown_timer"):
self._countdown_timer.stop()
self._set_status("Auto-afspilning annulleret", 3000)
return
self._player.stop()
self._song_ended = False
self._demo_active = False
self._btn_demo.setChecked(False)
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=self._demo_seconds,
fade_sec=self._demo_fade_seconds,
)
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):
self._song_ended = False
self._player.play()
self._btn_play.setText("")
def _on_library_song_selected(self, song: dict):
self._load_song(song)
# VLC er asynkron — vent kort på at media er klar
QTimer.singleShot(150, self._play_after_load)
def _play_after_load(self):
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()
# Hvis demo_then_play venter på at starte sang — lad timeren klare det
if getattr(self, "_demo_then_play_pending", False):
return
# Synkroniser current_idx til playlist_panel
self._playlist_panel._current_idx = self._current_idx
# Markér den afspillede sang
self._playlist_panel.mark_played(self._current_idx)
# Synkroniser event-status til den gemte navngivne liste
self._sync_event_status_to_playlist()
# Find næste uafspillede
ni = self._playlist_panel.next_playable_idx()
next_song = self._playlist_panel.get_song(ni) if ni is not None else None
if next_song:
self._current_idx = ni
self._playlist_panel.set_next_ready(ni)
self._load_song(next_song)
mode = self._settings.get("after_song_mode", "manual")
delay = self._settings.get("after_song_delay", 2)
if mode == "manual":
self._waiting_for_auto = False
self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte")
elif mode in ("auto_demo", "auto_play", "demo_then_play"):
self._waiting_for_auto = True
self._countdown_secs = delay
self._countdown_mode = mode
self._countdown_title = next_song.get("title", "")
label = "Auto-demo" if mode in ("auto_demo", "demo_then_play") else "Auto-play"
self._set_status(f"{label} om {delay}s — {self._countdown_title}")
if not hasattr(self, "_countdown_timer"):
self._countdown_timer = QTimer(self)
self._countdown_timer.setInterval(1000)
self._countdown_timer.timeout.connect(self._countdown_tick)
self._countdown_timer.start()
else:
self._current_idx = -1
self._playlist_panel._current_idx = -1
self._playlist_panel._song_ended = False
self._playlist_panel._refresh()
self._sync_event_status_to_playlist()
self._lbl_title.setText("— Danseliste afsluttet —")
self._lbl_meta.setText("")
self._lbl_dances.setText("")
self._set_status("Danselisten er afsluttet")
def _countdown_tick(self):
"""Tæller ned og starter auto-afspilning når den når 0."""
if not getattr(self, "_waiting_for_auto", False):
self._countdown_timer.stop()
return
self._countdown_secs -= 1
label = "Auto-play" if self._countdown_mode == "auto_play" else "Auto-demo"
if self._countdown_secs > 0:
self._set_status(f"{label} om {self._countdown_secs}s — {self._countdown_title}")
else:
self._countdown_timer.stop()
if self._countdown_mode == "auto_play":
self._auto_play_next()
else:
self._auto_demo_next() # auto_demo og demo_then_play starter begge med demo
def _auto_demo_next(self):
"""Afspil demo af den næste klargjorte sang automatisk."""
if not getattr(self, "_waiting_for_auto", False):
return
self._waiting_for_auto = False
self._playlist_panel.set_current(self._current_idx)
self._player.play_demo(self._demo_seconds, self._demo_fade_seconds)
self._demo_active = True
self._btn_demo.setChecked(True)
self._btn_play.setText("")
self._song_ended = False
# Hvis demo_then_play: start sangen automatisk når demo er færdig
if getattr(self, "_countdown_mode", "") == "demo_then_play":
delay = self._settings.get("after_song_delay", 2)
total = self._demo_seconds + self._demo_fade_seconds + delay
self._demo_then_play_pending = True
QTimer.singleShot(total * 1000, self._auto_play_after_demo)
def _auto_play_after_demo(self):
"""Start sangen efter demo er færdig (bruges af demo_then_play)."""
self._demo_then_play_pending = False
self._song_ended = False
self._player.stop()
self._demo_active = False
self._btn_demo.setChecked(False)
self._playlist_panel.set_current(self._current_idx)
self._player.play()
self._btn_play.setText("")
def _auto_play_next(self):
"""Start næste sang automatisk."""
if not getattr(self, "_waiting_for_auto", False):
return # Brugeren har allerede trykket ▶ manuelt
self._waiting_for_auto = False
self._playlist_panel.set_current(self._current_idx)
self._player.play()
self._btn_play.setText("")
self._song_ended = False
def _sync_event_status_to_playlist(self):
"""Gem event-fremgang lokalt og mini-sync til server."""
try:
pl_id = self._playlist_panel.get_named_playlist_id()
if not pl_id:
return
songs = self._playlist_panel.get_songs()
statuses = self._playlist_panel.get_statuses()
from local.local_db import get_db
with get_db() as conn:
for position, status in enumerate(statuses, start=1):
conn.execute(
"UPDATE playlist_songs SET status=? "
"WHERE playlist_id=? AND position=?",
(status, pl_id, position)
)
# Hent server_id for denne playliste
row = conn.execute(
"SELECT api_project_id FROM playlists WHERE id=?", (pl_id,)
).fetchone()
server_id = row["api_project_id"] if row else None
# Mini-sync til server hvis online
if server_id and self._api_token:
self._mini_sync_to_server(server_id, songs, statuses)
except Exception:
pass
def _mini_sync_to_server(self, server_id: str, songs: list, statuses: list):
"""Send kun playliste-status til server — kører i baggrundstråd."""
import threading, urllib.request, json
url = f"{self._api_url}/live/{server_id}/status"
token = self._api_token
payload = json.dumps({
"songs": [
{
"title": s.get("title", ""),
"artist": s.get("artist", ""),
"status": statuses[i] if i < len(statuses) else "pending",
"position": i + 1,
"dance": s.get("active_dance", "") or
(s.get("dances", [""])[0] if s.get("dances") else ""),
"duration": s.get("duration_sec", 0),
}
for i, s in enumerate(songs)
]
}).encode()
def _push():
try:
req = urllib.request.Request(
url, data=payload, method="POST",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
}
)
urllib.request.urlopen(req, timeout=4)
except Exception:
pass
threading.Thread(target=_push, daemon=True).start()
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)
from ui.settings_dialog import save_settings
self._settings["volume"] = value
save_settings(self._settings)
# ── 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._save_window_state()
self._player.stop()
if self._scan_worker and self._scan_worker.isRunning():
self._scan_worker.quit()
self._scan_worker.wait(2000)
# Stop scan workers
if hasattr(self, "_scan_workers"):
for w in self._scan_workers:
if w.isRunning():
w.cancel()
# Stop watchdog subprocess
if hasattr(self, "_watchdog_proc") and self._watchdog_proc:
try:
self._watchdog_proc.terminate()
except Exception:
pass
event.accept()