diff --git a/linedance-app/app_logger.py b/linedance-app/app_logger.py new file mode 100644 index 00000000..a1249700 --- /dev/null +++ b/linedance-app/app_logger.py @@ -0,0 +1,33 @@ +""" +app_logger.py β€” Central logging til fil i stedet for konsol. +P₯ 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") diff --git a/linedance-app/build_windows.spec b/linedance-app/build_windows.spec index d98dd1d2..e56deb62 100644 --- a/linedance-app/build_windows.spec +++ b/linedance-app/build_windows.spec @@ -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, diff --git a/linedance-app/local/__pycache__/__init__.cpython-312.pyc b/linedance-app/local/__pycache__/__init__.cpython-312.pyc index b47985a5..e3778912 100644 Binary files a/linedance-app/local/__pycache__/__init__.cpython-312.pyc and b/linedance-app/local/__pycache__/__init__.cpython-312.pyc differ diff --git a/linedance-app/local/__pycache__/local_db.cpython-312.pyc b/linedance-app/local/__pycache__/local_db.cpython-312.pyc index 17c6bce0..17f7ce47 100644 Binary files a/linedance-app/local/__pycache__/local_db.cpython-312.pyc and b/linedance-app/local/__pycache__/local_db.cpython-312.pyc differ diff --git a/linedance-app/local/local_db.py b/linedance-app/local/local_db.py index 464dc378..d1bbbcd1 100644 --- a/linedance-app/local/local_db.py +++ b/linedance-app/local/local_db.py @@ -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"): diff --git a/linedance-app/player/__pycache__/player.cpython-312.pyc b/linedance-app/player/__pycache__/player.cpython-312.pyc index 0e5a5622..9e97056f 100644 Binary files a/linedance-app/player/__pycache__/player.cpython-312.pyc and b/linedance-app/player/__pycache__/player.cpython-312.pyc differ diff --git a/linedance-app/player/player.py b/linedance-app/player/player.py index cba6d2fe..758077e8 100644 --- a/linedance-app/player/player.py +++ b/linedance-app/player/player.py @@ -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 diff --git a/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc b/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc index 43491e1d..a645a86a 100644 Binary files a/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc and b/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc differ diff --git a/linedance-app/ui/__pycache__/main_window.cpython-312.pyc b/linedance-app/ui/__pycache__/main_window.cpython-312.pyc index 196baa3c..79e44ac2 100644 Binary files a/linedance-app/ui/__pycache__/main_window.cpython-312.pyc and b/linedance-app/ui/__pycache__/main_window.cpython-312.pyc differ diff --git a/linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc b/linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc index 588b14e8..91523fd8 100644 Binary files a/linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc and b/linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc differ diff --git a/linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc b/linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc index c87a0a4d..11666ca1 100644 Binary files a/linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc and b/linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc differ diff --git a/linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc b/linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc index f4e36db1..0b10e7f7 100644 Binary files a/linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc and b/linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc differ diff --git a/linedance-app/ui/library_panel.py b/linedance-app/ui/library_panel.py index 62f6fa63..b30407da 100644 --- a/linedance-app/ui/library_panel.py +++ b/linedance-app/ui/library_panel.py @@ -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 ──────────────────────────────────────────────────────────── diff --git a/linedance-app/ui/main_window.py b/linedance-app/ui/main_window.py index e5c829cb..c28c622f 100644 --- a/linedance-app/ui/main_window.py +++ b/linedance-app/ui/main_window.py @@ -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 ────────────────────────────────────────────────────────────────── diff --git a/linedance-app/ui/playlist_panel.py b/linedance-app/ui/playlist_panel.py index 3e378989..ba1808d7 100644 --- a/linedance-app/ui/playlist_panel.py +++ b/linedance-app/ui/playlist_panel.py @@ -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}") diff --git a/linedance-app/ui/settings_dialog.py b/linedance-app/ui/settings_dialog.py index dcd7a3dc..c273519c 100644 --- a/linedance-app/ui/settings_dialog.py +++ b/linedance-app/ui/settings_dialog.py @@ -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 diff --git a/linedance-app/ui/tag_editor.py b/linedance-app/ui/tag_editor.py index 07e9d88d..1fd49040 100644 --- a/linedance-app/ui/tag_editor.py +++ b/linedance-app/ui/tag_editor.py @@ -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 pΓ₯ 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}")