Version 1

This commit is contained in:
2026-04-10 23:59:23 +02:00
parent 9d7adf42c1
commit d55859c593
17 changed files with 743 additions and 490 deletions

View File

@@ -0,0 +1,33 @@
"""
app_logger.py — Central logging til fil i stedet for konsol.
P<EFBFBD> Windows uden konsol skrives alt til ~/.linedance/app.log
"""
import logging
import sys
from pathlib import Path
LOG_PATH = Path.home() / ".linedance" / "app.log"
def setup_logging():
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
handlers = [logging.FileHandler(LOG_PATH, encoding="utf-8")]
# Kun tilføj konsol-handler hvis vi kører med konsol (development)
if sys.stdout and hasattr(sys.stdout, 'write'):
try:
sys.stdout.write("") # test om konsol virker
handlers.append(logging.StreamHandler(sys.stdout))
except Exception:
pass
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
handlers=handlers,
force=True,
)
logger = logging.getLogger("linedance")

View File

@@ -64,7 +64,7 @@ exe = EXE(
bootloader_ignore_signals=False,
strip=False,
upx=False, # UPX kan give problemer med PyQt6 DLL-filer
console=True, # Vis fejlbeskeder
console=False, # Ingen konsol-vindue
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,

View File

@@ -17,36 +17,51 @@ from pathlib import Path
DB_PATH = Path.home() / ".linedance" / "local.db"
_local = threading.local()
_global_conn: sqlite3.Connection | None = None
def _get_conn() -> sqlite3.Connection:
"""Returnerer en thread-lokal forbindelse."""
if not hasattr(_local, "conn") or _local.conn is None:
"""Returnerer en global forbindelse i autocommit mode."""
global _global_conn
if _global_conn is None:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL") # bedre concurrent adgang
conn.execute("PRAGMA foreign_keys=ON")
_local.conn = conn
return _local.conn
_global_conn = sqlite3.connect(str(DB_PATH), check_same_thread=False,
isolation_level=None) # autocommit
_global_conn.row_factory = sqlite3.Row
_global_conn.execute("PRAGMA journal_mode=WAL")
_global_conn.execute("PRAGMA foreign_keys=ON")
return _global_conn
def new_conn() -> sqlite3.Connection:
"""Åbn en frisk forbindelse til brug i tag_editor og dialogs."""
conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=OFF") # FK checker forhindrer level_id gem
return conn
@contextmanager
def get_db():
"""Context manager der bruger app-forbindelsen i autocommit mode.
Hver statement committer med det samme — ingen eksplicit transaktion."""
conn = _get_conn()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
def get_db_raw() -> sqlite3.Connection:
return _get_conn()
def init_db():
"""Opret alle tabeller hvis de ikke findes."""
conn = _get_conn()
# Brug executescript direkte (ikke via context manager) da det auto-committer
# executescript committer automatisk og nulstiller isolation_level
# Kør det direkte på den underliggende connection
conn.executescript("""
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -148,23 +163,20 @@ def init_db():
CREATE INDEX IF NOT EXISTS idx_song_dances ON song_dances(song_id);
""")
# Kør migrations for ældre databaser (each separately)
migrations = [
"ALTER TABLE songs ADD COLUMN extra_tags TEXT NOT NULL DEFAULT '{}'",
"ALTER TABLE song_dances ADD COLUMN level_id INTEGER REFERENCES dance_levels(id)",
"ALTER TABLE dance_alternatives ADD COLUMN alt_dance_name TEXT NOT NULL DEFAULT ''",
"ALTER TABLE dance_alternatives ADD COLUMN level_id INTEGER REFERENCES dance_levels(id)",
"ALTER TABLE dance_alternatives ADD COLUMN source TEXT NOT NULL DEFAULT 'local'",
"ALTER TABLE dance_alternatives ADD COLUMN created_by TEXT NOT NULL DEFAULT ''",
]
for sql in migrations:
try:
conn.execute(sql)
conn.commit()
except Exception:
pass
# executescript slår foreign_keys fra — genaktiver
conn.execute("PRAGMA foreign_keys=ON")
# Seed standard-niveauer — KUN hvis tabellen er tom
# Tilføj db_version tabel hvis den ikke findes
conn.execute("""
CREATE TABLE IF NOT EXISTS db_version (
version INTEGER PRIMARY KEY
)
""")
# Kør versionsbaserede migrationer
_run_versioned_migrations(conn)
# Seed standard-niveauer
count = conn.execute("SELECT COUNT(*) FROM dance_levels").fetchone()[0]
if count == 0:
defaults = [
@@ -174,14 +186,49 @@ def init_db():
(4, "Erfaren", "For dedikerede dansere"),
(5, "Ekspert", "Konkurrenceniveau"),
]
conn.executemany(
"INSERT OR IGNORE INTO dance_levels (sort_order, name, description) VALUES (?,?,?)",
defaults
for row in defaults:
conn.execute(
"INSERT OR IGNORE INTO dance_levels (sort_order, name, description) VALUES (?,?,?)",
row
)
# ── Versionsbaserede migrationer ──────────────────────────────────────────────
# Tilføj aldrig gamle — tilføj kun nye versioner nederst.
MIGRATIONS: dict[int, list[str]] = {
1: [
"ALTER TABLE songs ADD COLUMN extra_tags TEXT NOT NULL DEFAULT '{}'",
"ALTER TABLE song_dances ADD COLUMN level_id INTEGER REFERENCES dance_levels(id)",
"ALTER TABLE dance_alternatives ADD COLUMN alt_dance_name TEXT NOT NULL DEFAULT ''",
"ALTER TABLE dance_alternatives ADD COLUMN level_id INTEGER REFERENCES dance_levels(id)",
"ALTER TABLE dance_alternatives ADD COLUMN source TEXT NOT NULL DEFAULT 'local'",
"ALTER TABLE dance_alternatives ADD COLUMN created_by TEXT NOT NULL DEFAULT ''",
],
# Eksempel på fremtidig migration:
# 2: ["ALTER TABLE songs ADD COLUMN mbid TEXT"],
}
def _run_versioned_migrations(conn):
"""Kør kun migrationer der ikke allerede er kørt vha. db_version tabel."""
row = conn.execute("SELECT version FROM db_version").fetchone()
current_version = row["version"] if row else 0
for version in sorted(MIGRATIONS.keys()):
if version <= current_version:
continue
for sql in MIGRATIONS[version]:
try:
conn.execute(sql)
except Exception:
pass # kolonnen eksisterer allerede
conn.execute(
"INSERT OR REPLACE INTO db_version (version) VALUES (?)", (version,)
)
conn.commit()
print(f"Dans-niveauer seedet: {len(defaults)} niveauer")
else:
print(f"Dans-niveauer: {count} niveauer i databasen")
@@ -282,30 +329,49 @@ def upsert_song(song_data: dict) -> str:
extra_tags_json,
))
# Opdater danse hvis de er med i data
# Opdater danse hvis de er med i data — bevar level_id og alternativer
if "dances" in song_data:
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
for i, dance in enumerate(song_data["dances"], start=1):
# dance kan være str eller dict med {name, level_id}
file_dances = []
for dance in song_data["dances"]:
if isinstance(dance, dict):
name = dance.get("name", "")
level_id = dance.get("level_id")
file_dances.append(dance.get("name", ""))
else:
name = dance
level_id = None
conn.execute(
"INSERT INTO song_dances (song_id, dance_name, dance_order, level_id) VALUES (?,?,?,?)",
(song_id, name, i, level_id),
)
# Registrer navne i ordbogen
try:
from local.local_db import register_dance_name as _reg
for dance in song_data["dances"]:
nm = dance.get("name", dance) if isinstance(dance, dict) else dance
if nm:
_reg(nm)
except Exception:
pass
file_dances.append(dance)
file_dances = [d for d in file_dances if d]
# Hent eksisterende danse med level_id og alternativer
existing = conn.execute(
"SELECT id, dance_name, dance_order, level_id FROM song_dances "
"WHERE song_id=? ORDER BY dance_order",
(song_id,)
).fetchall()
existing_map = {r["dance_name"].lower(): r for r in existing}
# Slet danse der ikke længere er i filen
file_lower = [d.lower() for d in file_dances]
for row in existing:
if row["dance_name"].lower() not in file_lower:
conn.execute(
"DELETE FROM dance_alternatives WHERE song_dance_id=?", (row["id"],)
)
conn.execute("DELETE FROM song_dances WHERE id=?", (row["id"],))
# Tilføj eller opdater danse fra filen
for i, name in enumerate(file_dances, start=1):
ex = existing_map.get(name.lower())
if ex:
# Bevar level_id — opdater kun dance_order
conn.execute(
"UPDATE song_dances SET dance_order=? WHERE id=?",
(i, ex["id"])
)
else:
# Ny dans — ingen level_id endnu
conn.execute(
"INSERT INTO song_dances (song_id, dance_name, dance_order, level_id) "
"VALUES (?,?,?,NULL)",
(song_id, name, i)
)
return song_id
@@ -465,15 +531,41 @@ def clear_event_state():
# ── Dans-navne ordbog ─────────────────────────────────────────────────────────
def get_dance_name_suggestions(prefix: str, limit: int = 20) -> list[str]:
"""Returnerer danse-navne der starter med prefix, sorteret efter popularitet."""
"""Returnerer dans-navne der starter med prefix fra alle kendte sources,
sorteret efter popularitet. Inkluderer navne fra song_dances og dance_alternatives."""
with get_db() as conn:
# Hent fra dance_names ordbog (primær kilde)
rows = conn.execute("""
SELECT name FROM dance_names
SELECT name, use_count FROM dance_names
WHERE name LIKE ? COLLATE NOCASE
ORDER BY use_count DESC, name
LIMIT ?
""", (f"{prefix}%", limit)).fetchall()
return [r["name"] for r in rows]
names = {r["name"]: r["use_count"] for r in rows}
# Supplér med navne direkte fra song_dances der ikke er i ordbogen
extra = conn.execute("""
SELECT DISTINCT dance_name as name FROM song_dances
WHERE dance_name LIKE ? COLLATE NOCASE
LIMIT ?
""", (f"{prefix}%", limit)).fetchall()
for r in extra:
if r["name"] not in names:
names[r["name"]] = 0
# Supplér med alternativ-danse
extra2 = conn.execute("""
SELECT DISTINCT alt_dance_name as name FROM dance_alternatives
WHERE alt_dance_name LIKE ? COLLATE NOCASE
LIMIT ?
""", (f"{prefix}%", limit)).fetchall()
for r in extra2:
if r["name"] not in names:
names[r["name"]] = 0
# Sorter: kendte navne med høj use_count først, derefter alfabetisk
return sorted(names.keys(),
key=lambda n: (-names[n], n.lower()))[:limit]
def register_dance_name(name: str, source: str = "local"):

View File

@@ -11,6 +11,7 @@ Sender signals til GUI:
from PyQt6.QtCore import QObject, pyqtSignal, QTimer
import random
import math
try:
import vlc
@@ -33,6 +34,7 @@ class Player(QObject):
self._duration: int = 0
self._demo_mode = False
self._demo_stop_sec = 10
self._demo_fade_sec = 5
self._demo_fading = False
self._volume = 78
@@ -78,10 +80,15 @@ class Player(QObject):
self._poll_timer.start()
self.state_changed.emit("playing")
def play_demo(self, stop_at_sec: int = 10):
"""Afspil fra start og stop automatisk ved stop_at_sec med 2 sek fade-out."""
def play_demo(self, stop_at_sec: int = 10, fade_sec: int = 5):
"""
Afspil fra start, fade ud over fade_sec sekunder og stop.
Total afspilningstid = stop_at_sec + fade_sec.
fade_sec=0 giver ingen fade.
"""
self._demo_mode = True
self._demo_stop_sec = stop_at_sec
self._demo_stop_sec = stop_at_sec + fade_sec # total tid inkl. fade
self._demo_fade_sec = fade_sec
self._demo_fading = False
if VLC_AVAILABLE and self._media_player:
self._media_player.set_time(0)
@@ -156,18 +163,16 @@ class Player(QObject):
self.state_changed.emit("demo_ended")
return
# Demo fade-out — de sidste 2 sekunder
FADE_SEC = 2.0
if self._demo_mode and VLC_AVAILABLE and self._media_player:
# Demo fade-out — de sidste _demo_fade_sec sekunder (0 = ingen fade)
if self._demo_mode and VLC_AVAILABLE and self._media_player and self._demo_fade_sec > 0:
secs_left = self._demo_stop_sec - cur
if secs_left <= FADE_SEC and secs_left > 0:
# Fade fra fuld volumen til 0 over FADE_SEC sekunder
fade_fraction = secs_left / FADE_SEC # 1.0 → 0.0
faded_vol = int(self._volume * fade_fraction)
if secs_left <= self._demo_fade_sec and secs_left > 0:
fade_fraction = secs_left / self._demo_fade_sec # 1.0 → 0.0
log_fraction = math.log10(1 + fade_fraction * 9) / math.log10(10)
faded_vol = int(self._volume * log_fraction)
self._media_player.audio_set_volume(max(0, faded_vol))
self._demo_fading = True
elif not self._demo_fading:
# Ikke i fade-zone endnu — sørg for fuld volumen
self._media_player.audio_set_volume(self._volume)
# VU-meter: brug VLC's audio-amplitude hvis tilgængelig, ellers simulér

View File

@@ -41,9 +41,9 @@ class DraggableLibraryList(QListWidget):
class LibraryPanel(QWidget):
song_selected = pyqtSignal(dict)
add_to_playlist = pyqtSignal(dict)
scan_requested = pyqtSignal()
song_selected = pyqtSignal(dict)
add_to_playlist = pyqtSignal(dict)
scan_requested = pyqtSignal()
edit_tags_requested = pyqtSignal(dict)
send_mail_requested = pyqtSignal(dict)
@@ -51,6 +51,7 @@ class LibraryPanel(QWidget):
super().__init__(parent)
self._all_songs: list[dict] = []
self._filtered: list[dict] = []
self._bpm_scan_running = False
self._search_timer = QTimer(self)
self._search_timer.setSingleShot(True)
self._search_timer.setInterval(150)
@@ -70,6 +71,12 @@ class LibraryPanel(QWidget):
header.addWidget(lbl)
header.addStretch()
self._btn_bpm_scan = QPushButton("♩ BPM alle")
self._btn_bpm_scan.setFixedHeight(24)
self._btn_bpm_scan.setToolTip("Analysér BPM på alle sange uden BPM (kører i baggrunden)")
self._btn_bpm_scan.clicked.connect(self._start_bulk_bpm_scan)
header.addWidget(self._btn_bpm_scan)
btn_manage = QPushButton("⚙ Mapper")
btn_manage.setFixedHeight(24)
btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker")
@@ -172,7 +179,6 @@ class LibraryPanel(QWidget):
dance_levels = song.get("dance_levels", [])
missing = song.get("file_missing", False)
# Byg dans-streng med niveau hvis tilgængeligt
dance_parts = []
for i, d in enumerate(dances):
lvl = dance_levels[i] if i < len(dance_levels) else ""
@@ -183,13 +189,97 @@ class LibraryPanel(QWidget):
bpm = song.get("bpm", 0)
bpm_str = f"{bpm} BPM" if bpm else "? BPM"
line2 = f" {song.get('artist','')} · {bpm_str} · {song.get('file_format','').upper()}{dance_str}"
item = QListWidgetItem(f"{line1}\n{line2}")
row_widget = QWidget()
row_widget.setStyleSheet("background: transparent;")
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(2, 2, 2, 2)
row_layout.setSpacing(8)
lbl = QLabel(f"{line1}\n{line2}")
lbl.setWordWrap(False)
row_layout.addWidget(lbl, stretch=1)
btn_danse = QPushButton("Danse")
btn_danse.setFixedHeight(30)
btn_danse.setFixedWidth(70)
btn_danse.setToolTip("Rediger dans-tags")
btn_danse.setStyleSheet(
"QPushButton { background: #e8a020; color: #111; border-radius: 4px; "
"font-weight: bold; font-size: 12px; border: none; }"
"QPushButton:hover { background: #f0b030; }"
)
btn_danse.clicked.connect(lambda _, s=song: self.edit_tags_requested.emit(s))
row_layout.addWidget(btn_danse)
item = QListWidgetItem()
item.setData(Qt.ItemDataRole.UserRole, song)
if missing:
item.setForeground(QColor("#5a6070"))
elif q and any(q in d.lower() for d in dances):
item.setForeground(QColor("#e8a020"))
row_widget.adjustSize()
hint = row_widget.sizeHint()
hint.setHeight(max(hint.height(), 52))
item.setSizeHint(hint)
self._list.addItem(item)
self._list.setItemWidget(item, row_widget)
def _start_bulk_bpm_scan(self):
"""Start BPM-analyse på alle sange uden BPM i baggrundstråd med lav prioritet."""
if self._bpm_scan_running:
return
songs_without_bpm = [s for s in self._all_songs
if not s.get("bpm") and not s.get("file_missing")]
if not songs_without_bpm:
self._btn_bpm_scan.setText("♩ Alle har BPM")
return
self._bpm_scan_running = True
self._btn_bpm_scan.setText(f"♩ Scanner 0/{len(songs_without_bpm)}...")
self._btn_bpm_scan.setEnabled(False)
from PyQt6.QtCore import QThread, pyqtSignal as _sig
class BulkBpmWorker(QThread):
progress = _sig(int, int, str) # done, total, title
finished = _sig()
def __init__(self, songs):
super().__init__()
self._songs = songs
def run(self):
from local.tag_reader import analyze_and_save_bpm
total = len(self._songs)
for i, song in enumerate(self._songs, start=1):
if self.isInterruptionRequested():
break
try:
bpm = analyze_and_save_bpm(song["local_path"], song["id"])
if bpm:
song["bpm"] = int(round(bpm))
except Exception:
pass
self.progress.emit(i, total, song.get("title", ""))
self.finished.emit()
self._bulk_bpm_worker = BulkBpmWorker(songs_without_bpm)
def on_progress(done, total, title):
self._btn_bpm_scan.setText(f"{done}/{total}...")
# Opdater sangen i listen
for s in self._all_songs:
if s.get("title") == title and s.get("bpm"):
break
self._do_search()
def on_finished():
self._bpm_scan_running = False
self._btn_bpm_scan.setEnabled(True)
self._btn_bpm_scan.setText("♩ BPM alle")
self._do_search()
self._bulk_bpm_worker.progress.connect(on_progress)
self._bulk_bpm_worker.finished.connect(on_finished)
self._bulk_bpm_worker.start()
self._bulk_bpm_worker.setPriority(QThread.Priority.LowestPriority)
# ── Handlinger ────────────────────────────────────────────────────────────

View File

@@ -26,7 +26,8 @@ class ProgressBar(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._fraction = 0.0
self._demo_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)
@@ -34,8 +35,9 @@ class ProgressBar(QWidget):
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))
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):
@@ -46,6 +48,11 @@ class ProgressBar(QWidget):
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"))
@@ -81,6 +88,7 @@ class MainWindow(QMainWindow):
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._build_menu()
@@ -306,12 +314,12 @@ class MainWindow(QMainWindow):
self._vol_slider = QSlider(Qt.Orientation.Horizontal)
self._vol_slider.setRange(0, 100)
self._vol_slider.setValue(78)
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("78")
self._lbl_vol = QLabel(str(self._settings.get("volume", 78)))
self._lbl_vol.setObjectName("vol_val")
layout.addWidget(self._lbl_vol)
@@ -401,7 +409,7 @@ class MainWindow(QMainWindow):
except Exception as e:
self._set_status(f"DB fejl: {e}")
print(f"DB init fejl: {e}")
pass
def start_scan(self):
"""Start fuld scanning af alle biblioteker i baggrundstråd."""
@@ -463,7 +471,7 @@ class MainWindow(QMainWindow):
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}")
pass
def add_library_path(self, path: str):
try:
@@ -483,6 +491,7 @@ class MainWindow(QMainWindow):
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:
@@ -498,7 +507,7 @@ class MainWindow(QMainWindow):
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._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):
@@ -562,7 +571,7 @@ class MainWindow(QMainWindow):
self._set_status(f"Synkroniseret {len(levels)} niveauer og {len(names)} dans-navne", 4000)
except Exception as e:
print(f"Dans-sync fejl: {e}")
pass
def _go_offline(self):
self._api_url = self._api_token = self._api_username = None
@@ -744,7 +753,7 @@ class MainWindow(QMainWindow):
)
if dur > 0:
self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.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)
@@ -787,7 +796,10 @@ class MainWindow(QMainWindow):
else:
self._demo_active = True
self._btn_demo.setChecked(True)
self._player.play_demo(stop_at_sec=self._demo_seconds)
self._player.play_demo(
stop_at_sec=self._demo_seconds,
fade_sec=self._demo_fade_seconds,
)
self._btn_play.setText("")
def _prev_song(self):
@@ -853,33 +865,34 @@ class MainWindow(QMainWindow):
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 i den aktive navngivne liste."""
"""Gem event-fremgang (afspillet/sprunget over) til den navngivne liste."""
try:
from local.local_db import get_db
songs = self._playlist_panel.get_songs()
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:
# Find den aktive liste (ikke __aktiv__)
pl = conn.execute(
"SELECT id FROM playlists WHERE name != '__aktiv__' "
"ORDER BY created_at DESC LIMIT 1"
).fetchone()
if not pl:
return
# Opdater status for hver sang i listen
for i, (song, status) in enumerate(zip(songs, statuses)):
conn.execute("""
UPDATE playlist_songs SET status=?
WHERE playlist_id=? AND song_id=?
""", (status, pl["id"], song.get("id")))
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:
print(f"Event-status sync fejl: {e}")
pass
def _on_state_changed(self, state: str):
if state == "playing":
@@ -900,6 +913,9 @@ class MainWindow(QMainWindow):
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 ──────────────────────────────────────────────────────────────────

View File

@@ -32,6 +32,7 @@ class PlaylistPanel(QWidget):
self._current_idx = -1
self._song_ended = False
self._active_playlist_id: int | None = None
self._named_playlist_id: int | None = None # den indlæste/gemte navngivne liste
self._build_ui()
self.setAcceptDrops(True)
# Autogem-timer — venter 800ms efter sidst ændring
@@ -229,7 +230,7 @@ class PlaylistPanel(QWidget):
from local.local_db import save_event_state
save_event_state(self._current_idx, self._statuses)
except Exception as e:
print(f"Event-state gem fejl: {e}")
pass
def _trigger_event_state_save(self):
self._event_state_timer.start()
@@ -250,9 +251,12 @@ class PlaylistPanel(QWidget):
self._refresh()
return True
except Exception as e:
print(f"Event-state gendan fejl: {e}")
pass
return False
def get_named_playlist_id(self) -> int | None:
return self._named_playlist_id
def next_playable_idx(self) -> int | None:
"""Find første sang fra toppen der ikke er 'skipped' eller 'played'."""
for i in range(len(self._songs)):
@@ -286,7 +290,7 @@ class PlaylistPanel(QWidget):
self.playlist_changed.emit()
except Exception as e:
self._lbl_autosave.setText(f"⚠ gemfejl")
print(f"Autogem fejl: {e}")
pass
def restore_active_playlist(self):
"""Indlæs den sidst aktive liste ved opstart."""
@@ -324,7 +328,7 @@ class PlaylistPanel(QWidget):
self._lbl_autosave.setText("✓ gendannet")
return True
except Exception as e:
print(f"Gendan aktiv liste fejl: {e}")
pass
return False
# ── Ny / Gem som / Hent ───────────────────────────────────────────────────
@@ -362,6 +366,7 @@ class PlaylistPanel(QWidget):
for i, song in enumerate(self._songs, start=1):
if song.get("id"):
add_song_to_playlist(pl_id, song["id"], position=i)
self._named_playlist_id = pl_id
self._title_label.setText(f"DANSELISTE — {name.upper()}")
self._lbl_autosave.setText(f"✓ gemt som \"{name}\"")
except Exception as e:
@@ -400,11 +405,12 @@ class PlaylistPanel(QWidget):
from local.local_db import get_db
with get_db() as conn:
songs_raw = conn.execute("""
SELECT s.*, ps.position FROM playlist_songs ps
SELECT s.*, ps.position, ps.status FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id=? ORDER BY ps.position
""", (pl_id,)).fetchall()
songs = []
statuses = []
for row in songs_raw:
dances = conn.execute(
"SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
@@ -418,7 +424,16 @@ class PlaylistPanel(QWidget):
"file_missing": bool(row["file_missing"]),
"dances": [d["dance_name"] for d in dances],
})
self.load_songs(songs, name=pl_name)
statuses.append(row["status"] or "pending")
self._songs = songs
self._statuses = statuses
self._current_idx = -1
self._song_ended = False
self._named_playlist_id = pl_id
self._title_label.setText(f"DANSELISTE — {pl_name.upper()}")
self._lbl_autosave.setText("✓ gendannet")
self._refresh()
self._trigger_autosave()
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke indlæse listen: {e}")

View File

@@ -13,36 +13,41 @@ from PyQt6.QtCore import Qt, QSettings
SETTINGS_KEY_THEME = "appearance/dark_theme"
SETTINGS_KEY_DEMO_SEC = "playback/demo_seconds"
SETTINGS_KEY_MAIL_CLIENT = "mail/client" # "auto"|"thunderbird"|"outlook"|"mailto"
SETTINGS_KEY_DEMO_FADE = "playback/demo_fade_seconds"
SETTINGS_KEY_VOLUME = "playback/volume"
SETTINGS_KEY_MAIL_CLIENT = "mail/client"
SETTINGS_KEY_MAIL_PATH = "mail/custom_path"
SETTINGS_KEY_AUTO_LOGIN = "online/auto_login"
SETTINGS_KEY_USERNAME = "online/username"
SETTINGS_KEY_PASSWORD = "online/password" # gemt i klartekst — ikke ideelt, men funktionelt
SETTINGS_KEY_PASSWORD = "online/password"
def load_settings() -> dict:
"""Indlæs alle indstillinger med fornuftige standardværdier."""
s = QSettings("LineDance", "Player")
return {
"dark_theme": s.value(SETTINGS_KEY_THEME, True, type=bool),
"demo_seconds": s.value(SETTINGS_KEY_DEMO_SEC, 10, type=int),
"mail_client": s.value(SETTINGS_KEY_MAIL_CLIENT, "auto"),
"mail_path": s.value(SETTINGS_KEY_MAIL_PATH, ""),
"auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool),
"username": s.value(SETTINGS_KEY_USERNAME, ""),
"password": s.value(SETTINGS_KEY_PASSWORD, ""),
"dark_theme": s.value(SETTINGS_KEY_THEME, True, type=bool),
"demo_seconds": s.value(SETTINGS_KEY_DEMO_SEC, 10, type=int),
"demo_fade_seconds": s.value(SETTINGS_KEY_DEMO_FADE, 5, type=int),
"volume": s.value(SETTINGS_KEY_VOLUME, 78, type=int),
"mail_client": s.value(SETTINGS_KEY_MAIL_CLIENT, "auto"),
"mail_path": s.value(SETTINGS_KEY_MAIL_PATH, ""),
"auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool),
"username": s.value(SETTINGS_KEY_USERNAME, ""),
"password": s.value(SETTINGS_KEY_PASSWORD, ""),
}
def save_settings(values: dict):
s = QSettings("LineDance", "Player")
s.setValue(SETTINGS_KEY_THEME, values.get("dark_theme", True))
s.setValue(SETTINGS_KEY_DEMO_SEC, values.get("demo_seconds", 10))
s.setValue(SETTINGS_KEY_MAIL_CLIENT, values.get("mail_client", "auto"))
s.setValue(SETTINGS_KEY_MAIL_PATH, values.get("mail_path", ""))
s.setValue(SETTINGS_KEY_AUTO_LOGIN, values.get("auto_login", False))
s.setValue(SETTINGS_KEY_USERNAME, values.get("username", ""))
s.setValue(SETTINGS_KEY_PASSWORD, values.get("password", ""))
s.setValue(SETTINGS_KEY_THEME, values.get("dark_theme", True))
s.setValue(SETTINGS_KEY_DEMO_SEC, values.get("demo_seconds", 10))
s.setValue(SETTINGS_KEY_DEMO_FADE, values.get("demo_fade_seconds", 5))
s.setValue(SETTINGS_KEY_VOLUME, values.get("volume", 78))
s.setValue(SETTINGS_KEY_MAIL_CLIENT, values.get("mail_client", "auto"))
s.setValue(SETTINGS_KEY_MAIL_PATH, values.get("mail_path", ""))
s.setValue(SETTINGS_KEY_AUTO_LOGIN, values.get("auto_login", False))
s.setValue(SETTINGS_KEY_USERNAME, values.get("username", ""))
s.setValue(SETTINGS_KEY_PASSWORD, values.get("password", ""))
class SettingsDialog(QDialog):
@@ -117,9 +122,21 @@ class SettingsDialog(QDialog):
self._spin_demo.setFixedWidth(140)
grp_layout.addRow("Forspil-længde:", self._spin_demo)
self._spin_fade = QSpinBox()
self._spin_fade.setRange(0, 15)
self._spin_fade.setSuffix(" sekunder (0 = ingen fade)")
self._spin_fade.setFixedWidth(220)
self._spin_fade.setToolTip(
"Fade-out tilføjes til forspillets længde.\n"
"F.eks. 10 sek forspil + 5 sek fade = 15 sek total.\n"
"Sæt til 0 for ingen fade."
)
grp_layout.addRow("Fade-ud:", self._spin_fade)
note = QLabel(
"Forspillet afspiller begyndelsen af sangen så arrangøren kan bekræfte\n"
"at det er den rigtige sang og dans inden eventet starter."
"at det er den rigtige sang og dans inden eventet starter.\n"
"Fade-ud tilføjes oven i forspillets længde og fades logaritmisk."
)
note.setObjectName("result_count")
note.setWordWrap(True)
@@ -224,6 +241,7 @@ class SettingsDialog(QDialog):
v = self._values
self._chk_dark.setChecked(v.get("dark_theme", True))
self._spin_demo.setValue(v.get("demo_seconds", 10))
self._spin_fade.setValue(v.get("demo_fade_seconds", 5))
# Mail
client = v.get("mail_client", "auto")
@@ -246,13 +264,14 @@ class SettingsDialog(QDialog):
def _save_and_close(self):
values = {
"dark_theme": self._chk_dark.isChecked(),
"demo_seconds": self._spin_demo.value(),
"mail_client": self._mail_combo.currentData(),
"mail_path": self._mail_path.text().strip(),
"auto_login": self._chk_auto_login.isChecked(),
"username": self._user_input.text().strip(),
"password": self._pass_input.text(),
"dark_theme": self._chk_dark.isChecked(),
"demo_seconds": self._spin_demo.value(),
"demo_fade_seconds": self._spin_fade.value(),
"mail_client": self._mail_combo.currentData(),
"mail_path": self._mail_path.text().strip(),
"auto_login": self._chk_auto_login.isChecked(),
"username": self._user_input.text().strip(),
"password": self._pass_input.text(),
}
save_settings(values)
self._values = values

View File

@@ -1,237 +1,160 @@
"""
tag_editor.py — Rediger danse og alternativ-danse med niveau og autoudfyld.
tag_editor.py — Simpel og robust dans-tag editor.
Fire sektioner:
Mine danse | Fællesskabets danse
Mine alternativer | Fællesskabets alternativer
Danse gemmes til MP3-filen via mutagen.
Niveau og alternativ-danse gemmes til SQLite.
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QListWidget, QListWidgetItem, QFrame,
QSplitter, QWidget, QMessageBox, QComboBox, QCompleter,
QGridLayout, QGroupBox,
QPushButton, QComboBox, QWidget, QMessageBox, QGroupBox,
QScrollArea, QFrame, QGridLayout,
)
from PyQt6.QtCore import Qt, QTimer, QStringListModel, pyqtSignal
from PyQt6.QtGui import QColor
from PyQt6.QtCore import Qt, QTimer, QStringListModel
from PyQt6.QtWidgets import QCompleter
class AutoCompleteLineEdit(QLineEdit):
"""QLineEdit med autoudfyld fra dans-navne databasen."""
# ── Autoudfyld søgefelt ───────────────────────────────────────────────────────
def __init__(self, placeholder: str = "", parent=None):
class AutoLineEdit(QLineEdit):
def __init__(self, placeholder="", parent=None):
super().__init__(parent)
self.setPlaceholderText(placeholder)
self._completer_model = QStringListModel()
self._completer = QCompleter(self._completer_model, self)
self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self._completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
self._completer.setMaxVisibleItems(12)
self.setCompleter(self._completer)
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.setInterval(150)
self._timer.timeout.connect(self._update_suggestions)
self.textChanged.connect(lambda _: self._timer.start())
self._model = QStringListModel()
comp = QCompleter(self._model, self)
comp.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
comp.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
comp.setMaxVisibleItems(10)
self.setCompleter(comp)
t = QTimer(self)
t.setSingleShot(True)
t.setInterval(200)
t.timeout.connect(self._suggest)
self.textChanged.connect(lambda _: t.start())
self._timer = t
def _update_suggestions(self):
def _suggest(self):
prefix = self.text().strip()
if len(prefix) < 1:
if not prefix:
return
try:
from local.local_db import get_dance_name_suggestions
names = get_dance_name_suggestions(prefix, limit=20)
self._completer_model.setStringList(names)
self._model.setStringList(get_dance_name_suggestions(prefix))
except Exception:
pass
class DanceRow(QWidget):
"""Én dans med navn og niveau-dropdown."""
removed = pyqtSignal()
# ── Niveau dropdown ───────────────────────────────────────────────────────────
def __init__(self, dance_name: str = "", level_id: int | None = None,
levels: list = [], readonly: bool = False, parent=None):
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 2, 0, 2)
layout.setSpacing(6)
if readonly:
self._name_lbl = QLabel(dance_name)
self._name_lbl.setObjectName("track_meta")
layout.addWidget(self._name_lbl, stretch=1)
else:
self._name_edit = AutoCompleteLineEdit("Dansenavn...", self)
self._name_edit.setText(dance_name)
layout.addWidget(self._name_edit, stretch=1)
self._level_combo = QComboBox()
self._level_combo.addItem("— intet niveau —", None)
self._level_data = [None]
for lvl in levels:
self._level_combo.addItem(lvl["name"], lvl["id"])
self._level_data.append(lvl["id"])
if level_id is not None:
for i, lid in enumerate(self._level_data):
if lid == level_id:
self._level_combo.setCurrentIndex(i)
break
self._level_combo.setFixedWidth(130)
self._level_combo.setEnabled(not readonly)
layout.addWidget(self._level_combo)
if not readonly:
btn_rm = QPushButton("")
btn_rm.setFixedSize(24, 24)
btn_rm.clicked.connect(self.removed.emit)
layout.addWidget(btn_rm)
def get_name(self) -> str:
if hasattr(self, "_name_edit"):
return self._name_edit.text().strip()
return self._name_lbl.text()
def get_level_id(self) -> int | None:
return self._level_combo.currentData()
def make_level_combo(levels: list, current_id=None) -> QComboBox:
cb = QComboBox()
cb.addItem("— intet niveau —", None)
for lvl in levels:
cb.addItem(lvl["name"], lvl["id"])
if current_id is not None:
for i in range(cb.count()):
if cb.itemData(i) == current_id:
cb.setCurrentIndex(i)
break
cb.setFixedWidth(130)
return cb
class AltRow(QWidget):
"""Én alternativ-dans med navn, niveau og note."""
removed = pyqtSignal()
copy_to_mine = pyqtSignal(str, object, str) # name, level_id, note
def __init__(self, alt_name: str = "", level_id: int | None = None,
note: str = "", levels: list = [],
readonly: bool = False, source: str = "local",
rating: float = 0, rating_count: int = 0, parent=None):
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 2, 0, 2)
layout.setSpacing(6)
if readonly:
lbl = QLabel(f"{alt_name}")
lbl.setObjectName("track_meta")
layout.addWidget(lbl, stretch=1)
if rating_count > 0:
stars = "" * round(rating) + "" * (5 - round(rating))
lbl_r = QLabel(f"{stars} ({rating_count})")
lbl_r.setObjectName("result_count")
layout.addWidget(lbl_r)
else:
prefix_lbl = QLabel("")
prefix_lbl.setObjectName("track_meta")
layout.addWidget(prefix_lbl)
self._name_edit = AutoCompleteLineEdit("Alternativ dansenavn...", self)
self._name_edit.setText(alt_name)
layout.addWidget(self._name_edit, stretch=1)
self._level_combo = QComboBox()
self._level_combo.addItem("— niveau —", None)
self._level_data = [None]
for lvl in levels:
self._level_combo.addItem(lvl["name"], lvl["id"])
self._level_data.append(lvl["id"])
if level_id is not None:
for i, lid in enumerate(self._level_data):
if lid == level_id:
self._level_combo.setCurrentIndex(i)
break
self._level_combo.setFixedWidth(120)
self._level_combo.setEnabled(not readonly)
layout.addWidget(self._level_combo)
if readonly:
btn_copy = QPushButton("← Kopier")
btn_copy.setFixedHeight(22)
btn_copy.clicked.connect(
lambda: self.copy_to_mine.emit(alt_name, self._level_combo.currentData(), note)
)
layout.addWidget(btn_copy)
else:
self._note_edit = QLineEdit()
self._note_edit.setPlaceholderText("note...")
self._note_edit.setText(note)
self._note_edit.setFixedWidth(100)
layout.addWidget(self._note_edit)
btn_rm = QPushButton("")
btn_rm.setFixedSize(24, 24)
btn_rm.clicked.connect(self.removed.emit)
layout.addWidget(btn_rm)
def get_name(self) -> str:
if hasattr(self, "_name_edit"):
return self._name_edit.text().strip()
return ""
def get_level_id(self) -> int | None:
return self._level_combo.currentData()
def get_note(self) -> str:
if hasattr(self, "_note_edit"):
return self._note_edit.text().strip()
return ""
# ── Hoved-dialog ─────────────────────────────────────────────────────────────
class TagEditorDialog(QDialog):
def __init__(self, song: dict, parent=None):
super().__init__(parent)
self._song = song
self._levels = []
self._my_dance_rows: list[DanceRow] = []
self._my_alt_rows: list[AltRow] = []
self.setWindowTitle(f"Rediger tags — {song.get('title','')}")
self.setMinimumSize(860, 620)
self._song = song
self._levels = []
self._dances = [] # list of {name, level_id, db_id}
self._alts = [] # list of {name, level_id, note}
self.setWindowTitle(f"Rediger tags — {song.get('title', '')}")
self.setMinimumSize(720, 500)
self.resize(820, 580)
self._load_levels()
self._load_existing()
self._build_ui()
self._load_data()
# ── Indlæsning ────────────────────────────────────────────────────────────
def _load_levels(self):
try:
from local.local_db import get_dance_levels
self._levels = [dict(r) for r in get_dance_levels()]
except Exception:
except Exception as e:
pass # log fejl
self._levels = []
def _load_existing(self):
"""Indlæs eksisterende danse og alternativer fra DB."""
try:
from local.local_db import new_conn
conn = new_conn()
song_id = self._song.get("id")
rows = conn.execute(
"SELECT id, dance_name, level_id FROM song_dances "
"WHERE song_id=? ORDER BY dance_order",
(song_id,)
).fetchall()
for row in rows:
for row in rows:
alts = conn.execute(
"SELECT alt_dance_name, level_id, note FROM dance_alternatives "
"WHERE song_dance_id=? AND source='local'",
(row["id"],)
).fetchall()
self._dances.append({
"name": row["dance_name"],
"level_id": row["level_id"],
"db_id": row["id"],
})
for alt in alts:
self._alts.append({
"name": alt["alt_dance_name"],
"level_id": alt["level_id"],
"note": alt["note"] or "",
})
conn.close()
except Exception as e:
pass # log fejl
# ── UI ────────────────────────────────────────────────────────────────────
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(10)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
# ── Sang-info ─────────────────────────────────────────────────────────
# Sang-info
info = QFrame()
info.setObjectName("track_display")
info_layout = QHBoxLayout(info)
info_layout.setContentsMargins(10, 8, 10, 8)
title_col = QVBoxLayout()
lbl_title = QLabel(self._song.get("title", ""))
lbl_title.setObjectName("track_title")
title_col.addWidget(lbl_title)
meta = f"{self._song.get('artist','')} · {self._song.get('bpm',0)} BPM · {self._song.get('file_format','').upper()}"
lbl_meta = QLabel(meta)
lbl_meta.setObjectName("track_meta")
title_col.addWidget(lbl_meta)
can_write = self._song.get("file_format","").lower() in ("mp3","flac","ogg","opus","m4a")
lbl_write = QLabel("✓ Tags skrives til filen" if can_write else "⚠ Tags gemmes kun i database")
lbl_write.setObjectName("result_count")
title_col.addWidget(lbl_write)
info_layout.addLayout(title_col, stretch=1)
il = QHBoxLayout(info)
il.setContentsMargins(10, 8, 10, 8)
lbl_t = QLabel(self._song.get("title", ""))
lbl_t.setObjectName("track_title")
il.addWidget(lbl_t, stretch=1)
fmt = self._song.get("file_format", "").lower()
can_write = fmt in ("mp3", "flac", "ogg", "opus", "m4a")
lbl_w = QLabel("✓ Danse skrives til filen" if can_write
else "⚠ Dette format understøtter ikke fil-skrivning")
lbl_w.setObjectName("result_count")
il.addWidget(lbl_w)
layout.addWidget(info)
# ── Fire paneler i 2x2 grid ───────────────────────────────────────────
grid = QWidget()
grid_layout = QGridLayout(grid)
grid_layout.setSpacing(8)
# To kolonner
cols = QHBoxLayout()
cols.setSpacing(12)
cols.addWidget(self._build_dances_panel())
cols.addWidget(self._build_alts_panel())
layout.addLayout(cols, stretch=1)
grid_layout.addWidget(self._build_my_dances_panel(), 0, 0)
grid_layout.addWidget(self._build_community_dances_panel(), 0, 1)
grid_layout.addWidget(self._build_my_alts_panel(), 1, 0)
grid_layout.addWidget(self._build_community_alts_panel(), 1, 1)
layout.addWidget(grid, stretch=1)
# ── Knapper ───────────────────────────────────────────────────────────
# Knapper
btn_row = QHBoxLayout()
btn_row.addStretch()
btn_cancel = QPushButton("Annuller")
@@ -243,202 +166,262 @@ class TagEditorDialog(QDialog):
btn_row.addWidget(btn_save)
layout.addLayout(btn_row)
# ── Mine danse ────────────────────────────────────────────────────────────
def _build_my_dances_panel(self) -> QGroupBox:
grp = QGroupBox("Mine danse")
def _build_dances_panel(self) -> QGroupBox:
grp = QGroupBox("Danse")
layout = QVBoxLayout(grp)
layout.setSpacing(4)
self._my_dances_container = QVBoxLayout()
layout.addLayout(self._my_dances_container)
layout.addStretch()
# Scroll-område til eksisterende danse
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
container = QWidget()
self._dance_layout = QVBoxLayout(container)
self._dance_layout.setSpacing(4)
self._dance_layout.addStretch()
scroll.setWidget(container)
layout.addWidget(scroll, stretch=1)
# Udfyld med eksisterende
self._dance_rows = []
for d in self._dances:
self._add_dance_row(d["name"], d["level_id"])
# Tilføj-linje
add_row = QHBoxLayout()
self._new_dance = AutoLineEdit("Ny dans...", self)
self._new_dance.returnPressed.connect(self._on_add_dance)
add_row.addWidget(self._new_dance)
btn = QPushButton("+ Tilføj")
btn.setFixedWidth(70)
btn.clicked.connect(self._on_add_dance)
add_row.addWidget(btn)
layout.addLayout(add_row)
return grp
def _add_dance_row(self, name="", level_id=None):
row_widget = QWidget()
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(4)
name_edit = AutoLineEdit("Dans...", self)
name_edit.setText(name)
row_layout.addWidget(name_edit, stretch=1)
level_cb = make_level_combo(self._levels, level_id)
row_layout.addWidget(level_cb)
btn_rm = QPushButton("")
btn_rm.setFixedSize(24, 24)
row_layout.addWidget(btn_rm)
# Indsæt FØR stretch
idx = self._dance_layout.count() - 1
self._dance_layout.insertWidget(idx, row_widget)
entry = {"widget": row_widget, "name": name_edit, "level": level_cb}
self._dance_rows.append(entry)
btn_rm.clicked.connect(lambda: self._remove_dance_row(entry))
def _remove_dance_row(self, entry):
self._dance_rows.remove(entry)
entry["widget"].deleteLater()
def _on_add_dance(self):
name = self._new_dance.text().strip()
if name:
self._add_dance_row(name)
self._new_dance.clear()
def _build_alts_panel(self) -> QGroupBox:
grp = QGroupBox("Alternativ-danse")
layout = QVBoxLayout(grp)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
container = QWidget()
self._alt_layout = QVBoxLayout(container)
self._alt_layout.setSpacing(4)
self._alt_layout.addStretch()
scroll.setWidget(container)
layout.addWidget(scroll, stretch=1)
self._alt_rows = []
for a in self._alts:
self._add_alt_row(a["name"], a["level_id"], a["note"])
add_row = QHBoxLayout()
self._new_dance_input = AutoCompleteLineEdit("Ny dans...", self)
self._new_dance_input.returnPressed.connect(self._add_my_dance)
add_row.addWidget(self._new_dance_input)
btn_add = QPushButton("+ Tilføj")
btn_add.clicked.connect(self._add_my_dance)
add_row.addWidget(btn_add)
self._new_alt = AutoLineEdit("Nyt alternativ...", self)
self._new_alt.returnPressed.connect(self._on_add_alt)
add_row.addWidget(self._new_alt)
btn = QPushButton("+ Tilføj")
btn.setFixedWidth(70)
btn.clicked.connect(self._on_add_alt)
add_row.addWidget(btn)
layout.addLayout(add_row)
return grp
def _add_my_dance(self, name: str = "", level_id=None):
n = name or self._new_dance_input.text().strip()
if not n:
return
row = DanceRow(n, level_id, self._levels, readonly=False, parent=self)
row.removed.connect(lambda r=row: self._remove_dance_row(r))
self._my_dance_rows.append(row)
self._my_dances_container.addWidget(row)
self._new_dance_input.clear()
def _add_alt_row(self, name="", level_id=None, note=""):
row_widget = QWidget()
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(4)
def _remove_dance_row(self, row: DanceRow):
self._my_dance_rows.remove(row)
self._my_dances_container.removeWidget(row)
row.deleteLater()
lbl = QLabel("")
lbl.setObjectName("track_meta")
row_layout.addWidget(lbl)
# ── Fællesskabets danse ───────────────────────────────────────────────────
name_edit = AutoLineEdit("Dans...", self)
name_edit.setText(name)
row_layout.addWidget(name_edit, stretch=1)
def _build_community_dances_panel(self) -> QGroupBox:
grp = QGroupBox("Fællesskabets danse")
layout = QVBoxLayout(grp)
self._community_dances_container = QVBoxLayout()
layout.addLayout(self._community_dances_container)
layout.addStretch()
lbl = QLabel("Kræver online forbindelse")
lbl.setObjectName("result_count")
layout.addWidget(lbl)
return grp
level_cb = make_level_combo(self._levels, level_id)
row_layout.addWidget(level_cb)
# ── Mine alternativer ─────────────────────────────────────────────────────
note_edit = QLineEdit()
note_edit.setPlaceholderText("note...")
note_edit.setText(note)
note_edit.setFixedWidth(80)
row_layout.addWidget(note_edit)
def _build_my_alts_panel(self) -> QGroupBox:
grp = QGroupBox("Mine alternativ-danse")
layout = QVBoxLayout(grp)
layout.setSpacing(4)
self._my_alts_container = QVBoxLayout()
layout.addLayout(self._my_alts_container)
layout.addStretch()
btn_rm = QPushButton("")
btn_rm.setFixedSize(24, 24)
row_layout.addWidget(btn_rm)
add_row = QHBoxLayout()
self._new_alt_input = AutoCompleteLineEdit("Alternativ dansenavn...", self)
self._new_alt_input.returnPressed.connect(self._add_my_alt)
add_row.addWidget(self._new_alt_input)
btn_add = QPushButton("+ Tilføj")
btn_add.clicked.connect(self._add_my_alt)
add_row.addWidget(btn_add)
layout.addLayout(add_row)
return grp
idx = self._alt_layout.count() - 1
self._alt_layout.insertWidget(idx, row_widget)
def _add_my_alt(self, name: str = "", level_id=None, note: str = ""):
n = name or self._new_alt_input.text().strip()
if not n:
return
row = AltRow(n, level_id, note, self._levels, readonly=False, parent=self)
row.removed.connect(lambda r=row: self._remove_alt_row(r))
self._my_alt_rows.append(row)
self._my_alts_container.addWidget(row)
self._new_alt_input.clear()
entry = {"widget": row_widget, "name": name_edit,
"level": level_cb, "note": note_edit}
self._alt_rows.append(entry)
btn_rm.clicked.connect(lambda: self._remove_alt_row(entry))
def _remove_alt_row(self, row: AltRow):
self._my_alt_rows.remove(row)
self._my_alts_container.removeWidget(row)
row.deleteLater()
def _remove_alt_row(self, entry):
self._alt_rows.remove(entry)
entry["widget"].deleteLater()
# ── Fællesskabets alternativer ────────────────────────────────────────────
def _build_community_alts_panel(self) -> QGroupBox:
grp = QGroupBox("Fællesskabets alternativ-danse")
layout = QVBoxLayout(grp)
self._community_alts_container = QVBoxLayout()
layout.addLayout(self._community_alts_container)
layout.addStretch()
lbl = QLabel("Kræver online forbindelse")
lbl.setObjectName("result_count")
layout.addWidget(lbl)
return grp
# ── Indlæs eksisterende data ──────────────────────────────────────────────
def _load_data(self):
try:
from local.local_db import get_db, get_alternatives_for_dance
song_id = self._song.get("id")
with get_db() as conn:
dances = conn.execute(
"SELECT id, dance_name, dance_order, level_id FROM song_dances "
"WHERE song_id=? ORDER BY dance_order",
(song_id,)
).fetchall()
for d in dances:
self._add_my_dance(d["dance_name"], d["level_id"])
# Indlæs alternativer for denne dans
alts = get_alternatives_for_dance(d["id"])
for alt in alts:
if alt["source"] == "local":
self._add_my_alt(
alt["alt_dance_name"],
alt["level_id"],
alt["note"],
)
else:
# Community-alternativ
row = AltRow(
alt["alt_dance_name"], alt["level_id"],
alt["note"], self._levels,
readonly=True, source="community",
parent=self,
)
row.copy_to_mine.connect(self._add_my_alt)
self._community_alts_container.addWidget(row)
except Exception as e:
print(f"Tag editor load fejl: {e}")
def _on_add_alt(self):
name = self._new_alt.text().strip()
if name:
self._add_alt_row(name)
self._new_alt.clear()
# ── Gem ───────────────────────────────────────────────────────────────────
def _save(self):
song_id = self._song.get("id")
import uuid
song_id = self._song.get("id")
local_path = self._song.get("local_path", "")
# Saml data fra UI
dances = []
for row in self._dance_rows:
name = row["name"].text().strip()
if name:
dances.append((name, row["level"].currentData()))
alts = []
for row in self._alt_rows:
name = row["name"].text().strip()
if name:
alts.append((name, row["level"].currentData(),
row["note"].text().strip()))
try:
from local.local_db import get_db, register_dance_name, add_alternative
from local.local_db import new_conn
from local.tag_reader import write_dances, can_write_dances
import uuid
# Saml danse fra UI
dances = [(r.get_name(), r.get_level_id())
for r in self._my_dance_rows if r.get_name()]
conn = new_conn()
# Slet gammelt
old = conn.execute(
"SELECT id FROM song_dances WHERE song_id=?", (song_id,)
).fetchall()
for o in old:
conn.execute(
"DELETE FROM dance_alternatives WHERE song_dance_id=?",
(o["id"],)
)
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
# Indsæt danse
dance_ids = []
with get_db() as conn:
# Slet eksisterende danse og alternativer
old_dances = conn.execute(
"SELECT id FROM song_dances WHERE song_id=?", (song_id,)
).fetchall()
for od in old_dances:
conn.execute("DELETE FROM dance_alternatives WHERE song_dance_id=?", (od["id"],))
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
for i, (name, level_id) in enumerate(dances, 1):
conn.execute(
"INSERT INTO song_dances "
"(song_id, dance_name, dance_order, level_id) VALUES (?,?,?,?)",
(song_id, name, i, level_id)
)
row = conn.execute(
"SELECT id FROM song_dances "
"WHERE song_id=? AND dance_order=?", (song_id, i)
).fetchone()
dance_ids.append(row["id"])
# Indsæt nye danse og hent IDs
for i, (name, level_id) in enumerate(dances, start=1):
# Opdater dance_names
existing = conn.execute(
"SELECT id FROM dance_names WHERE name=? COLLATE NOCASE",
(name,)
).fetchone()
if existing:
conn.execute(
"INSERT INTO song_dances (song_id, dance_name, dance_order, level_id) "
"VALUES (?,?,?,?)",
(song_id, name, i, level_id)
"UPDATE dance_names SET use_count=use_count+1 WHERE id=?",
(existing["id"],)
)
else:
conn.execute(
"INSERT INTO dance_names (name, source, use_count) "
"VALUES (?,?,1)", (name, "local")
)
new_id = conn.execute(
"SELECT id FROM song_dances WHERE song_id=? AND dance_order=?",
(song_id, i)
).fetchone()["id"]
dance_ids.append(new_id)
register_dance_name(name)
# Indsæt alternativer knyttet til første dans
if dance_ids and self._my_alt_rows:
first_dance_id = dance_ids[0]
for row in self._my_alt_rows:
name = row.get_name()
if name:
import uuid as _uuid
conn.execute("""
INSERT INTO dance_alternatives
(id, song_dance_id, alt_dance_name, level_id, note, source)
VALUES (?,?,?,?,?,'local')
""", (str(_uuid.uuid4()), first_dance_id,
name, row.get_level_id(), row.get_note()))
register_dance_name(name)
# Indsæt alternativer første dans
if dance_ids and alts:
fid = dance_ids[0]
for alt_name, alt_level, alt_note in alts:
conn.execute(
"INSERT INTO dance_alternatives "
"(id, song_dance_id, alt_dance_name, level_id, note, source) "
"VALUES (?,?,?,?,?,'local')",
(str(uuid.uuid4()), fid, alt_name, alt_level, alt_note)
)
existing = conn.execute(
"SELECT id FROM dance_names WHERE name=? COLLATE NOCASE",
(alt_name,)
).fetchone()
if existing:
conn.execute(
"UPDATE dance_names SET use_count=use_count+1 WHERE id=?",
(existing["id"],)
)
else:
conn.execute(
"INSERT INTO dance_names (name, source, use_count) "
"VALUES (?,?,1)", (alt_name, "local")
)
# Skriv til fil
if local_path and can_write_dances(local_path):
dance_names = [n for n, _ in dances]
ok = write_dances(local_path, dance_names)
if not ok:
QMessageBox.warning(self, "Advarsel",
"Tags gemt i database, men kunne ikke skrives til filen.")
conn.commit()
"SELECT COUNT(*) FROM song_dances WHERE song_id=?", (song_id,)
conn.close()
# Skriv danse til filen
if local_path:
from local.tag_reader import write_dances, can_write_dances
if can_write_dances(local_path):
dance_names = [n for n, _ in dances]
if not write_dances(local_path, dance_names):
QMessageBox.warning(
self, "Advarsel",
"Gemt i database, men kunne ikke skrive til filen."
)
self.accept()
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme tags: {e}")
import traceback
traceback.print_exc()
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")