""" 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("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_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()