993 lines
39 KiB
Python
993 lines
39 KiB
Python
"""
|
|
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.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_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._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_go_offline = QAction("Gå offline", self)
|
|
self._act_go_offline.triggered.connect(self._go_offline)
|
|
self._act_go_offline.setEnabled(False)
|
|
file_menu.addAction(self._act_go_offline)
|
|
|
|
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_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(f"▶\n{self._demo_seconds} 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(self._settings.get("volume", 78))
|
|
self._vol_slider.setFixedWidth(100)
|
|
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._load_song)
|
|
|
|
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()
|
|
# Trigger library load via signal
|
|
self._library_loaded.emit([]) # tomt signal = "DB klar, load nu"
|
|
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)
|
|
_file_changed_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal()
|
|
|
|
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
|
|
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
|
|
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 []
|
|
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,
|
|
})
|
|
self._library_loaded.emit(songs)
|
|
except Exception:
|
|
pass
|
|
|
|
def _apply_library(self, songs: list):
|
|
if not songs:
|
|
# Tomt signal = DB er klar, start library load og post-init
|
|
self._reload_library()
|
|
self._post_init()
|
|
return
|
|
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."""
|
|
try:
|
|
restored = self._playlist_panel.restore_active_playlist()
|
|
if restored:
|
|
if self._playlist_panel.restore_event_state():
|
|
idx = self._playlist_panel._current_idx
|
|
song = self._playlist_panel.get_song(idx)
|
|
if song:
|
|
self._current_idx = idx
|
|
self._load_song(song)
|
|
self._set_status(
|
|
f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte",
|
|
6000,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# Periodisk reload af bibliotek hvert 10. sekund — fanger ny-scannede sange
|
|
self._auto_reload_timer = QTimer(self)
|
|
self._auto_reload_timer.setInterval(10000)
|
|
self._auto_reload_timer.timeout.connect(self._reload_library)
|
|
self._auto_reload_timer.start()
|
|
|
|
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", "")
|
|
if not username or not password:
|
|
return
|
|
try:
|
|
import urllib.request, urllib.parse, json
|
|
data = urllib.parse.urlencode({"username": username, "password": password}).encode()
|
|
req = urllib.request.Request(
|
|
f"{API_URL}/auth/login", data=data,
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=8) as resp:
|
|
body = json.loads(resp.read())
|
|
self._api_token = body.get("access_token")
|
|
self._api_url = API_URL
|
|
self._api_username = username
|
|
self._set_online_state(True)
|
|
self._set_status(f"Automatisk logget ind som {username}", 4000)
|
|
# Synkroniser dans-niveauer og navne
|
|
QTimer.singleShot(500, self._sync_dance_data)
|
|
except Exception:
|
|
self._set_status("Auto-login fejlede — kør Filer → Gå online manuelt", 5000)
|
|
|
|
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)
|
|
QTimer.singleShot(500, self._sync_dance_data)
|
|
|
|
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):
|
|
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 _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
|
|
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()
|
|
|
|
# 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 første ikke-afspillede og ikke-skippede sang fra TOPPEN
|
|
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:
|
|
# Danseliste afsluttet — nulstil liste-markering og synkroniser
|
|
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)
|
|
try:
|
|
if self._watcher:
|
|
self._watcher.stop()
|
|
except Exception:
|
|
pass
|
|
event.accept()
|