Version 15

This commit is contained in:
2026-04-10 11:12:49 +02:00
parent ad33255b88
commit 3031b7153b
20 changed files with 1866 additions and 196 deletions

View File

@@ -11,15 +11,15 @@ from PyQt6.QtWidgets import (
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.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 player.player import Player
from ui.settings_dialog import SettingsDialog, load_settings
from player.player import Player
class ProgressBar(QWidget):
@@ -63,8 +63,8 @@ class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("LineDance Player")
self.setMinimumSize(860, 680)
self.resize(960, 760)
self.setMinimumSize(1000, 680)
self.resize(1600, 820)
self._dark_theme = True
self._player = Player(self)
@@ -77,15 +77,28 @@ class MainWindow(QMainWindow):
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._connect_player_signals()
self._build_menu()
self._build_ui()
self._build_statusbar()
apply_theme(self._app_ref(), dark=True)
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()
@@ -163,6 +176,13 @@ class MainWindow(QMainWindow):
act_theme.triggered.connect(self._toggle_theme)
view_menu.addAction(act_theme)
view_menu.addSeparator()
act_settings = QAction("Indstillinger...", self)
act_settings.setShortcut("Ctrl+,")
act_settings.triggered.connect(self._open_settings)
view_menu.addAction(act_settings)
# ── Statuslinje ───────────────────────────────────────────────────────────
def _build_statusbar(self):
@@ -187,7 +207,6 @@ class MainWindow(QMainWindow):
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)
@@ -272,11 +291,6 @@ class MainWindow(QMainWindow):
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")
@@ -297,7 +311,7 @@ class MainWindow(QMainWindow):
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_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)
@@ -336,22 +350,43 @@ class MainWindow(QMainWindow):
return frame
def _build_panels(self) -> QSplitter:
splitter = QSplitter(Qt.Orientation.Horizontal)
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)
splitter.addWidget(self._playlist_panel)
splitter.addWidget(self._library_panel)
splitter.setSizes([480, 480])
self._splitter.addWidget(self._playlist_panel)
self._splitter.addWidget(self._library_panel)
self._splitter.setSizes([700, 900])
return splitter
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 ───────────────────────────────────────────────────
@@ -373,6 +408,23 @@ class MainWindow(QMainWindow):
# Indlæs hvad vi allerede kender fra SQLite
self._reload_library()
# Gendan sidst aktive danseliste
restored = self._playlist_panel.restore_active_playlist()
# Gendan event-fremgang hvis liste blev gendannet
if restored:
if self._playlist_panel.restore_event_state():
# Indlæs den sang vi var nået til
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,
)
# Kør automatisk scanning ved opstart
self._set_status("Starter scanning af biblioteker...")
QTimer.singleShot(100, self.start_scan)
@@ -447,6 +499,55 @@ class MainWindow(QMainWindow):
except Exception as e:
self._set_status(f"Fejl: {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)
# 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))
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():
@@ -456,6 +557,33 @@ class MainWindow(QMainWindow):
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:
print(f"Dans-sync fejl: {e}")
def _go_offline(self):
self._api_url = self._api_token = self._api_username = None
@@ -493,6 +621,120 @@ class MainWindow(QMainWindow):
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)
@@ -508,7 +750,6 @@ class MainWindow(QMainWindow):
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)
@@ -524,7 +765,7 @@ class MainWindow(QMainWindow):
)
if dur > 0:
self._progress.set_demo_marker(min(10 / dur, 1.0))
self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0))
self._set_status(f"Indlæst: {song.get('title','')}", 3000)
@@ -537,9 +778,6 @@ class MainWindow(QMainWindow):
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
@@ -549,14 +787,15 @@ class MainWindow(QMainWindow):
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._next_up.hide_bar()
self._btn_play.setText("")
self._vu.reset()
@@ -569,7 +808,7 @@ class MainWindow(QMainWindow):
else:
self._demo_active = True
self._btn_demo.setChecked(True)
self._player.play_demo(stop_at_sec=10)
self._player.play_demo(stop_at_sec=self._demo_seconds)
self._btn_play.setText("")
def _prev_song(self):
@@ -584,13 +823,9 @@ class MainWindow(QMainWindow):
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("")
self._song_ended = False
self._player.play()
self._btn_play.setText("")
def _on_library_song_selected(self, song: dict):
self._load_song(song)
@@ -627,20 +862,18 @@ class MainWindow(QMainWindow):
# Markér den afspillede sang
self._playlist_panel.mark_played(self._current_idx)
# Fremhæv næste sang i listen — men afspil den IKKE
ni = self._current_idx + 1
next_song = self._playlist_panel.get_song(ni)
# Find næste afspilbare sang — spring skippede og afspillede over
ni = self._playlist_panel.next_playable_idx(self._current_idx + 1)
next_song = self._playlist_panel.get_song(ni) if ni is not None else None
if next_song:
# set_current med song_ended=True markerer næste som "next" (blå)
# uden at ændre _current_idx i main_window
self._playlist_panel.set_current(self._current_idx, song_ended=True)
self._next_up.show_next(
next_song.get("title", ""),
next_song.get("artist", ""),
next_song.get("dances", []),
)
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._lbl_title.setText("— Danseliste afsluttet —")
self._lbl_meta.setText("")
self._lbl_dances.setText("")
self._set_status("Danselisten er afsluttet")
def _on_state_changed(self, state: str):
@@ -676,6 +909,7 @@ class MainWindow(QMainWindow):
# ── 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()