""" 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, 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) 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("LINEDANCE 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.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.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(DISTINCT d.name) AS dance_names, GROUP_CONCAT(DISTINCT 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(30000, self.start_background_scan) 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) 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): 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: 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) 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 if self._player.is_playing(): self._player.pause() else: self._song_ended = False self._player.play() self._btn_play.setText("⏸") def _stop(self): 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() # 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) self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte") 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 _sync_event_status_to_playlist(self): """Gem event-fremgang (afspillet/sprunget over) til den navngivne liste.""" try: pl_id = self._playlist_panel.get_named_playlist_id() if not pl_id: return 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) ) except Exception as e: pass 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()