diff --git a/linedance-app/local/file_watcher.py b/linedance-app/local/file_watcher.py index db739ae2..efc9e8f8 100644 --- a/linedance-app/local/file_watcher.py +++ b/linedance-app/local/file_watcher.py @@ -148,21 +148,98 @@ class LibraryWatcher: self._running = False def add_library(self, path: str) -> int: - """Tilføj et nyt bibliotek og start overvågning af det med det samme.""" + """Tilføj et nyt bibliotek — scanner i baggrundstråd med egen DB-forbindelse.""" library_id = add_library(path) if self._observer and self._running: handler = MusicLibraryHandler(library_id, self.on_change) self._observer.schedule(handler, path, recursive=True) - logger.info(f"Tilføjet bibliotek: {path}") - # Scan det nye bibliotek i baggrunden - threading.Thread( - target=self._full_scan_library, - args=(library_id, path), - daemon=True, - ).start() + # Scan i baggrundstråd med daemon=True så den ikke blokerer programlukning + def _scan_in_background(lib_id, lib_path): + try: + import sqlite3 + from local.local_db import DB_PATH, is_supported, get_file_modified_at + from local.tag_reader import read_tags + import os + # Åbn egen forbindelse — deler ikke med GUI-tråden + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + + base = Path(lib_path) + if not self._path_accessible(base): + conn.close() + return + + known = { + row["local_path"]: row["file_modified_at"] + for row in conn.execute( + "SELECT local_path, file_modified_at FROM songs WHERE library_id=?", + (lib_id,) + ).fetchall() + } + + processed = 0 + for dirpath, _, filenames in os.walk(str(base), followlinks=False): + for filename in filenames: + file_path = Path(dirpath) / filename + if not is_supported(file_path): + continue + path_str = str(file_path) + disk_modified = get_file_modified_at(file_path) + if path_str not in known or known[path_str] != disk_modified: + try: + tags = read_tags(file_path) + tags["library_id"] = lib_id + # Upsert via direkte SQL på denne forbindelse + import uuid, json + existing = conn.execute( + "SELECT id FROM songs WHERE local_path=?", + (path_str,) + ).fetchone() + extra = json.dumps(tags.get("extra_tags", {}), ensure_ascii=False) + if existing: + conn.execute(""" + UPDATE songs SET library_id=?, title=?, artist=?, + album=?, bpm=?, duration_sec=?, file_format=?, + file_modified_at=?, file_missing=0, extra_tags=? + WHERE id=? + """, (lib_id, tags.get("title",""), tags.get("artist",""), + tags.get("album",""), tags.get("bpm",0), + tags.get("duration_sec",0), tags.get("file_format",""), + disk_modified, extra, existing["id"])) + song_id = existing["id"] + else: + song_id = str(uuid.uuid4()) + conn.execute(""" + INSERT INTO songs (id, library_id, local_path, title, + artist, album, bpm, duration_sec, file_format, + file_modified_at, extra_tags) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + """, (song_id, lib_id, path_str, tags.get("title",""), + tags.get("artist",""), tags.get("album",""), + tags.get("bpm",0), tags.get("duration_sec",0), + tags.get("file_format",""), disk_modified, extra)) + conn.commit() + processed += 1 + if self.on_change: + self.on_change("upserted", path_str, song_id) + except Exception as e: + logger.error(f"Scan fejl: {file_path}: {e}") + + conn.execute( + "UPDATE libraries SET last_full_scan=datetime('now') WHERE id=?", + (lib_id,) + ) + conn.commit() + conn.close() + logger.info(f"Bibliotek scannet: {lib_path} — {processed} filer") + except Exception as e: + logger.error(f"Baggrunds-scan fejl: {e}") + + t = threading.Thread(target=_scan_in_background, args=(library_id, path), daemon=True) + t.start() return library_id def remove_library(self, library_id: int): @@ -182,69 +259,159 @@ class LibraryWatcher: self._observer.schedule(handler, str(path), recursive=True) def _full_scan_all(self): - """Kør fuld scan på alle aktive biblioteker.""" + """Kør fuld scan på alle aktive biblioteker — i baggrundstråde.""" for lib in get_libraries(active_only=True): path = Path(lib["path"]) if path.exists(): - self._full_scan_library(lib["id"], str(path)) + t = threading.Thread( + target=self._full_scan_library, + args=(lib["id"], str(path)), + daemon=True + ) + t.start() def _full_scan_library(self, library_id: int, library_path: str): - """ - Sammenligner filer på disk med SQLite og synkroniserer forskelle. - Håndterer utilgængelige mapper og symlinks sikkert. - """ + """Scan ét bibliotek med sin egen SQLite-forbindelse — blokerer aldrig GUI.""" + import sqlite3, uuid, json, os + from local.local_db import DB_PATH + from local.tag_reader import read_tags, is_supported, get_file_modified_at + logger.info(f"Fuld scan starter: {library_path}") base = Path(library_path) - # Tjek at mappen faktisk er tilgængelig — med timeout if not self._path_accessible(base): - logger.warning(f"Bibliotek ikke tilgængeligt (timeout eller ingen adgang): {library_path}") + logger.warning(f"Bibliotek ikke tilgængeligt: {library_path}") return - known = get_all_song_paths_for_library(library_id) - found_paths = set() - processed = 0 - errors = 0 + try: + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row - import os - for dirpath, dirnames, filenames in os.walk( - str(base), followlinks=False, - onerror=lambda e: logger.warning(f"Adgang nægtet: {e}") - ): - for filename in filenames: - file_path = Path(dirpath) / filename - try: + # Hent kendte stier og modified-tider + known = { + row["local_path"]: row["file_modified_at"] + for row in conn.execute( + "SELECT local_path, file_modified_at FROM songs WHERE library_id=?", + (library_id,) + ).fetchall() + } + + found_paths = set() + processed = 0 + + for dirpath, _, filenames in os.walk(str(base), followlinks=False): + for filename in filenames: + file_path = Path(dirpath) / filename if not is_supported(file_path): continue path_str = str(file_path) found_paths.add(path_str) - disk_modified = get_file_modified_at(file_path) + try: + disk_modified = get_file_modified_at(file_path) + if path_str in known and known[path_str] == disk_modified: + continue # uændret — skip - if path_str not in known or known[path_str] != disk_modified: tags = read_tags(file_path) - tags["library_id"] = library_id - upsert_song(tags) + extra = json.dumps(tags.get("extra_tags", {}), ensure_ascii=False) + existing = conn.execute( + "SELECT id FROM songs WHERE local_path=?", (path_str,) + ).fetchone() + + if existing: + song_id = existing["id"] + conn.execute(""" + UPDATE songs SET library_id=?, title=?, artist=?, + album=?, bpm=?, duration_sec=?, file_format=?, + file_modified_at=?, file_missing=0, extra_tags=? + WHERE id=? + """, (library_id, tags.get("title",""), tags.get("artist",""), + tags.get("album",""), tags.get("bpm",0), + tags.get("duration_sec",0), tags.get("file_format",""), + disk_modified, extra, song_id)) + else: + song_id = str(uuid.uuid4()) + conn.execute(""" + INSERT INTO songs (id, library_id, local_path, title, + artist, album, bpm, duration_sec, file_format, + file_modified_at, extra_tags) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + """, (song_id, library_id, path_str, + tags.get("title",""), tags.get("artist",""), + tags.get("album",""), tags.get("bpm",0), + tags.get("duration_sec",0), tags.get("file_format",""), + disk_modified, extra)) + + # Danse fra fil — merge med eksisterende niveau + file_dances = tags.get("dances", []) + if file_dances: + existing_dances = { + row["name"].lower(): row + for row in conn.execute(""" + SELECT d.id, d.name, sd.dance_order + FROM song_dances sd JOIN dances d ON d.id=sd.dance_id + WHERE sd.song_id=? + """, (song_id,)).fetchall() + } + for i, dance_name in enumerate(file_dances, 1): + if not dance_name: + continue + name_lower = dance_name.lower() + if name_lower in existing_dances: + conn.execute( + "UPDATE song_dances SET dance_order=? " + "WHERE song_id=? AND dance_id=?", + (i, song_id, existing_dances[name_lower]["id"]) + ) + else: + # Opret dans uden niveau + d = conn.execute( + "SELECT id FROM dances WHERE name=? COLLATE NOCASE " + "AND level_id IS NULL", (dance_name,) + ).fetchone() + if not d: + conn.execute( + "INSERT INTO dances (name, level_id, source) " + "VALUES (?,NULL,'local')", (dance_name,) + ) + d = conn.execute( + "SELECT id FROM dances WHERE name=? COLLATE NOCASE " + "AND level_id IS NULL", (dance_name,) + ).fetchone() + conn.execute( + "INSERT OR IGNORE INTO song_dances " + "(song_id, dance_id, dance_order) VALUES (?,?,?)", + (song_id, d["id"], i) + ) + + conn.commit() processed += 1 if self.on_change: - self.on_change("upserted", path_str, None) - except Exception as e: - logger.error(f"Scan-fejl for {file_path}: {e}") - errors += 1 + self.on_change("upserted", path_str, song_id) - # Marker forsvundne filer - missing_count = 0 - for known_path in known: - if known_path not in found_paths: - mark_song_missing(known_path) - missing_count += 1 - if self.on_change: - self.on_change("deleted", known_path, None) + except Exception as e: + logger.error(f"Scan-fejl for {file_path}: {e}") - update_library_scan_time(library_id) - logger.info( - f"Scan færdig: {library_path} — " - f"{processed} opdateret, {missing_count} mangler, {errors} fejl" - ) + # Markér forsvundne filer + for known_path in known: + if known_path not in found_paths: + conn.execute( + "UPDATE songs SET file_missing=1 WHERE local_path=?", + (known_path,) + ) + conn.commit() + if self.on_change: + self.on_change("deleted", known_path, None) + + conn.execute( + "UPDATE libraries SET last_full_scan=datetime('now') WHERE id=?", + (library_id,) + ) + conn.commit() + conn.close() + logger.info(f"Scan færdig: {library_path} — {processed} opdateret") + + except Exception as e: + logger.error(f"Scan fejl: {library_path}: {e}") def _path_accessible(self, path: Path, timeout_sec: float = 5.0) -> bool: """Tjek om en sti er tilgængelig inden for timeout.""" diff --git a/linedance-app/local/local_db.py b/linedance-app/local/local_db.py index 548116ca..a26a6fca 100644 --- a/linedance-app/local/local_db.py +++ b/linedance-app/local/local_db.py @@ -232,6 +232,25 @@ MIGRATIONS: dict[int, list[str]] = { SELECT DISTINCT dance_name, level_id, 'local' FROM song_dances WHERE dance_name IS NOT NULL AND dance_name != ''""", ], + 3: [ + "ALTER TABLE playlists ADD COLUMN tags TEXT NOT NULL DEFAULT ''", + ], + 4: [ + "ALTER TABLE dances ADD COLUMN choreographer TEXT NOT NULL DEFAULT ''", + "ALTER TABLE dances ADD COLUMN video_url TEXT NOT NULL DEFAULT ''", + "ALTER TABLE dances ADD COLUMN stepsheet_url TEXT NOT NULL DEFAULT ''", + "ALTER TABLE dances ADD COLUMN notes TEXT NOT NULL DEFAULT ''", + ], + 5: [ + # Workshop-markering på sang+dans kombination (ikke dans alene) + """ALTER TABLE song_dances ADD COLUMN is_workshop INTEGER NOT NULL DEFAULT 0""", + """ALTER TABLE song_alt_dances ADD COLUMN is_workshop INTEGER NOT NULL DEFAULT 0""", + ], + 6: [ + # Workshop og dans-valg på selve playlist-sangen + """ALTER TABLE playlist_songs ADD COLUMN is_workshop INTEGER NOT NULL DEFAULT 0""", + """ALTER TABLE playlist_songs ADD COLUMN dance_override TEXT NOT NULL DEFAULT ''""", + ], } @@ -283,11 +302,12 @@ def get_libraries(active_only: bool = True) -> list[sqlite3.Row]: def remove_library(library_id: int): with get_db() as conn: - # Marker sange som manglende + # Marker sange som manglende og løsriv dem fra biblioteket conn.execute( - "UPDATE songs SET file_missing=1 WHERE library_id=?", (library_id,) + "UPDATE songs SET file_missing=1, library_id=NULL WHERE library_id=?", + (library_id,) ) - # Slet biblioteket helt + # Nu kan biblioteket slettes uden FK-konflikt conn.execute("DELETE FROM libraries WHERE id=?", (library_id,)) @@ -452,20 +472,70 @@ def get_all_song_paths_for_library(library_id: int) -> dict[str, str]: # ── Afspilningslister ───────────────────────────────────────────────────────── -def create_playlist(name: str, description: str = "") -> int: +def create_playlist(name: str, description: str = "", tags: str = "") -> int: with get_db() as conn: cur = conn.execute( - "INSERT INTO playlists (name, description) VALUES (?,?)", - (name, description) + "INSERT INTO playlists (name, description, tags) VALUES (?,?,?)", + (name, description, tags) ) return cur.lastrowid -def get_playlists() -> list[sqlite3.Row]: +def update_playlist_tags(playlist_id: int, tags: str): with get_db() as conn: - return conn.execute( - "SELECT * FROM playlists ORDER BY created_at DESC" + conn.execute( + "UPDATE playlists SET tags=? WHERE id=?", + (tags, playlist_id) + ) + + +def get_all_playlist_tags() -> list[str]: + """Returnerer alle unikke tags på tværs af alle playlists, sorteret alfabetisk.""" + with get_db() as conn: + rows = conn.execute( + "SELECT tags FROM playlists WHERE tags != '' AND name != ?", + ("__aktiv__",) ).fetchall() + tags = set() + for row in rows: + for tag in row["tags"].split(","): + t = tag.strip().lower() + if t: + tags.add(t) + return sorted(tags) + + +def get_playlists(tag_filter: str | None = None) -> list[sqlite3.Row]: + """Hent alle navngivne playlists med sang-antal. Filtrer på tag hvis angivet.""" + with get_db() as conn: + if tag_filter: + rows = conn.execute(""" + SELECT p.*, COUNT(ps.id) as song_count + FROM playlists p + LEFT JOIN playlist_songs ps ON ps.playlist_id = p.id + WHERE p.name != ? AND ( + p.tags LIKE ? OR p.tags LIKE ? OR + p.tags LIKE ? OR p.tags = ? + ) + GROUP BY p.id + ORDER BY p.created_at DESC + """, ( + "__aktiv__", + f"{tag_filter},%", + f"%, {tag_filter},%", + f"%, {tag_filter}", + tag_filter, + )).fetchall() + else: + rows = conn.execute(""" + SELECT p.*, COUNT(ps.id) as song_count + FROM playlists p + LEFT JOIN playlist_songs ps ON ps.playlist_id = p.id + WHERE p.name != ? + GROUP BY p.id + ORDER BY p.created_at DESC + """, ("__aktiv__",)).fetchall() + return rows def add_song_to_playlist(playlist_id: int, song_id: str, position: int | None = None) -> int: @@ -554,6 +624,29 @@ def clear_event_state(): # ── Dans-entitet funktioner ─────────────────────────────────────────────────── +def get_dance(dance_id: int) -> sqlite3.Row | None: + with get_db() as conn: + return conn.execute( + "SELECT * FROM dances WHERE id=?", (dance_id,) + ).fetchone() + + +def update_dance_info(dance_id: int, choreographer: str = "", + video_url: str = "", stepsheet_url: str = "", + notes: str = ""): + with get_db() as conn: + conn.execute(""" + UPDATE dances SET + choreographer = ?, + video_url = ?, + stepsheet_url = ?, + notes = ? + WHERE id = ? + """, (choreographer.strip(), video_url.strip(), + stepsheet_url.strip(), notes.strip(), dance_id)) + + + def get_or_create_dance(name: str, level_id: int | None, conn=None) -> int: """Find eller opret en dans (name + level_id kombination). @@ -605,11 +698,12 @@ def get_dance_suggestions(prefix: str, limit: int = 20) -> list[dict]: def get_dances_for_song(song_id: str) -> list[dict]: - """Hent hoveddanse for en sang med niveau-info.""" + """Hent hoveddanse for en sang med niveau-info og workshop-flag.""" with get_db() as conn: rows = conn.execute(""" SELECT d.id as dance_id, d.name, d.level_id, - dl.name as level_name, sd.dance_order, sd.id as song_dance_id + dl.name as level_name, sd.dance_order, + sd.id as song_dance_id, sd.is_workshop FROM song_dances sd JOIN dances d ON d.id = sd.dance_id LEFT JOIN dance_levels dl ON dl.id = d.level_id diff --git a/linedance-app/main.py b/linedance-app/main.py index ad5f9af2..879ad6fa 100644 --- a/linedance-app/main.py +++ b/linedance-app/main.py @@ -8,7 +8,6 @@ Start: import sys import os -# Sørg for at rodmappen er i Python-stien sys.path.insert(0, os.path.dirname(__file__)) from PyQt6.QtWidgets import QApplication @@ -21,7 +20,13 @@ def main(): app.setApplicationName("LineDance Player") app.setOrganizationName("LineDance") - apply_theme(app, dark=True) + # Indlæs sprog fra indstillinger + from ui.settings_dialog import load_settings + from translations import load_language + settings = load_settings() + load_language(settings.get("language", "da")) + + apply_theme(app, dark=settings.get("dark_theme", True)) window = MainWindow() window.show() diff --git a/linedance-app/player/player.py b/linedance-app/player/player.py index 758077e8..393185bd 100644 --- a/linedance-app/player/player.py +++ b/linedance-app/player/player.py @@ -63,7 +63,9 @@ class Player(QObject): self._demo_mode = False if VLC_AVAILABLE and self._media_player: - media = self._instance.media_new(path) + # Konverter GVFS SMB-sti til SMB URL som VLC kan tilgå direkte + vlc_path = self._resolve_path(path) + media = self._instance.media_new(vlc_path) self._media_player.set_media(media) self._media_player.audio_set_volume(self._volume) @@ -71,6 +73,33 @@ class Player(QObject): self.time_changed.emit(0, self._duration) self.state_changed.emit("stopped") + def _resolve_path(self, path: str) -> str: + """Konverter platform-specifikke netværksstier til URL'er VLC kan bruge.""" + import re, sys + + # Linux GVFS SMB: /run/user/1000/gvfs/smb-share:server=X,share=Y/sti/fil.mp3 + m = re.match(r".*/gvfs/smb-share:server=([^,]+),share=([^/]+)(/.+)$", path) + if m: + server, share, rest = m.group(1), m.group(2), m.group(3) + return f"smb://{server}/{share}{rest}" + + # Windows UNC: \\server\share\sti\fil.mp3 + if path.startswith("\\\\"): + # \\server\share\rest → smb://server/share/rest + parts = path.replace("\\", "/").lstrip("/").split("/", 2) + if len(parts) >= 2: + server = parts[0] + share = parts[1] + rest = "/" + parts[2] if len(parts) > 2 else "" + return f"smb://{server}/{share}{rest}" + + # Lokale stier og drevbogstaver (C:\...) — VLC håndterer dem fint + return path + + self.position_changed.emit(0.0) + self.time_changed.emit(0, self._duration) + self.state_changed.emit("stopped") + # ── Transport ───────────────────────────────────────────────────────────── def play(self): diff --git a/linedance-app/setup.sh b/linedance-app/setup.sh new file mode 100755 index 00000000..32e49353 --- /dev/null +++ b/linedance-app/setup.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Kør fra LinedanceAfspiller/linedance-app/ mappen + +echo "=== LineDance Player Setup ===" + +# Opret venv hvis den ikke eksisterer +if [ ! -d "venv" ]; then + echo "Opretter virtual environment..." + python3 -m venv venv +fi + +# Aktiver venv +source venv/bin/activate + +# Installer afhængigheder +echo "Installerer afhængigheder..." +pip install --upgrade pip +pip install -r requirements.txt + +echo "" +echo "=== Færdig! ===" +echo "Start programmet med:" +echo " source venv/bin/activate" +echo " python main.py" \ No newline at end of file diff --git a/linedance-app/translations/__init__.py b/linedance-app/translations/__init__.py new file mode 100644 index 00000000..09394bb1 --- /dev/null +++ b/linedance-app/translations/__init__.py @@ -0,0 +1,57 @@ +""" +translations/__init__.py — Central oversættelsesfunktion. + +Brug: + from translations import _ + btn = QPushButton(_("btn.start_event")) + lbl = QLabel(_("level.beginner")) + +Fallback: hvis nøglen ikke findes returneres nøglen selv. +""" + +_current: dict[str, str] = {} +_current_lang: str = "da" + + +def load_language(lang: str = "da"): + """Indlæs sprogfil. Kaldes ved opstart og ved sprogskift i indstillinger.""" + global _current, _current_lang + _current_lang = lang + try: + if lang == "en": + from translations.en import STRINGS + else: + from translations.da import STRINGS + _current = STRINGS + except ImportError: + from translations.da import STRINGS + _current = STRINGS + + +def _(key: str, **kwargs) -> str: + """Oversæt en nøgle. Fallback = nøglen selv hvis ikke fundet.""" + text = _current.get(key, key) + return text.format(**kwargs) if kwargs else text + + +def current_lang() -> str: + return _current_lang + + +def translate_level(level_name: str | None) -> str: + """Oversæt et niveau-navn fra API/DB canonical navn til valgt sprog.""" + if not level_name: + return _("level.none") + key = f"level.{level_name.lower().replace(' ', '_').replace('ø', 'o').replace('æ', 'ae').replace('å', 'aa')}" + result = _current.get(key) + if result: + return result + # Fallback: prøv direkte match + for k, v in _current.items(): + if k.startswith("level.") and v.lower() == level_name.lower(): + return v + return level_name # helt rå fallback + + +# Indlæs dansk som standard ved import +load_language("da") diff --git a/linedance-app/translations/da.py b/linedance-app/translations/da.py new file mode 100644 index 00000000..e15b803c --- /dev/null +++ b/linedance-app/translations/da.py @@ -0,0 +1,201 @@ +"""Dansk oversættelse — standardsprog.""" + +STRINGS = { + # App + "app.title": "LineDance Player", + "app.ready": "Klar", + "app.no_song": "— Ingen sang indlæst —", + "app.playlist_done": "— Danseliste afsluttet —", + "app.no_dance_tagged": "ingen dans tagget", + + # Menu + "menu.file": "Filer", + "menu.go_online": "Gå online...", + "menu.go_offline": "Gå offline", + "menu.settings": "Indstillinger...", + "menu.quit": "Afslut", + + # Bibliotek + "library.title": "BIBLIOTEK", + "library.search": "Søg i titel, artist, album, dans...", + "library.songs": "{count} sange", + "library.song": "{count} sang", + "library.results": "{count} resultater for \"{query}\"", + "library.result": "{count} resultat for \"{query}\"", + "library.btn_bpm": "♩ BPM alle", + "library.btn_manage": "⚙ Mapper", + "library.bpm_scanning": "♩ {done}/{total}...", + "library.bpm_all_done": "♩ Alle har BPM", + "library.missing": "⚠ ", + "library.no_dance": "ingen dans tagget", + + # Mapper dialog + "folders.title": "Administrer musikbiblioteker", + "folders.active": "Aktive musikbiblioteker:", + "folders.note": "Når du fjerner et bibliotek, slettes det fra overvågningen.\nSangene forbliver i databasen men markeres som manglende (⚠).", + "folders.btn_add": "+ Tilføj mappe", + "folders.btn_remove": "✕ Fjern valgt", + "folders.btn_scan": "⟳ Scan alle", + "folders.btn_close": "Luk", + "folders.never_scanned": "aldrig", + "folders.not_found": " ⚠ mappe ikke fundet", + "folders.songs_count": "{count} sange · senest scannet: {date}", + "folders.confirm_remove": "Fjern overvågningen af:\n{path}\n\nSange i biblioteket forbliver i databasen men markeres som manglende.", + + # Danseliste + "playlist.title": "DANSELISTE", + "playlist.new_title": "DANSELISTE — NY", + "playlist.btn_new": "✚ Ny", + "playlist.btn_save": "💾 Gem som...", + "playlist.btn_load": "📂 Hent...", + "playlist.btn_start": "▶ START EVENT", + "playlist.progress": "{played} / {total} afspillet", + "playlist.not_saved": "● ikke gemt", + "playlist.saved": "✓ gemt", + "playlist.save_error": "⚠ gemfejl", + "playlist.restored": "✓ gendannet", + "playlist.saved_as": "✓ gemt som \"{name}\"", + "playlist.name_prompt": "Navn på danselisten:", + "playlist.name_dialog": "Gem danseliste", + "playlist.load_dialog": "Hent danseliste", + "playlist.load_choose": "Vælg en liste:", + "playlist.empty": "Danselisten er tom.", + "playlist.no_lists": "Ingen gemte danselister fundet.", + "playlist.confirm_new": "Ryd den aktuelle liste og start forfra?", + "playlist.confirm_event": "Dette nulstiller alle statusser i danselisten.\nFortsæt?", + "playlist.ready": "Klar: {title} — tryk ▶ for at starte", + "playlist.done": "Danselisten er afsluttet", + "playlist.event_ready": "Event klar: {title} — tryk ▶ for at starte", + "playlist.added": "Tilføjet til danseliste: {title}", + + # Kontekstmenu danseliste + "playlist.ctx_play": "▶ Afspil denne", + "playlist.ctx_skip": "— Spring over", + "playlist.ctx_unplay": "↺ Sæt til ikke afspillet", + "playlist.ctx_played": "✓ Sæt til afspillet", + "playlist.ctx_remove": "✕ Fjern fra liste", + + # Kontekstmenu bibliotek + "library.ctx_add": "Tilføj til danseliste", + "library.ctx_play": "Afspil", + "library.ctx_tags": "✎ Rediger dans-tags...", + "library.ctx_bpm": "♩ Analysér BPM", + "library.ctx_send": "Send til", + "library.ctx_mail": "✉ Send som mail", + "library.btn_danse": "Danse", + + # Afspiller + "player.no_song": "Ingen sang indlæst", + "player.loaded": "Indlæst: {title}", + "player.vol": "VOL", + "player.demo_btn": "▶\n{sec} SEK", + "player.event_resumed": "Event genoptaget ved: {title} — tryk ▶ for at fortsætte", + + # Transport-knapper (tooltips) + "player.btn_prev": "Forrige sang", + "player.btn_play": "Afspil / Pause", + "player.btn_stop": "Stop", + "player.btn_next": "Næste sang", + "player.btn_demo": "Afspil forspil", + + # Indstillinger + "settings.title": "Indstillinger", + "settings.tab_appearance": "🎨 Udseende", + "settings.tab_playback": "▶ Afspilning", + "settings.tab_mail": "✉ Mail", + "settings.tab_online": "🌐 Online", + "settings.tab_language": "🌍 Sprog", + "settings.btn_save": "💾 Gem indstillinger", + "settings.btn_cancel": "Annuller", + "settings.dark_theme": "Start med mørkt tema", + "settings.theme_note": "Du kan altid skifte tema mens programmet kører via topbar-knappen.", + "settings.demo_group": "Forspil (▶ N SEK knappen)", + "settings.demo_length": "Forspil-længde:", + "settings.demo_fade": "Fade-ud:", + "settings.demo_suffix": " sekunder", + "settings.fade_suffix": " sekunder (0 = ingen fade)", + "settings.demo_note": "Forspillet afspiller begyndelsen af sangen.\nFade-ud tilføjes oven i forspillets længde.", + "settings.mail_group": "Mailklient", + "settings.mail_label": "Klient:", + "settings.mail_path": "Sti:", + "settings.mail_auto": "Auto-detekter (Thunderbird → Outlook → mailto:)", + "settings.mail_tb": "Thunderbird", + "settings.mail_ol": "Outlook (Windows)", + "settings.mail_custom": "Brugerdefineret sti", + "settings.mail_mailto": "Kun mailto: (ingen vedhæftning)", + "settings.mail_note": "Med Thunderbird og Outlook åbnes et nyt compose-vindue med filen vedhæftet.", + "settings.online_group": "Automatisk login ved opstart", + "settings.auto_login": "Log automatisk ind når programmet starter", + "settings.username": "Brugernavn:", + "settings.password": "Kodeord:", + "settings.password_warn": "⚠ Kodeordet gemmes lokalt på denne computer.\nBrug kun dette på en personlig maskine.", + "settings.lang_group": "Sprog", + "settings.lang_label": "Programsprog:", + "settings.lang_note": "Sproget anvendes næste gang programmet startes.", + "settings.saved": "Indstillinger gemt", + + # Tag-editor + "tags.title": "Rediger tags — {title}", + "tags.can_write": "✓ Danse skrives til filen", + "tags.cant_write": "⚠ Dette format understøtter ikke fil-skrivning", + "tags.hint": "Skriv dansenavn — forslag vises som 'Navn / Niveau'. Vælg fra listen for at få niveau automatisk.", + "tags.dances": "Danse", + "tags.alts": "Alternativ-danse", + "tags.btn_add": "+ Tilføj", + "tags.btn_save": "💾 Gem tags", + "tags.btn_cancel": "Annuller", + "tags.new_dance": "Ny dans (f.eks. Cowboy Cha Cha)...", + "tags.new_alt": "Alternativ dans...", + "tags.note": "note...", + "tags.warn_file": "Gemt i database, men kunne ikke skrive til filen.", + "tags.error": "Kunne ikke gemme: {error}", + "tags.no_level": "— intet niveau —", + + # Niveauer + "level.none": "— intet niveau —", + "level.beginner": "Begynder", + "level.let_ovet": "Let øvet", + "level.easy": "Let øvet", + "level.ovet": "Øvet", + "level.intermediate": "Øvet", + "level.erfaren": "Erfaren", + "level.experienced": "Erfaren", + "level.ekspert": "Ekspert", + "level.expert": "Ekspert", + + # Online / login + "online.logging_in": "Logger ind som {username}...", + "online.logged_in": "Online som {username}", + "online.auto_login_fail": "Auto-login fejlede — kør Filer → Gå online manuelt", + "online.logged_out": "Offline", + "online.syncing": "Synkroniserer dans-data...", + "online.synced": "Synkroniseret {levels} niveauer og {names} dans-navne", + + # Scanning + "scan.preparing": "Starter scanning af biblioteker...", + "scan.scanning": "Scanner: {name}...", + "scan.scanning_count": "Scanner: {name} ({count} filer)...", + "scan.done": "Scan færdig — {count} filer gennemgået", + "scan.error": "Scan fejl: {error}", + "scan.folder_missing": "⚠ Mappe ikke fundet: {path}", + + # Fejl + "error.title": "Fejl", + "error.db_init": "Database fejl: {error}", + "error.folder_remove": "Kunne ikke fjerne: {error}", + "error.save_tags": "Kunne ikke gemme tags: {error}", + + # Mail + "mail.thunderbird_ok": "Thunderbird åbnet med {filename} vedh.", + "mail.outlook_ok": "Outlook åbnet med {filename} vedh.", + "mail.fallback": "Ingen kendt mailklient fundet — åbnet mailto: (uden vedhæftning)", + "mail.file_missing": "Filen blev ikke fundet — kan ikke sende mail", + + # Generelt + "btn.ok": "OK", + "btn.cancel": "Annuller", + "btn.close": "Luk", + "btn.yes": "Ja", + "btn.no": "Nej", + "dialog.confirm": "Bekræft", +} diff --git a/linedance-app/translations/en.py b/linedance-app/translations/en.py new file mode 100644 index 00000000..4cae35a8 --- /dev/null +++ b/linedance-app/translations/en.py @@ -0,0 +1,201 @@ +"""English translation.""" + +STRINGS = { + # App + "app.title": "LineDance Player", + "app.ready": "Ready", + "app.no_song": "— No song loaded —", + "app.playlist_done": "— Playlist finished —", + "app.no_dance_tagged": "no dance tagged", + + # Menu + "menu.file": "File", + "menu.go_online": "Go online...", + "menu.go_offline": "Go offline", + "menu.settings": "Settings...", + "menu.quit": "Quit", + + # Library + "library.title": "LIBRARY", + "library.search": "Search title, artist, album, dance...", + "library.songs": "{count} songs", + "library.song": "{count} song", + "library.results": "{count} results for \"{query}\"", + "library.result": "{count} result for \"{query}\"", + "library.btn_bpm": "♩ BPM all", + "library.btn_manage": "⚙ Folders", + "library.bpm_scanning": "♩ {done}/{total}...", + "library.bpm_all_done": "♩ All have BPM", + "library.missing": "⚠ ", + "library.no_dance": "no dance tagged", + + # Folders dialog + "folders.title": "Manage music libraries", + "folders.active": "Active music libraries:", + "folders.note": "When you remove a library, it is removed from monitoring.\nSongs remain in the database but are marked as missing (⚠).", + "folders.btn_add": "+ Add folder", + "folders.btn_remove": "✕ Remove selected", + "folders.btn_scan": "⟳ Scan all", + "folders.btn_close": "Close", + "folders.never_scanned": "never", + "folders.not_found": " ⚠ folder not found", + "folders.songs_count": "{count} songs · last scanned: {date}", + "folders.confirm_remove": "Remove monitoring of:\n{path}\n\nSongs remain in the database but will be marked as missing.", + + # Playlist + "playlist.title": "PLAYLIST", + "playlist.new_title": "PLAYLIST — NEW", + "playlist.btn_new": "✚ New", + "playlist.btn_save": "💾 Save as...", + "playlist.btn_load": "📂 Load...", + "playlist.btn_start": "▶ START EVENT", + "playlist.progress": "{played} / {total} played", + "playlist.not_saved": "● unsaved", + "playlist.saved": "✓ saved", + "playlist.save_error": "⚠ save error", + "playlist.restored": "✓ restored", + "playlist.saved_as": "✓ saved as \"{name}\"", + "playlist.name_prompt": "Playlist name:", + "playlist.name_dialog": "Save playlist", + "playlist.load_dialog": "Load playlist", + "playlist.load_choose": "Choose a playlist:", + "playlist.empty": "The playlist is empty.", + "playlist.no_lists": "No saved playlists found.", + "playlist.confirm_new": "Clear the current playlist and start over?", + "playlist.confirm_event": "This will reset all statuses in the playlist.\nContinue?", + "playlist.ready": "Ready: {title} — press ▶ to start", + "playlist.done": "Playlist finished", + "playlist.event_ready": "Event ready: {title} — press ▶ to start", + "playlist.added": "Added to playlist: {title}", + + # Playlist context menu + "playlist.ctx_play": "▶ Play this", + "playlist.ctx_skip": "— Skip", + "playlist.ctx_unplay": "↺ Mark as not played", + "playlist.ctx_played": "✓ Mark as played", + "playlist.ctx_remove": "✕ Remove from playlist", + + # Library context menu + "library.ctx_add": "Add to playlist", + "library.ctx_play": "Play", + "library.ctx_tags": "✎ Edit dance tags...", + "library.ctx_bpm": "♩ Analyse BPM", + "library.ctx_send": "Send to", + "library.ctx_mail": "✉ Send by email", + "library.btn_danse": "Dances", + + # Player + "player.no_song": "No song loaded", + "player.loaded": "Loaded: {title}", + "player.vol": "VOL", + "player.demo_btn": "▶\n{sec} SEC", + "player.event_resumed": "Event resumed at: {title} — press ▶ to continue", + + # Transport tooltips + "player.btn_prev": "Previous song", + "player.btn_play": "Play / Pause", + "player.btn_stop": "Stop", + "player.btn_next": "Next song", + "player.btn_demo": "Play preview", + + # Settings + "settings.title": "Settings", + "settings.tab_appearance": "🎨 Appearance", + "settings.tab_playback": "▶ Playback", + "settings.tab_mail": "✉ Mail", + "settings.tab_online": "🌐 Online", + "settings.tab_language": "🌍 Language", + "settings.btn_save": "💾 Save settings", + "settings.btn_cancel": "Cancel", + "settings.dark_theme": "Start with dark theme", + "settings.theme_note": "You can always switch theme while the program is running.", + "settings.demo_group": "Preview (▶ N SEC button)", + "settings.demo_length": "Preview length:", + "settings.demo_fade": "Fade-out:", + "settings.demo_suffix": " seconds", + "settings.fade_suffix": " seconds (0 = no fade)", + "settings.demo_note": "The preview plays the beginning of the song.\nFade-out is added on top of the preview length.", + "settings.mail_group": "Mail client", + "settings.mail_label": "Client:", + "settings.mail_path": "Path:", + "settings.mail_auto": "Auto-detect (Thunderbird → Outlook → mailto:)", + "settings.mail_tb": "Thunderbird", + "settings.mail_ol": "Outlook (Windows)", + "settings.mail_custom": "Custom path", + "settings.mail_mailto": "mailto: only (no attachment)", + "settings.mail_note": "With Thunderbird and Outlook a new compose window opens with the file attached.", + "settings.online_group": "Automatic login at startup", + "settings.auto_login": "Log in automatically when the program starts", + "settings.username": "Username:", + "settings.password": "Password:", + "settings.password_warn": "⚠ The password is stored locally on this computer.\nOnly use this on a personal machine.", + "settings.lang_group": "Language", + "settings.lang_label": "Interface language:", + "settings.lang_note": "The language will be applied next time the program starts.", + "settings.saved": "Settings saved", + + # Tag editor + "tags.title": "Edit tags — {title}", + "tags.can_write": "✓ Dances are written to the file", + "tags.cant_write": "⚠ This format does not support file writing", + "tags.hint": "Type a dance name — suggestions show as 'Name / Level'. Select from list to set level automatically.", + "tags.dances": "Dances", + "tags.alts": "Alternative dances", + "tags.btn_add": "+ Add", + "tags.btn_save": "💾 Save tags", + "tags.btn_cancel": "Cancel", + "tags.new_dance": "New dance (e.g. Cowboy Cha Cha)...", + "tags.new_alt": "Alternative dance...", + "tags.note": "note...", + "tags.warn_file": "Saved to database, but could not write to file.", + "tags.error": "Could not save: {error}", + "tags.no_level": "— no level —", + + # Levels + "level.none": "— no level —", + "level.beginner": "Beginner", + "level.let_ovet": "Easy", + "level.easy": "Easy", + "level.ovet": "Intermediate", + "level.intermediate": "Intermediate", + "level.erfaren": "Experienced", + "level.experienced": "Experienced", + "level.ekspert": "Expert", + "level.expert": "Expert", + + # Online / login + "online.logging_in": "Logging in as {username}...", + "online.logged_in": "Online as {username}", + "online.auto_login_fail": "Auto-login failed — use File → Go online manually", + "online.logged_out": "Offline", + "online.syncing": "Syncing dance data...", + "online.synced": "Synced {levels} levels and {names} dance names", + + # Scanning + "scan.preparing": "Starting library scan...", + "scan.scanning": "Scanning: {name}...", + "scan.scanning_count": "Scanning: {name} ({count} files)...", + "scan.done": "Scan complete — {count} files processed", + "scan.error": "Scan error: {error}", + "scan.folder_missing": "⚠ Folder not found: {path}", + + # Errors + "error.title": "Error", + "error.db_init": "Database error: {error}", + "error.folder_remove": "Could not remove: {error}", + "error.save_tags": "Could not save tags: {error}", + + # Mail + "mail.thunderbird_ok": "Thunderbird opened with {filename} attached.", + "mail.outlook_ok": "Outlook opened with {filename} attached.", + "mail.fallback": "No known mail client found — opened mailto: (no attachment)", + "mail.file_missing": "File not found — cannot send mail", + + # General + "btn.ok": "OK", + "btn.cancel": "Cancel", + "btn.close": "Close", + "btn.yes": "Yes", + "btn.no": "No", + "dialog.confirm": "Confirm", +} diff --git a/linedance-app/ui/dance_info_dialog.py b/linedance-app/ui/dance_info_dialog.py new file mode 100644 index 00000000..3cb2c355 --- /dev/null +++ b/linedance-app/ui/dance_info_dialog.py @@ -0,0 +1,226 @@ +""" +dance_info_dialog.py — Rediger info om en dans: koreograf, video, step sheet, noter. +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QTextEdit, QComboBox, QFrame, QMessageBox, + QTabWidget, QWidget, +) +from PyQt6.QtCore import Qt, QUrl +from PyQt6.QtGui import QDesktopServices + + +class DanceInfoDialog(QDialog): + """Vis og rediger info om danse tilknyttet en sang.""" + + def __init__(self, song: dict, parent=None): + super().__init__(parent) + self._song = song + self._dances = [] # [{dance_id, name, level_name, ...}] + self._current_idx = 0 + + self.setWindowTitle(f"Dans-info — {song.get('title', '')}") + self.setMinimumSize(560, 420) + self.resize(620, 460) + + self._load_dances() + self._build_ui() + if self._dances: + self._show_dance(0) + + def _load_dances(self): + try: + from local.local_db import get_dances_for_song, get_alt_dances_for_song, new_conn + conn = new_conn() + + rows = conn.execute(""" + SELECT d.id, d.name, d.level_id, d.choreographer, + d.video_url, d.stepsheet_url, d.notes, + dl.name as level_name + FROM song_dances sd + JOIN dances d ON d.id = sd.dance_id + LEFT JOIN dance_levels dl ON dl.id = d.level_id + WHERE sd.song_id=? ORDER BY sd.dance_order + """, (self._song.get("id"),)).fetchall() + + for row in rows: + self._dances.append({ + "dance_id": row["id"], + "name": row["name"], + "level_name": row["level_name"] or "", + "choreographer": row["choreographer"] or "", + "video_url": row["video_url"] or "", + "stepsheet_url": row["stepsheet_url"] or "", + "notes": row["notes"] or "", + "is_alt": False, + }) + + # Alternativ-danse + alt_rows = conn.execute(""" + SELECT d.id, d.name, d.level_id, d.choreographer, + d.video_url, d.stepsheet_url, d.notes, + dl.name as level_name + FROM song_alt_dances sad + JOIN dances d ON d.id = sad.dance_id + LEFT JOIN dance_levels dl ON dl.id = d.level_id + WHERE sad.song_id=? ORDER BY d.name + """, (self._song.get("id"),)).fetchall() + + for row in alt_rows: + self._dances.append({ + "dance_id": row["id"], + "name": row["name"], + "level_name": row["level_name"] or "", + "choreographer": row["choreographer"] or "", + "video_url": row["video_url"] or "", + "stepsheet_url": row["stepsheet_url"] or "", + "notes": row["notes"] or "", + "is_alt": True, + }) + conn.close() + except Exception as e: + print(f"DanceInfoDialog load fejl: {e}") + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + + # Sang-info + info = QFrame() + info.setObjectName("track_display") + il = QHBoxLayout(info) + il.setContentsMargins(10, 8, 10, 8) + lbl = QLabel(self._song.get("title", "—")) + lbl.setObjectName("track_title") + il.addWidget(lbl, stretch=1) + layout.addWidget(info) + + if not self._dances: + layout.addWidget(QLabel("Ingen danse tagget på denne sang.")) + btn_close = QPushButton("Luk") + btn_close.clicked.connect(self.reject) + layout.addWidget(btn_close) + return + + # Dans-vælger + top = QHBoxLayout() + top.addWidget(QLabel("Dans:")) + self._dance_combo = QComboBox() + for d in self._dances: + prefix = "↪ " if d["is_alt"] else "" + lvl = f" / {d['level_name']}" if d["level_name"] else "" + self._dance_combo.addItem(f"{prefix}{d['name']}{lvl}") + self._dance_combo.currentIndexChanged.connect(self._on_dance_changed) + top.addWidget(self._dance_combo, stretch=1) + layout.addLayout(top) + + # Formular + form_frame = QFrame() + form_frame.setObjectName("track_display") + form = QVBoxLayout(form_frame) + form.setContentsMargins(12, 10, 12, 10) + form.setSpacing(8) + + # Koreograf + row1 = QHBoxLayout() + row1.addWidget(QLabel("Koreograf:")) + self._choreo = QLineEdit() + self._choreo.setPlaceholderText("Koreografens navn...") + row1.addWidget(self._choreo) + form.addLayout(row1) + + # Step sheet URL + row2 = QHBoxLayout() + row2.addWidget(QLabel("Step sheet:")) + self._stepsheet = QLineEdit() + self._stepsheet.setPlaceholderText("https://www.copperknob.co.uk/...") + row2.addWidget(self._stepsheet) + btn_ss = QPushButton("↗") + btn_ss.setFixedWidth(28) + btn_ss.setToolTip("Åbn i browser") + btn_ss.clicked.connect(lambda: self._open_url(self._stepsheet.text())) + row2.addWidget(btn_ss) + form.addLayout(row2) + + # Video URL + row3 = QHBoxLayout() + row3.addWidget(QLabel("Video:")) + self._video = QLineEdit() + self._video.setPlaceholderText("https://www.youtube.com/...") + row3.addWidget(self._video) + btn_v = QPushButton("↗") + btn_v.setFixedWidth(28) + btn_v.setToolTip("Åbn i browser") + btn_v.clicked.connect(lambda: self._open_url(self._video.text())) + row3.addWidget(btn_v) + form.addLayout(row3) + + # Noter + form.addWidget(QLabel("Noter:")) + self._notes = QTextEdit() + self._notes.setPlaceholderText("Egne noter om dansen...") + self._notes.setMaximumHeight(80) + form.addWidget(self._notes) + + layout.addWidget(form_frame, stretch=1) + + # Knapper + btn_row = QHBoxLayout() + btn_row.addStretch() + btn_cancel = QPushButton("Luk") + btn_cancel.clicked.connect(self.reject) + btn_row.addWidget(btn_cancel) + btn_save = QPushButton("💾 Gem") + btn_save.setObjectName("btn_play") + btn_save.clicked.connect(self._save) + btn_row.addWidget(btn_save) + layout.addLayout(btn_row) + + def _on_dance_changed(self, idx: int): + self._save_to_cache(self._current_idx) + self._current_idx = idx + self._show_dance(idx) + + def _show_dance(self, idx: int): + if not 0 <= idx < len(self._dances): + return + d = self._dances[idx] + self._choreo.setText(d["choreographer"]) + self._stepsheet.setText(d["stepsheet_url"]) + self._video.setText(d["video_url"]) + self._notes.setPlainText(d["notes"]) + + def _save_to_cache(self, idx: int): + """Gem UI-værdier til cache så de ikke mistes ved dans-skift.""" + if not 0 <= idx < len(self._dances): + return + self._dances[idx]["choreographer"] = self._choreo.text().strip() + self._dances[idx]["stepsheet_url"] = self._stepsheet.text().strip() + self._dances[idx]["video_url"] = self._video.text().strip() + self._dances[idx]["notes"] = self._notes.toPlainText().strip() + + def _save(self): + self._save_to_cache(self._current_idx) + try: + from local.local_db import update_dance_info + for d in self._dances: + update_dance_info( + d["dance_id"], + choreographer = d["choreographer"], + video_url = d["video_url"], + stepsheet_url = d["stepsheet_url"], + notes = d["notes"], + ) + self.accept() + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}") + + def _open_url(self, url: str): + url = url.strip() + if not url: + return + if not url.startswith("http"): + url = "https://" + url + QDesktopServices.openUrl(QUrl(url)) diff --git a/linedance-app/ui/dance_picker_dialog.py b/linedance-app/ui/dance_picker_dialog.py new file mode 100644 index 00000000..0879f7c8 --- /dev/null +++ b/linedance-app/ui/dance_picker_dialog.py @@ -0,0 +1,105 @@ +""" +dance_picker_dialog.py — Dialog til at vælge eller skrive en dans med autoudfyld. +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QListWidget, QListWidgetItem, +) +from PyQt6.QtCore import Qt, QTimer + + +class DancePickerDialog(QDialog): + def __init__(self, current_dance: str = "", song_title: str = "", parent=None): + super().__init__(parent) + self._chosen = current_dance + self.setWindowTitle("Vælg dans") + self.setMinimumWidth(380) + self.setFixedWidth(420) + self._build_ui(current_dance, song_title) + self._load_suggestions("") + + def _build_ui(self, current_dance: str, song_title: str): + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + + if song_title: + lbl = QLabel(song_title) + lbl.setObjectName("track_title") + lbl.setWordWrap(True) + layout.addWidget(lbl) + + lbl2 = QLabel("Vælg eller skriv dans-navn:") + lbl2.setObjectName("track_meta") + layout.addWidget(lbl2) + + # Søgefelt med autoudfyld + self._edit = QLineEdit() + self._edit.setText(current_dance) + self._edit.setPlaceholderText("Skriv dans-navn...") + self._edit.selectAll() + self._edit.textChanged.connect(self._on_text_changed) + self._edit.returnPressed.connect(self._on_accept) + layout.addWidget(self._edit) + + # Liste med forslag + self._suggestion_list = QListWidget() + self._suggestion_list.setMaximumHeight(180) + self._suggestion_list.itemDoubleClicked.connect(self._on_item_selected) + self._suggestion_list.itemClicked.connect( + lambda item: self._edit.setText(item.text()) + ) + layout.addWidget(self._suggestion_list) + + # Debounce timer + self._timer = QTimer(self) + self._timer.setSingleShot(True) + self._timer.setInterval(200) + self._timer.timeout.connect( + lambda: self._load_suggestions(self._edit.text().strip()) + ) + + # Knapper + btn_row = QHBoxLayout() + btn_row.addStretch() + btn_cancel = QPushButton("Annuller") + btn_cancel.clicked.connect(self.reject) + btn_row.addWidget(btn_cancel) + btn_ok = QPushButton("✓ Vælg") + btn_ok.setObjectName("btn_play") + btn_ok.clicked.connect(self._on_accept) + btn_row.addWidget(btn_ok) + layout.addLayout(btn_row) + + self._edit.setFocus() + + def _on_text_changed(self, text: str): + self._timer.start() + + def _load_suggestions(self, prefix: str): + try: + from local.local_db import get_dance_suggestions + suggestions = get_dance_suggestions(prefix or "", limit=20) + self._suggestion_list.clear() + for s in suggestions: + label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"] + item = QListWidgetItem(label) + item.setData(Qt.ItemDataRole.UserRole, s["name"]) + self._suggestion_list.addItem(item) + except Exception: + pass + + def _on_item_selected(self, item: QListWidgetItem): + name = item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0] + self._edit.setText(name) + self._chosen = name + self.accept() + + def _on_accept(self): + self._chosen = self._edit.text().strip() + if self._chosen: + self.accept() + + def get_dance(self) -> str: + return self._chosen diff --git a/linedance-app/ui/library_manager.py b/linedance-app/ui/library_manager.py index 3fdf047f..e8bcf037 100644 --- a/linedance-app/ui/library_manager.py +++ b/linedance-app/ui/library_manager.py @@ -1,22 +1,76 @@ """ -library_manager.py — Dialog til at se og fjerne musikbiblioteker. +library_manager.py — Dialog til at administrere musikbiblioteker med BPM-scanning. """ from PyQt6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QWidget, QPushButton, QListWidget, QListWidgetItem, QMessageBox, + QFrame, QSizePolicy, ) -from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtCore import Qt, pyqtSignal, QThread +from PyQt6.QtGui import QColor + + +class BpmScanWorker(QThread): + progress = pyqtSignal(int, int) # done, total + finished = pyqtSignal(int) # antal scannet + + def __init__(self, library_id: int, scan_all: bool = False): + super().__init__() + self._library_id = library_id + self._scan_all = scan_all # False = kun manglende, True = alle + + def run(self): + import sqlite3 + from local.local_db import DB_PATH + from local.tag_reader import analyze_bpm + + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + + if self._scan_all: + songs = conn.execute( + "SELECT id, local_path FROM songs WHERE library_id=? AND file_missing=0", + (self._library_id,) + ).fetchall() + else: + songs = conn.execute( + "SELECT id, local_path FROM songs " + "WHERE library_id=? AND file_missing=0 AND (bpm IS NULL OR bpm=0)", + (self._library_id,) + ).fetchall() + + total = len(songs) + done = 0 + for song in songs: + if self.isInterruptionRequested(): + break + try: + bpm = analyze_bpm(song["local_path"]) + if bpm: + conn.execute( + "UPDATE songs SET bpm=? WHERE id=?", + (int(round(bpm)), song["id"]) + ) + conn.commit() + except Exception: + pass + done += 1 + self.progress.emit(done, total) + + conn.close() + self.finished.emit(done) class LibraryManagerDialog(QDialog): - library_removed = pyqtSignal(int) # library_id + library_removed = pyqtSignal(int) def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Administrer musikbiblioteker") - self.setMinimumWidth(500) - self.setMinimumHeight(320) + self.setMinimumWidth(580) + self.setMinimumHeight(360) + self._bpm_workers = {} # library_id → BpmScanWorker self._build_ui() self._load() @@ -29,8 +83,10 @@ class LibraryManagerDialog(QDialog): lbl.setObjectName("track_meta") layout.addWidget(lbl) - self._list = QListWidget() - layout.addWidget(self._list) + self._libs_layout = QVBoxLayout() + self._libs_layout.setSpacing(6) + layout.addLayout(self._libs_layout) + layout.addStretch() note = QLabel( "Når du fjerner et bibliotek, slettes det fra overvågningen.\n" @@ -44,16 +100,6 @@ class LibraryManagerDialog(QDialog): btn_add = QPushButton("+ Tilføj mappe") btn_add.clicked.connect(self._add_folder) btn_row.addWidget(btn_add) - - btn_remove = QPushButton("✕ Fjern valgt") - btn_remove.clicked.connect(self._remove_selected) - btn_row.addWidget(btn_remove) - - btn_scan = QPushButton("⟳ Scan alle") - btn_scan.setToolTip("Scan alle mapper for nye og ændrede filer") - btn_scan.clicked.connect(self._scan_all) - btn_row.addWidget(btn_scan) - btn_row.addStretch() btn_close = QPushButton("Luk") btn_close.clicked.connect(self.accept) @@ -61,41 +107,141 @@ class LibraryManagerDialog(QDialog): layout.addLayout(btn_row) def _load(self): - self._list.clear() + # Ryd eksisterende widgets + while self._libs_layout.count(): + item = self._libs_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + try: from local.local_db import get_libraries, get_db - libs = get_libraries(active_only=True) # kun aktive + libs = get_libraries(active_only=True) for lib in libs: - from pathlib import Path - path = lib["path"] - exists = Path(path).exists() - last_scan = lib["last_full_scan"] or "aldrig" - if isinstance(last_scan, str) and len(last_scan) > 10: - last_scan = last_scan[:10] - with get_db() as conn: - count = conn.execute( - "SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0", - (lib["id"],) - ).fetchone()[0] - exist_icon = "" if exists else " ⚠ mappe ikke fundet" - label = f"{path}{exist_icon}\n {count} sange · senest scannet: {last_scan}" - item = QListWidgetItem(label) - item.setData(Qt.ItemDataRole.UserRole, dict(lib)) - if not exists: - from PyQt6.QtGui import QColor - item.setForeground(QColor("#5a6070")) - self._list.addItem(item) + self._libs_layout.addWidget(self._make_lib_row(lib)) except Exception as e: - print(f"Library manager load fejl: {e}") + pass - def _scan_all(self): + def _make_lib_row(self, lib: dict) -> QFrame: + from pathlib import Path + lib_id = lib["id"] + path = lib["path"] + exists = Path(path).exists() + + frame = QFrame() + frame.setObjectName("track_display") + vbox = QVBoxLayout(frame) + vbox.setContentsMargins(10, 8, 10, 8) + vbox.setSpacing(4) + + # Sti + scan-info + last_scan = lib.get("last_full_scan") or "aldrig" + if isinstance(last_scan, str) and len(last_scan) > 10: + last_scan = last_scan[:10] + try: + from local.local_db import get_db + with get_db() as conn: + total = conn.execute( + "SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0", + (lib_id,) + ).fetchone()[0] + missing_bpm = conn.execute( + "SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0 " + "AND (bpm IS NULL OR bpm=0)", + (lib_id,) + ).fetchone()[0] + except Exception: + total = 0 + missing_bpm = 0 + + lbl_path = QLabel(("⚠ " if not exists else "") + path) + lbl_path.setObjectName("track_title" if exists else "result_count") + vbox.addWidget(lbl_path) + + lbl_info = QLabel( + f" {total} sange · senest scannet: {last_scan} · " + f"{missing_bpm} uden BPM" + ) + lbl_info.setObjectName("result_count") + vbox.addWidget(lbl_info) + + # Statuslinje til BPM-fremgang + lbl_status = QLabel("") + lbl_status.setObjectName("result_count") + lbl_status.hide() + vbox.addWidget(lbl_status) + + # Knap-række + btn_row = QHBoxLayout() + btn_row.setSpacing(6) + + btn_scan = QPushButton("⟳ Fil-scan") + btn_scan.setFixedHeight(24) + btn_scan.setToolTip("Scan for nye og ændrede filer") + btn_scan.clicked.connect(lambda _, lid=lib_id, p=path: self._scan_files(lid, p)) + btn_row.addWidget(btn_scan) + + btn_bpm = QPushButton(f"♩ BPM manglende ({missing_bpm})") + btn_bpm.setFixedHeight(24) + btn_bpm.setToolTip("Analysér BPM på sange der mangler det") + btn_bpm.setEnabled(missing_bpm > 0) + btn_bpm.clicked.connect( + lambda _, lid=lib_id, b=btn_bpm, s=lbl_status: self._start_bpm(lid, False, b, s) + ) + btn_row.addWidget(btn_bpm) + + btn_bpm_all = QPushButton("♩ BPM alle") + btn_bpm_all.setFixedHeight(24) + btn_bpm_all.setToolTip("Genanalysér BPM på alle sange (overskriver eksisterende)") + btn_bpm_all.clicked.connect( + lambda _, lid=lib_id, b=btn_bpm_all, s=lbl_status: self._start_bpm(lid, True, b, s) + ) + btn_row.addWidget(btn_bpm_all) + + btn_row.addStretch() + + btn_remove = QPushButton("✕ Fjern") + btn_remove.setFixedHeight(24) + btn_remove.clicked.connect(lambda _, l=lib: self._remove_library(l)) + btn_row.addWidget(btn_remove) + + vbox.addLayout(btn_row) + return frame + + def _scan_files(self, library_id: int, path: str): mw = self.parent() - if hasattr(mw, "start_scan"): - mw.start_scan() - self._set_status("Scanning startet...") + if hasattr(mw, "_watcher") and mw._watcher: + mw._watcher._full_scan_library(library_id, path) + from PyQt6.QtCore import QTimer + QTimer.singleShot(1000, self._load) - def _set_status(self, text: str): - pass # kan udvides med statuslinje i dialogen + def _start_bpm(self, library_id: int, scan_all: bool, + btn: QPushButton, lbl_status: QLabel): + if library_id in self._bpm_workers: + return # allerede i gang + + worker = BpmScanWorker(library_id, scan_all=scan_all) + + def on_progress(done, total): + lbl_status.setText(f"♩ {done}/{total} analyseret...") + lbl_status.show() + btn.setEnabled(False) + + def on_finished(count): + lbl_status.setText(f"✓ {count} sange analyseret") + btn.setEnabled(True) + self._bpm_workers.pop(library_id, None) + # Opdater UI og bibliotek + from PyQt6.QtCore import QTimer + QTimer.singleShot(500, self._load) + mw = self.parent() + if hasattr(mw, "_reload_library"): + QTimer.singleShot(600, mw._reload_library) + + worker.progress.connect(on_progress) + worker.finished.connect(on_finished) + self._bpm_workers[library_id] = worker + worker.start() + worker.setPriority(QThread.Priority.LowestPriority) def _add_folder(self): from PyQt6.QtWidgets import QFileDialog @@ -104,19 +250,14 @@ class LibraryManagerDialog(QDialog): mw = self.parent() if hasattr(mw, "add_library_path"): mw.add_library_path(folder) - # Genindlæs listen efter kort pause så DB er opdateret from PyQt6.QtCore import QTimer - QTimer.singleShot(600, self._load) + QTimer.singleShot(800, self._load) - def _remove_selected(self): - item = self._list.currentItem() - if not item: - return - lib = item.data(Qt.ItemDataRole.UserRole) + def _remove_library(self, lib: dict): reply = QMessageBox.question( self, "Fjern bibliotek", f"Fjern overvågningen af:\n{lib['path']}\n\n" - "Sange i biblioteket forbliver i databasen men markeres som manglende.", + "Sange forbliver i databasen men markeres som manglende.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: diff --git a/linedance-app/ui/library_panel.py b/linedance-app/ui/library_panel.py index b30407da..295cfb8f 100644 --- a/linedance-app/ui/library_panel.py +++ b/linedance-app/ui/library_panel.py @@ -4,15 +4,47 @@ library_panel.py — Musikbibliotek med søgning og drag-and-drop til danseliste from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QListWidget, QListWidgetItem, - QLineEdit, QLabel, QHBoxLayout, QPushButton, QProgressBar, - QAbstractItemView, + QLineEdit, QLabel, QHBoxLayout, QPushButton, + QAbstractItemView, QStyledItemDelegate, ) -from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray -from PyQt6.QtGui import QColor, QDrag +from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray, QRect +from PyQt6.QtGui import QColor, QDrag, QPainter, QBrush, QPen, QFont + + +class DanseButtonDelegate(QStyledItemDelegate): + """Tegner en orange 'Danse' label i højre side af hvert list-item.""" + + BTN_W = 54 + BTN_H = 22 + BTN_MARGIN = 8 + + def paint(self, painter: QPainter, option, index): + super().paint(painter, option, index) + rect = option.rect + btn_rect = QRect( + rect.right() - self.BTN_W - self.BTN_MARGIN, + rect.top() + (rect.height() - self.BTN_H) // 2, + self.BTN_W, + self.BTN_H, + ) + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setBrush(QBrush(QColor("#e8a020"))) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRoundedRect(btn_rect, 4, 4) + painter.setPen(QPen(QColor("#111111"))) + font = QFont() + font.setPointSize(8) + font.setBold(True) + painter.setFont(font) + painter.drawText(btn_rect, Qt.AlignmentFlag.AlignCenter, "Danse") + painter.restore() class DraggableLibraryList(QListWidget): - """QListWidget der understøtter drag-start med sang-data som mime.""" + """QListWidget med drag, dobbeltklik og klik på højre side for dans-tags.""" + + danse_clicked = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(dict) def __init__(self, parent=None): super().__init__(parent) @@ -20,6 +52,28 @@ class DraggableLibraryList(QListWidget): self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly) self.setDefaultDropAction(Qt.DropAction.CopyAction) + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + item = self.itemAt(event.pos()) + if item and event.pos().x() > self.viewport().width() - 75: + song = item.data(Qt.ItemDataRole.UserRole) + if song: + self.danse_clicked.emit(song) + return + super().mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + item = self.itemAt(event.pos()) + if item: + # Dobbeltklik i højre 75px = Danse, ellers song_selected + if event.pos().x() > self.viewport().width() - 75: + song = item.data(Qt.ItemDataRole.UserRole) + if song: + self.danse_clicked.emit(song) + return + super().mouseDoubleClickEvent(event) + def startDrag(self, supported_actions): item = self.currentItem() if not item: @@ -71,34 +125,13 @@ 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.setFixedHeight(28) btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker") btn_manage.clicked.connect(self._manage_libraries) header.addWidget(btn_manage) layout.addLayout(header) - # Scan status - self._scan_bar = QProgressBar() - self._scan_bar.setObjectName("scan_bar") - self._scan_bar.setTextVisible(True) - self._scan_bar.setFormat("Scanner...") - self._scan_bar.setFixedHeight(16) - self._scan_bar.setRange(0, 0) - self._scan_bar.hide() - layout.addWidget(self._scan_bar) - - self._scan_label = QLabel("") - self._scan_label.setObjectName("result_count") - self._scan_label.hide() - layout.addWidget(self._scan_label) - # Søgefelt self._search = QLineEdit() self._search.setPlaceholderText("Søg i titel, artist, album, dans...") @@ -121,8 +154,10 @@ class LibraryPanel(QWidget): self._list = DraggableLibraryList() self._list.setObjectName("library_list") self._list.itemDoubleClicked.connect(self._on_double_click) + self._list.danse_clicked.connect(self.edit_tags_requested) self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self._list.customContextMenuRequested.connect(self._show_context_menu) + self._list.setItemDelegate(DanseButtonDelegate(self._list)) layout.addWidget(self._list) # ── Scanning ────────────────────────────────────────────────────────────── @@ -131,16 +166,10 @@ class LibraryPanel(QWidget): self.scan_requested.emit() def set_scanning(self, scanning: bool, status_text: str = ""): - if scanning: - self._scan_bar.show() - self._scan_label.setText(status_text or "Starter...") - self._scan_label.show() - else: - self._scan_bar.hide() - self._scan_label.hide() + pass # Status vises i statuslinjen def update_scan_status(self, text: str): - self._scan_label.setText(text) + pass # Status vises i statuslinjen # ── Sange ───────────────────────────────────────────────────────────────── @@ -185,41 +214,34 @@ class LibraryPanel(QWidget): dance_parts.append(f"{d} / {lvl}" if lvl else d) dance_str = " · " + " | ".join(dance_parts) if dance_parts else "" - line1 = ("⚠ " if missing else "") + song.get("title", "—") - bpm = song.get("bpm", 0) + def _render(self): + self._list.clear() + q = self._search.text().strip().lower() + for song in self._filtered: + dances = song.get("dances", []) + dance_levels = song.get("dance_levels", []) + missing = song.get("file_missing", False) + + dance_parts = [] + for i, d in enumerate(dances): + lvl = dance_levels[i] if i < len(dance_levels) else "" + dance_parts.append(f"{d} / {lvl}" if lvl else d) + dance_str = " · " + " | ".join(dance_parts) if dance_parts else "" + + prefix = "⚠ " if missing else "" + 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}" + line1 = prefix + song.get("title", "—") + line2 = f" {song.get('artist','—')} · {bpm_str} · {song.get('file_format','').upper()}{dance_str}" - 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 = QListWidgetItem(f"{line1}\n{line2}") item.setData(Qt.ItemDataRole.UserRole, song) - row_widget.adjustSize() - hint = row_widget.sizeHint() - hint.setHeight(max(hint.height(), 52)) - item.setSizeHint(hint) + item.setSizeHint(__import__('PyQt6.QtCore', fromlist=['QSize']).QSize(0, 52)) + if missing: + item.setForeground(QColor("#5a6070")) + elif q and any(q in d.lower() for d in dances): + item.setForeground(QColor("#e8a020")) 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.""" @@ -301,6 +323,7 @@ class LibraryPanel(QWidget): act_play = menu.addAction("Afspil") menu.addSeparator() act_tags = menu.addAction("✎ Rediger dans-tags...") + act_info = menu.addAction("ℹ Dans-info...") act_bpm = menu.addAction("♩ Analysér BPM") menu.addSeparator() send_menu = menu.addMenu("Send til") @@ -312,6 +335,10 @@ class LibraryPanel(QWidget): self.song_selected.emit(song) elif action == act_tags: self.edit_tags_requested.emit(song) + elif action == act_info: + from ui.dance_info_dialog import DanceInfoDialog + dlg = DanceInfoDialog(song, parent=self.window()) + dlg.exec() elif action == act_bpm: self._analyze_bpm(song) elif action == act_mail: diff --git a/linedance-app/ui/main_window.py b/linedance-app/ui/main_window.py index 96e72101..7447d766 100644 --- a/linedance-app/ui/main_window.py +++ b/linedance-app/ui/main_window.py @@ -91,6 +91,7 @@ class MainWindow(QMainWindow): self._demo_fade_seconds = self._settings.get("demo_fade_seconds", 5) self._connect_player_signals() + self._library_loaded.connect(self._apply_library) self._build_menu() self._build_ui() self._build_statusbar() @@ -367,66 +368,60 @@ class MainWindow(QMainWindow): # ── Lokal DB + scanning ─────────────────────────────────────────────────── def _init_local_db(self): + # Debounce-timer til reload (skal oprettes i GUI-tråden) + self._reload_timer = QTimer(self) + self._reload_timer.setSingleShot(True) + self._reload_timer.setInterval(2000) + self._reload_timer.timeout.connect(self._reload_library) + + # Kør init_db i baggrundstråd — blokerer ikke GUI + import threading + threading.Thread(target=self._init_db_background, daemon=True).start() + + def _init_db_background(self): + """Kører i baggrundstråd — initialiserer DB og loader bibliotek.""" try: - import sys, os - sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) from local.local_db import init_db - from local.file_watcher import get_watcher - init_db() - - # Brug et Qt signal til thread-safe reload fra watcher-tråden - from PyQt6.QtCore import QMetaObject, Q_ARG - def on_file_change(event_type, path, song_id): - QTimer.singleShot(0, self._reload_library) - - self._watcher = get_watcher(on_change=on_file_change) - self._watcher.start() - - # Indlæs hvad vi allerede kender fra SQLite - self._reload_library() - - # Gendan sidst aktive danseliste - restored = self._playlist_panel.restore_active_playlist() - - # Gendan event-fremgang hvis liste blev gendannet - if restored: - if self._playlist_panel.restore_event_state(): - # Indlæs den sang vi var nået til - idx = self._playlist_panel._current_idx - song = self._playlist_panel.get_song(idx) - if song: - self._current_idx = idx - self._load_song(song) - self._set_status( - f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte", - 6000, - ) - - # Kør automatisk scanning ved opstart - self._set_status("Starter scanning af biblioteker...") - QTimer.singleShot(100, self.start_scan) - + # Trigger library load via signal + self._library_loaded.emit([]) # tomt signal = "DB klar, load nu" except Exception as e: - self._set_status(f"DB fejl: {e}") pass - def start_scan(self): - """Start fuld scanning af alle biblioteker i baggrundstråd.""" - if self._scan_worker and self._scan_worker.isRunning(): - return # Scanning kører allerede + def _start_watcher(self): + """Start fil-watcher i baggrundstråd — blokerer aldrig GUI.""" + import threading + def _start(): + try: + from local.file_watcher import get_watcher + + def on_file_change(event_type, path, song_id): + # QMetaObject.invokeMethod er den korrekte måde at kalde + # en slot fra en ikke-GUI-tråd + from PyQt6.QtCore import QMetaObject, Qt + QMetaObject.invokeMethod( + self._reload_timer, "start", + Qt.ConnectionType.QueuedConnection + ) + + watcher = get_watcher(on_change=on_file_change) + watcher.start() + self._watcher = watcher # sæt først når den er klar + except Exception: + pass + + threading.Thread(target=_start, daemon=True).start() + + def start_scan(self): + """Start fuld scanning af alle biblioteker — watcher kører i egne baggrundstråde.""" if not self._watcher: self._set_status("Ingen biblioteker at scanne — tilføj en mappe først") return - - self._library_panel.set_scanning(True, "Forbereder scanning...") - self._act_scan.setEnabled(False) - - self._scan_worker = ScanWorker(self._watcher, parent=self) - self._scan_worker.status_update.connect(self._on_scan_status) - self._scan_worker.scan_done.connect(self._on_scan_done) - self._scan_worker.start() + self._set_status("Scanner biblioteker i baggrunden...") + self._watcher._full_scan_all() + # Genindlæs bibliotekslisten efter et øjeblik + QTimer.singleShot(3000, self._reload_library) def _on_scan_status(self, text: str): self._set_status(text) @@ -440,20 +435,41 @@ class MainWindow(QMainWindow): # Genindlæs biblioteket QTimer.singleShot(200, self._reload_library) + # Signal til at opdatere biblioteket fra baggrundstråd + _library_loaded = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(list) + def _reload_library(self): + """Hent sange fra DB i baggrundstråd — thread-safe via signal.""" + import threading + threading.Thread(target=self._fetch_library, daemon=True).start() + + def _fetch_library(self): + """Kører i baggrundstråd — henter sange og sender til GUI via signal.""" try: - from local.local_db import search_songs, get_db - songs_raw = search_songs("", limit=5000) + import sqlite3 + from local.local_db import DB_PATH + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + rows = conn.execute(""" + SELECT s.id, s.title, s.artist, s.album, s.bpm, + s.duration_sec, s.local_path, s.file_format, + s.file_missing, + GROUP_CONCAT(d.name, '||') AS dance_names, + GROUP_CONCAT(COALESCE(dl.name,''), '||') AS dance_levels + FROM songs s + LEFT JOIN song_dances sd ON sd.song_id = s.id + LEFT JOIN dances d ON d.id = sd.dance_id + LEFT JOIN dance_levels dl ON dl.id = d.level_id + WHERE s.file_missing = 0 + GROUP BY s.id + ORDER BY s.artist, s.title + """).fetchall() + conn.close() + songs = [] - for row in songs_raw: - with get_db() as conn: - dances_raw = conn.execute(""" - SELECT d.name, dl.name as level_name - FROM song_dances sd - JOIN dances d ON d.id = sd.dance_id - LEFT JOIN dance_levels dl ON dl.id = d.level_id - WHERE sd.song_id=? ORDER BY sd.dance_order - """, (row["id"],)).fetchall() + for row in rows: + dances = row["dance_names"].split("||") if row["dance_names"] else [] + levels = row["dance_levels"].split("||") if row["dance_levels"] else [] songs.append({ "id": row["id"], "title": row["title"], @@ -464,25 +480,54 @@ class MainWindow(QMainWindow): "local_path": row["local_path"], "file_format": row["file_format"], "file_missing": bool(row["file_missing"]), - "dances": [d["name"] for d in dances_raw], - "dance_levels": [d["level_name"] or "" for d in dances_raw], + "dances": dances, + "dance_levels": levels, }) - self._library_panel.load_songs(songs) - count = len(songs) - self._set_status(f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000) - except Exception as e: + self._library_loaded.emit(songs) + except Exception: pass + def _apply_library(self, songs: list): + if not songs: + # Tomt signal = DB er klar, start library load og post-init + self._reload_library() + self._post_init() + return + self._library_panel.load_songs(songs) + count = len(songs) + self._set_status( + f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000 + ) + + def _post_init(self): + """Kør efter DB er initialiseret — gendan state og start watcher.""" + try: + restored = self._playlist_panel.restore_active_playlist() + if restored: + if self._playlist_panel.restore_event_state(): + idx = self._playlist_panel._current_idx + song = self._playlist_panel.get_song(idx) + if song: + self._current_idx = idx + self._load_song(song) + self._set_status( + f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte", + 6000, + ) + except Exception: + pass + QTimer.singleShot(5000, self._start_watcher) + QTimer.singleShot(60000, self.start_scan) + def add_library_path(self, path: str): try: if not self._watcher: self._set_status("Watcher ikke klar endnu — prøv igen om et øjeblik", 3000) return self._watcher.add_library(path) - self._set_status(f"Tilføjet: {path} — scanner...") - # Genindlæs bibliotekslisten og start scan - QTimer.singleShot(500, self._reload_library) - QTimer.singleShot(1000, self.start_scan) + self._set_status(f"Tilføjet: {path} — scanner i baggrunden...") + # Genindlæs bibliotekslisten efter kort pause + QTimer.singleShot(800, self._reload_library) except Exception as e: self._set_status(f"Fejl ved tilføjelse: {e}") @@ -820,6 +865,10 @@ class MainWindow(QMainWindow): def _on_library_song_selected(self, song: dict): self._load_song(song) + # VLC er asynkron — vent kort på at media er klar + QTimer.singleShot(150, self._play_after_load) + + def _play_after_load(self): self._player.play() self._btn_play.setText("⏸") diff --git a/linedance-app/ui/playlist_browser.py b/linedance-app/ui/playlist_browser.py new file mode 100644 index 00000000..1a2eb201 --- /dev/null +++ b/linedance-app/ui/playlist_browser.py @@ -0,0 +1,346 @@ +""" +playlist_browser.py — Dialog til at hente og gemme danselister med tag-organisering. + +Viser en liste over alle gemte danselister med: + - Navn, dato, antal sange + - Tag-filtrering i venstre side + - Gem ny liste med tags +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QListWidget, QListWidgetItem, QWidget, + QSplitter, QFrame, QMessageBox, QInputDialog, +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QColor + + +class PlaylistBrowserDialog(QDialog): + """Kombineret gem/hent dialog til danselister.""" + + playlist_selected = pyqtSignal(int, str) # playlist_id, name + + def __init__(self, mode: str = "load", current_songs: list = None, + current_name: str = "", parent=None): + super().__init__(parent) + self._mode = mode # "load" eller "save" + self._current_songs = current_songs or [] + self._current_name = current_name + self._all_playlists = [] + self._active_tag = None + + title = "Gem danseliste" if mode == "save" else "Hent danseliste" + self.setWindowTitle(title) + self.setMinimumSize(700, 480) + self.resize(780, 520) + + self._build_ui() + self._load_data() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + + # Gem-felter (kun i save-mode) + if self._mode == "save": + save_frame = QFrame() + save_frame.setObjectName("track_display") + save_layout = QVBoxLayout(save_frame) + save_layout.setContentsMargins(10, 8, 10, 8) + + row1 = QHBoxLayout() + row1.addWidget(QLabel("Navn:")) + self._name_input = QLineEdit() + self._name_input.setText(self._current_name) + self._name_input.setPlaceholderText("Navn på danselisten...") + row1.addWidget(self._name_input) + save_layout.addLayout(row1) + + row2 = QHBoxLayout() + row2.addWidget(QLabel("Tags:")) + self._tags_input = QLineEdit() + self._tags_input.setPlaceholderText("stævne, øvning, workshop (komma-separeret)") + self._tags_input.textChanged.connect(self._suggest_tags) + row2.addWidget(self._tags_input) + save_layout.addLayout(row2) + + # Tag-forslag + self._tag_suggestions = QListWidget() + self._tag_suggestions.setMaximumHeight(80) + self._tag_suggestions.hide() + self._tag_suggestions.itemClicked.connect(self._add_tag_suggestion) + save_layout.addWidget(self._tag_suggestions) + + lbl = QLabel(f"{len(self._current_songs)} sange vil blive gemt") + lbl.setObjectName("result_count") + save_layout.addWidget(lbl) + + layout.addWidget(save_frame) + + # Splitter: tags til venstre, lister til højre + splitter = QSplitter(Qt.Orientation.Horizontal) + + # ── Venstre: tag-filtrering ── + tag_panel = QWidget() + tag_layout = QVBoxLayout(tag_panel) + tag_layout.setContentsMargins(0, 0, 0, 0) + tag_layout.setSpacing(4) + lbl_tags = QLabel("FILTRÉR PÅ TAG") + lbl_tags.setObjectName("section_title") + tag_layout.addWidget(lbl_tags) + self._tag_list = QListWidget() + self._tag_list.currentItemChanged.connect(self._on_tag_selected) + tag_layout.addWidget(self._tag_list) + tag_panel.setMaximumWidth(180) + splitter.addWidget(tag_panel) + + # ── Højre: danseliste-oversigt ── + list_panel = QWidget() + list_layout = QVBoxLayout(list_panel) + list_layout.setContentsMargins(0, 0, 0, 0) + list_layout.setSpacing(4) + + # Søgefelt + self._search = QLineEdit() + self._search.setPlaceholderText("Søg i navn...") + self._search.textChanged.connect(self._filter) + list_layout.addWidget(self._search) + + self._count_label = QLabel("") + self._count_label.setObjectName("result_count") + list_layout.addWidget(self._count_label) + + self._list = QListWidget() + self._list.itemDoubleClicked.connect(self._on_double_click) + list_layout.addWidget(self._list) + splitter.addWidget(list_panel) + + splitter.setSizes([160, 580]) + layout.addWidget(splitter, stretch=1) + + # Knapper + btn_row = QHBoxLayout() + + if self._mode == "load": + btn_delete = QPushButton("🗑 Slet valgte") + btn_delete.clicked.connect(self._delete_selected) + btn_row.addWidget(btn_delete) + btn_tags = QPushButton("🏷 Rediger tags") + btn_tags.clicked.connect(self._edit_tags) + btn_row.addWidget(btn_tags) + + btn_row.addStretch() + btn_cancel = QPushButton("Annuller") + btn_cancel.clicked.connect(self.reject) + btn_row.addWidget(btn_cancel) + + if self._mode == "save": + btn_ok = QPushButton("💾 Gem") + btn_ok.setObjectName("btn_play") + btn_ok.clicked.connect(self._save) + else: + btn_ok = QPushButton("📂 Hent valgte") + btn_ok.setObjectName("btn_play") + btn_ok.clicked.connect(self._load_selected) + btn_row.addWidget(btn_ok) + + layout.addLayout(btn_row) + + def _load_data(self): + try: + from local.local_db import get_playlists, get_all_playlist_tags + self._all_playlists = [dict(r) for r in get_playlists()] + + # Udfyld tag-liste + self._tag_list.clear() + all_item = QListWidgetItem("Alle lister") + all_item.setData(Qt.ItemDataRole.UserRole, None) + self._tag_list.addItem(all_item) + + for tag in get_all_playlist_tags(): + item = QListWidgetItem(f"# {tag}") + item.setData(Qt.ItemDataRole.UserRole, tag) + self._tag_list.addItem(item) + + self._tag_list.setCurrentRow(0) + self._render(self._all_playlists) + except Exception as e: + print(f"Playlist browser load fejl: {e}") + + def _on_tag_selected(self, current, previous): + if not current: + return + self._active_tag = current.data(Qt.ItemDataRole.UserRole) + self._filter() + + def _suggest_tags(self, text: str): + """Vis forslag til det sidst indtastede tag.""" + if not hasattr(self, '_tag_suggestions'): + return + parts = text.split(",") + prefix = parts[-1].strip().lower() + if not prefix: + self._tag_suggestions.hide() + return + try: + from local.local_db import get_all_playlist_tags + all_tags = get_all_playlist_tags() + matches = [t for t in all_tags + if t.startswith(prefix) and t not in + [p.strip().lower() for p in parts[:-1]]] + if matches: + self._tag_suggestions.clear() + for t in matches[:5]: + self._tag_suggestions.addItem(t) + self._tag_suggestions.show() + else: + self._tag_suggestions.hide() + except Exception: + self._tag_suggestions.hide() + + def _add_tag_suggestion(self, item): + """Tilføj et foreslået tag til tekstfeltet.""" + parts = self._tags_input.text().split(",") + parts[-1] = " " + item.text() + self._tags_input.setText(",".join(parts) + ", ") + self._tag_suggestions.hide() + self._tags_input.setFocus() + + def _edit_tags(self): + """Rediger tags på den valgte liste.""" + item = self._list.currentItem() + if not item: + return + pl = item.data(Qt.ItemDataRole.UserRole) + if not pl or not isinstance(pl, dict): + return + from PyQt6.QtWidgets import QInputDialog + current = pl.get("tags", "") + new_tags, ok = QInputDialog.getText( + self, "Rediger tags", "Tags (komma-separeret):", text=current + ) + if ok: + try: + from local.local_db import update_playlist_tags + update_playlist_tags(pl["id"], new_tags.strip()) + self._load_data() + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke opdatere tags: {e}") + + def _filter(self): + query = self._search.text().strip().lower() + tag = self._active_tag + + filtered = self._all_playlists + if tag: + filtered = [ + p for p in filtered + if tag in [t.strip().lower() for t in p.get("tags", "").split(",")] + ] + if query: + filtered = [p for p in filtered if query in p["name"].lower()] + + self._render(filtered) + + def _render(self, playlists: list): + self._list.clear() + self._count_label.setText(f"{len(playlists)} liste{'r' if len(playlists) != 1 else ''}") + + for pl in playlists: + date = pl.get("created_at", "")[:10] + count = pl.get("song_count", 0) + tags = pl.get("tags", "") + tag_str = f" [{tags}]" if tags else "" + + item = QListWidgetItem( + f"{pl['name']}\n" + f" {date} · {count} sange{tag_str}" + ) + item.setData(Qt.ItemDataRole.UserRole, pl) + self._list.addItem(item) + + def _on_double_click(self, item: QListWidgetItem): + if self._mode == "load": + self._load_selected() + else: + self._save() + + def _load_selected(self): + item = self._list.currentItem() + if not item: + QMessageBox.information(self, "Vælg", "Vælg en liste først.") + return + pl = item.data(Qt.ItemDataRole.UserRole) + self.playlist_selected.emit(pl["id"], pl["name"]) + self.accept() + + def _save(self): + name = self._name_input.text().strip() + if not name: + QMessageBox.warning(self, "Navn mangler", "Angiv et navn til danselisten.") + self._name_input.setFocus() + return + tags = self._tags_input.text().strip() + + # Tjek om navn allerede eksisterer + existing = [p for p in self._all_playlists + if p["name"].lower() == name.lower()] + if existing: + reply = QMessageBox.question( + self, "Navn eksisterer allerede", + f"Der findes allerede en liste med navnet '{name}'.\n" + f"Vil du overskrive den?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + try: + from local.local_db import get_db, add_song_to_playlist + pl_id = existing[0]["id"] + with get_db() as conn: + conn.execute( + "DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,) + ) + if tags: + conn.execute( + "UPDATE playlists SET tags=? WHERE id=?", (tags, pl_id) + ) + for i, song in enumerate(self._current_songs, start=1): + if song.get("id"): + add_song_to_playlist(pl_id, song["id"], position=i) + self.playlist_selected.emit(pl_id, name) + self.accept() + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke overskrive: {e}") + return + + try: + from local.local_db import create_playlist, add_song_to_playlist + pl_id = create_playlist(name, tags=tags) + for i, song in enumerate(self._current_songs, start=1): + if song.get("id"): + add_song_to_playlist(pl_id, song["id"], position=i) + self.playlist_selected.emit(pl_id, name) + self.accept() + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}") + + def _delete_selected(self): + item = self._list.currentItem() + if not item: + return + pl = item.data(Qt.ItemDataRole.UserRole) + reply = QMessageBox.question( + self, "Slet danseliste", + f"Slet '{pl['name']}'?\n\nDette kan ikke fortrydes.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + try: + from local.local_db import get_db + with get_db() as conn: + conn.execute("DELETE FROM playlists WHERE id=?", (pl["id"],)) + self._load_data() + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke slette: {e}") diff --git a/linedance-app/ui/playlist_info_dialog.py b/linedance-app/ui/playlist_info_dialog.py new file mode 100644 index 00000000..f4924416 --- /dev/null +++ b/linedance-app/ui/playlist_info_dialog.py @@ -0,0 +1,201 @@ +""" +playlist_info_dialog.py — Flydende danseliste-info vindue med dynamisk opdatering. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSpinBox, + QFrame, QGridLayout, +) +from PyQt6.QtCore import Qt, pyqtSignal +from datetime import datetime, timedelta + + +def fmt_time(seconds: int) -> str: + if seconds < 0: + seconds = 0 + h = seconds // 3600 + m = (seconds % 3600) // 60 + s = seconds % 60 + if h > 0: + return f"{h}:{m:02d}:{s:02d}" + return f"{m}:{s:02d}" + + +class PlaylistInfoWindow(QWidget): + pause_changed = pyqtSignal(int) + + def __init__(self, playlist_panel, parent=None): + super().__init__(parent, + Qt.WindowType.Tool | + Qt.WindowType.WindowStaysOnTopHint + ) + self._panel = playlist_panel + self._pause_seconds = getattr(playlist_panel, "_pause_seconds", 60) + self._workshop_seconds = getattr(playlist_panel, "_workshop_seconds", 600) + + self.setWindowTitle("Danseliste-info") + self.setMinimumWidth(380) + self.setFixedWidth(440) + self._build_ui() + self._update() + + playlist_panel.playlist_changed.connect(self._update) + playlist_panel.status_changed.connect(lambda *_: self._update()) + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + + # Stats + stats = QFrame() + stats.setObjectName("track_display") + grid = QGridLayout(stats) + grid.setContentsMargins(12, 10, 12, 10) + grid.setSpacing(5) + grid.setColumnStretch(1, 1) + + def row(r, label, attr): + l = QLabel(label) + l.setObjectName("track_meta") + grid.addWidget(l, r, 0) + v = QLabel("—") + v.setAlignment(Qt.AlignmentFlag.AlignRight) + grid.addWidget(v, r, 1) + setattr(self, attr, v) + + row(0, "Antal sange:", "_lbl_count") + row(1, "Afspillet:", "_lbl_played") + row(2, "Workshop:", "_lbl_ws") + row(3, "Sprunget over:", "_lbl_skipped") + row(4, "Tilbage:", "_lbl_remaining") + + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + grid.addWidget(sep, 5, 0, 1, 2) + + row(6, "Musik-tid total:", "_lbl_music_total") + row(7, "Musik-tid tilbage:", "_lbl_music_remain") + row(8, "Pause-tid total:", "_lbl_pause_total") + row(9, "Workshop-tid total:", "_lbl_ws_total") + row(10, "Samlet tid total:", "_lbl_total") + row(11, "Samlet tid tilbage:", "_lbl_total_remain") + + layout.addWidget(stats) + + # Indstillinger + cfg = QFrame() + cfg.setObjectName("track_display") + cfg_layout = QGridLayout(cfg) + cfg_layout.setContentsMargins(12, 8, 12, 8) + cfg_layout.setSpacing(6) + + cfg_layout.addWidget(QLabel("Tid mellem musikstykker:"), 0, 0) + self._spin_pause = QSpinBox() + self._spin_pause.setRange(0, 600) + self._spin_pause.setValue(self._pause_seconds) + self._spin_pause.setSuffix(" sek") + self._spin_pause.setFixedWidth(90) + self._spin_pause.valueChanged.connect(self._on_pause_changed) + cfg_layout.addWidget(self._spin_pause, 0, 1) + + cfg_layout.addWidget(QLabel("Tid per workshop:"), 1, 0) + self._spin_ws = QSpinBox() + self._spin_ws.setRange(0, 120) + self._spin_ws.setValue(self._workshop_seconds // 60) + self._spin_ws.setSuffix(" min") + self._spin_ws.setFixedWidth(90) + self._spin_ws.valueChanged.connect(self._on_ws_changed) + cfg_layout.addWidget(self._spin_ws, 1, 1) + + layout.addWidget(cfg) + + # Fremgang og ETA + eta_frame = QFrame() + eta_frame.setObjectName("track_display") + eta_layout = QVBoxLayout(eta_frame) + eta_layout.setContentsMargins(12, 8, 12, 8) + eta_layout.setSpacing(4) + + self._lbl_eta = QLabel("") + self._lbl_eta.setWordWrap(True) + self._lbl_eta.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._lbl_eta.setObjectName("track_title") + eta_layout.addWidget(self._lbl_eta) + + self._lbl_finish = QLabel("") + self._lbl_finish.setWordWrap(True) + self._lbl_finish.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._lbl_finish.setObjectName("track_title") + eta_layout.addWidget(self._lbl_finish) + + layout.addWidget(eta_frame) + + def _on_pause_changed(self, value: int): + self._pause_seconds = value + if hasattr(self._panel, "_pause_seconds"): + self._panel._pause_seconds = value + self.pause_changed.emit(value) + self._update() + + def _on_ws_changed(self, minutes: int): + self._workshop_seconds = minutes * 60 + if hasattr(self._panel, "_workshop_seconds"): + self._panel._workshop_seconds = self._workshop_seconds + self._update() + + def _update(self): + songs = self._panel.get_songs() + statuses = self._panel.get_statuses() + total = len(songs) + played = statuses.count("played") + skipped = statuses.count("skipped") + remaining = total - played - skipped + + ws_total = sum(1 for s in songs if s.get("is_workshop")) + ws_remain = sum(1 for s, st in zip(songs, statuses) + if s.get("is_workshop") and st == "pending") + + music_total = sum(s.get("duration_sec", 0) for s in songs) + music_remain = sum( + s.get("duration_sec", 0) + for s, st in zip(songs, statuses) if st == "pending" + ) + + p = self._pause_seconds + w = self._workshop_seconds + pause_total = max(0, total - 1) * p + pause_remain = max(0, remaining - 1) * p + ws_time_total = ws_total * w + ws_time_remain = ws_remain * w + + total_time = music_total + pause_total + ws_time_total + remain_time = music_remain + pause_remain + ws_time_remain + + self._lbl_count.setText(str(total)) + self._lbl_played.setText(str(played)) + self._lbl_ws.setText(f"{ws_total} ({fmt_time(ws_time_total)})") + self._lbl_skipped.setText(str(skipped)) + self._lbl_remaining.setText(str(remaining)) + self._lbl_music_total.setText(fmt_time(music_total)) + self._lbl_music_remain.setText(fmt_time(music_remain)) + self._lbl_pause_total.setText(f"{fmt_time(pause_total)} ({max(0,total-1)} × {p}s)") + self._lbl_ws_total.setText(f"{fmt_time(ws_time_total)} ({ws_total} × {w//60}min)") + self._lbl_total.setText(fmt_time(total_time)) + self._lbl_total_remain.setText(fmt_time(remain_time)) + + # ETA + if remaining == 0 and total > 0: + self._lbl_eta.setText("✓ Danselisten er afsluttet!") + self._lbl_finish.setText("") + elif total > 0: + pct = int(played / total * 100) if total > 0 else 0 + self._lbl_eta.setText( + f"{pct}% færdig · {fmt_time(remain_time)} tilbage" + if played > 0 else f"Samlet varighed: {fmt_time(total_time)}" + ) + finish = datetime.now() + timedelta(seconds=remain_time) + self._lbl_finish.setText(f"Estimeret sluttid: {finish.strftime('%H:%M')}") + else: + self._lbl_eta.setText("Ingen sange i listen") + self._lbl_finish.setText("") diff --git a/linedance-app/ui/playlist_panel.py b/linedance-app/ui/playlist_panel.py index ba1808d7..c66ab668 100644 --- a/linedance-app/ui/playlist_panel.py +++ b/linedance-app/ui/playlist_panel.py @@ -5,7 +5,7 @@ playlist_panel.py — Danseliste med Ny/Gem/Hent knapper, autogem og event-overb from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView, - QMessageBox, QInputDialog, + QMessageBox, ) from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QByteArray from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent @@ -76,6 +76,13 @@ class PlaylistPanel(QWidget): btn_save.clicked.connect(self._save_as) toolbar.addWidget(btn_save) + self._btn_save_current = QPushButton("💾 Gem") + self._btn_save_current.setFixedHeight(26) + self._btn_save_current.setToolTip("Gem ændringer til den indlæste liste") + self._btn_save_current.clicked.connect(self._save_current) + self._btn_save_current.setEnabled(False) + toolbar.addWidget(self._btn_save_current) + btn_load = QPushButton("📂 Hent...") btn_load.setFixedHeight(26) btn_load.setToolTip("Hent en tidligere gemt danseliste") @@ -106,8 +113,24 @@ class PlaylistPanel(QWidget): self._lbl_progress.setObjectName("result_count") ctrl.addWidget(self._lbl_progress) + btn_info = QPushButton("ℹ") + btn_info.setFixedSize(24, 28) + btn_info.setToolTip("Danseliste-info: samlet tid, pause-tid m.m.") + btn_info.clicked.connect(self._show_playlist_info) + ctrl.addWidget(btn_info) + layout.addLayout(ctrl) + # Pause-tid per dans (skjult men kan vises via info) + try: + from ui.settings_dialog import load_settings + s = load_settings() + self._pause_seconds = s.get("between_seconds", 60) + self._workshop_seconds = s.get("workshop_minutes", 10) * 60 + except Exception: + self._pause_seconds = 60 + self._workshop_seconds = 600 + # ── Liste ───────────────────────────────────────────────────────────── self._list = QListWidget() self._list.setObjectName("playlist_list") @@ -201,6 +224,11 @@ class PlaylistPanel(QWidget): def _on_rows_moved(self, parent, start, end, dest, dest_row): """Opdater _songs og _statuses når en sang flyttes via drag.""" + # Husk hvilken sang der er aktiv + current_song_id = None + if 0 <= self._current_idx < len(self._songs): + current_song_id = self._songs[self._current_idx].get("id") + new_songs = [] new_statuses = [] for i in range(self._list.count()): @@ -210,17 +238,20 @@ class PlaylistPanel(QWidget): new_statuses.append(self._statuses[old_idx]) self._songs = new_songs self._statuses = new_statuses - self._current_idx = -1 + + # Gendan current_idx til den sang der stadig spiller + if current_song_id: + for i, s in enumerate(self._songs): + if s.get("id") == current_song_id: + self._current_idx = i + break + else: + self._current_idx = -1 + self._song_ended = False self._refresh() self._trigger_autosave() - - # Find første afspilbare sang og udsend signal så afspilleren opdateres - ni = self.next_playable_idx() - if ni is not None: - self._current_idx = ni - self._refresh() - self.next_song_ready.emit(self._songs[ni]) + # Emit IKKE next_song_ready — afspilning fortsætter uforstyrret # ── Event-state ─────────────────────────────────────────────────────────── @@ -292,42 +323,95 @@ class PlaylistPanel(QWidget): self._lbl_autosave.setText(f"⚠ gemfejl") pass + def _save_named_playlist_id(self, pl_id: int | None): + """Gem hvilken navngiven liste der er aktiv — til brug ved næste opstart.""" + from PyQt6.QtCore import QSettings + s = QSettings("LineDance", "Player") + if pl_id: + s.setValue("session/named_playlist_id", pl_id) + else: + s.remove("session/named_playlist_id") + def restore_active_playlist(self): - """Indlæs den sidst aktive liste ved opstart.""" + """Gendan senest aktive navngivne liste med event-fremgang ved opstart.""" try: - from local.local_db import get_db - with get_db() as conn: - pl = conn.execute( - "SELECT id FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,) - ).fetchone() - if not pl: - return False - songs_raw = conn.execute(""" - SELECT s.*, ps.position 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 = [] - for row in songs_raw: - dances = conn.execute( - "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order", - (row["id"],) - ).fetchall() - songs.append({ - "id": row["id"], "title": row["title"], - "artist": row["artist"], "album": row["album"], - "bpm": row["bpm"], "duration_sec": row["duration_sec"], - "local_path": row["local_path"], "file_format": row["file_format"], - "file_missing": bool(row["file_missing"]), - "dances": [d["dance_name"] for d in dances], - }) + from PyQt6.QtCore import QSettings + s = QSettings("LineDance", "Player") + pl_id = s.value("session/named_playlist_id", None, type=int) + if not pl_id: + return False + + import sqlite3 + from local.local_db import DB_PATH + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + + # Verificer at listen stadig eksisterer + pl = conn.execute( + "SELECT id, name FROM playlists WHERE id=?", (pl_id,) + ).fetchone() + if not pl: + conn.close() + return False + + # Hent sange med status, workshop og dans-override + songs_raw = conn.execute(""" + SELECT s.*, ps.position, ps.status, + ps.is_workshop, ps.dance_override + 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 d.name FROM song_dances sd + JOIN dances d ON d.id = sd.dance_id + WHERE sd.song_id=? ORDER BY sd.dance_order + """, (row["id"],)).fetchall() + dance_names = [d["name"] for d in dances] + override = row["dance_override"] or "" + active_dance = override if override else (dance_names[0] if dance_names else "") + songs.append({ + "id": row["id"], + "title": row["title"], + "artist": row["artist"], + "album": row["album"], + "bpm": row["bpm"], + "duration_sec": row["duration_sec"], + "local_path": row["local_path"], + "file_format": row["file_format"], + "file_missing": bool(row["file_missing"]), + "dances": dance_names, + "active_dance": active_dance, + "is_workshop": bool(row["is_workshop"]), + }) + statuses.append(row["status"] or "pending") + conn.close() + if songs: - self._songs = songs - self._statuses = ["pending"] * len(songs) - self._refresh() + self._songs = songs + self._statuses = statuses + self._named_playlist_id = pl_id + self._current_idx = -1 + self._song_ended = False + self._btn_save_current.setEnabled(True) + self._btn_save_current.setToolTip(f"Gem ændringer til '{pl['name']}'") + self._title_label.setText(f"DANSELISTE — {pl['name'].upper()}") self._lbl_autosave.setText("✓ gendannet") + self._refresh() + + # Find næste uafspillede og sæt den klar + ni = self.next_playable_idx() + if ni is not None: + self._current_idx = ni + self._refresh() + self.next_song_ready.emit(self._songs[ni]) + return True - except Exception as e: + except Exception: pass return False @@ -346,6 +430,10 @@ class PlaylistPanel(QWidget): self._statuses = [] self._current_idx = -1 self._song_ended = False + self._named_playlist_id = None + self._btn_save_current.setEnabled(False) + self._btn_save_current.setToolTip("Gem ændringer til den indlæste liste") + self._save_named_playlist_id(None) self._title_label.setText("DANSELISTE — NY") self._refresh() self._trigger_autosave() @@ -354,75 +442,88 @@ class PlaylistPanel(QWidget): if not self._songs: QMessageBox.information(self, "Gem", "Danselisten er tom.") return - name, ok = QInputDialog.getText( - self, "Gem danseliste", "Navn på danselisten:", + from ui.playlist_browser import PlaylistBrowserDialog + current_name = self._title_label.text().replace("DANSELISTE — ", "").replace("DANSELISTE", "").strip() + dialog = PlaylistBrowserDialog( + mode="save", + current_songs=self._songs, + current_name=current_name, + parent=self.window() ) - if not ok or not name.strip(): - return - name = name.strip() - try: - from local.local_db import create_playlist, add_song_to_playlist - pl_id = create_playlist(name) - for i, song in enumerate(self._songs, start=1): - if song.get("id"): - add_song_to_playlist(pl_id, song["id"], position=i) + def on_saved(pl_id, name): self._named_playlist_id = pl_id self._title_label.setText(f"DANSELISTE — {name.upper()}") self._lbl_autosave.setText(f"✓ gemt som \"{name}\"") + self._btn_save_current.setEnabled(True) + self._btn_save_current.setToolTip(f"Gem ændringer til '{name}'") + self._save_named_playlist_id(pl_id) + dialog.playlist_selected.connect(on_saved) + dialog.exec() + + def _save_current(self): + """Gem ændringer tilbage til den aktuelt indlæste navngivne liste.""" + if not self._named_playlist_id: + return + if not self._songs: + QMessageBox.information(self, "Gem", "Danselisten er tom.") + return + try: + from local.local_db import get_db + with get_db() as conn: + conn.execute( + "DELETE FROM playlist_songs WHERE playlist_id=?", + (self._named_playlist_id,) + ) + for i, song in enumerate(self._songs, start=1): + if song.get("id"): + status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending" + conn.execute( + "INSERT INTO playlist_songs " + "(playlist_id, song_id, position, status) VALUES (?,?,?,?)", + (self._named_playlist_id, song["id"], i, status) + ) + self._lbl_autosave.setText("✓ gemt") except Exception as e: QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}") def _load_dialog(self): - """Vis liste af gemte danselister og lad brugeren vælge.""" - try: - from local.local_db import get_db - with get_db() as conn: - lists = conn.execute( - "SELECT id, name, created_at FROM playlists " - "WHERE name != ? ORDER BY created_at DESC", - (ACTIVE_PLAYLIST_NAME,) - ).fetchall() - except Exception as e: - QMessageBox.warning(self, "Fejl", f"Kunne ikke hente lister: {e}") - return - - if not lists: - QMessageBox.information(self, "Hent liste", "Ingen gemte danselister fundet.") - return - - names = [f"{row['name']} ({row['created_at'][:10]})" for row in lists] - choice, ok = QInputDialog.getItem( - self, "Hent danseliste", "Vælg en liste:", names, editable=False - ) - if not ok: - return - - idx = names.index(choice) - pl_id = lists[idx]["id"] - pl_name = lists[idx]["name"] + from ui.playlist_browser import PlaylistBrowserDialog + dialog = PlaylistBrowserDialog(mode="load", parent=self.window()) + dialog.playlist_selected.connect(self._load_playlist_by_id) + dialog.exec() + def _load_playlist_by_id(self, pl_id: int, pl_name: str): try: from local.local_db import get_db with get_db() as conn: songs_raw = conn.execute(""" - SELECT s.*, ps.position, ps.status FROM playlist_songs ps + SELECT s.*, ps.position, ps.status, + ps.is_workshop, ps.dance_override + 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", - (row["id"],) - ).fetchall() + dances = conn.execute(""" + SELECT d.name FROM song_dances sd + JOIN dances d ON d.id = sd.dance_id + WHERE sd.song_id=? ORDER BY sd.dance_order + """, (row["id"],)).fetchall() + dance_names = [d["name"] for d in dances] + # dance_override bestemmer hvilken dans der vises + override = row["dance_override"] or "" + active_dance = override if override else (dance_names[0] if dance_names else "") songs.append({ "id": row["id"], "title": row["title"], "artist": row["artist"], "album": row["album"], "bpm": row["bpm"], "duration_sec": row["duration_sec"], "local_path": row["local_path"], "file_format": row["file_format"], "file_missing": bool(row["file_missing"]), - "dances": [d["dance_name"] for d in dances], + "dances": dance_names, + "active_dance": active_dance, + "is_workshop": bool(row["is_workshop"]), }) statuses.append(row["status"] or "pending") self._songs = songs @@ -432,6 +533,9 @@ class PlaylistPanel(QWidget): self._named_playlist_id = pl_id self._title_label.setText(f"DANSELISTE — {pl_name.upper()}") self._lbl_autosave.setText("✓ gendannet") + self._btn_save_current.setEnabled(True) + self._btn_save_current.setToolTip(f"Gem ændringer til '{pl_name}'") + self._save_named_playlist_id(pl_id) self._refresh() self._trigger_autosave() except Exception as e: @@ -439,6 +543,94 @@ class PlaylistPanel(QWidget): # ── Start event ─────────────────────────────────────────────────────────── + def _show_playlist_info(self): + """Åbn/luk det flydende danseliste-info vindue.""" + try: + if hasattr(self, "_info_window") and self._info_window \ + and self._info_window.isVisible(): + self._info_window.close() + self._info_window = None + return + except RuntimeError: + self._info_window = None + + # Opdater defaults fra indstillinger ved åbning + try: + from ui.settings_dialog import load_settings + s = load_settings() + self._pause_seconds = s.get("between_seconds", 60) + self._workshop_seconds = s.get("workshop_minutes", 10) * 60 + except Exception: + pass + + from ui.playlist_info_dialog import PlaylistInfoWindow + from PyQt6.QtWidgets import QApplication + main = self.window() + self._info_window = PlaylistInfoWindow(self, parent=main) + QApplication.instance().aboutToQuit.connect(self._info_window.close) + if main: + geo = main.geometry() + self._info_window.move(geo.right() + 10, geo.top() + 100) + self._info_window.show() + + def _change_dance(self, idx: int, song: dict): + """Lad brugeren vælge/skrive hvilken dans der vises for dette nummer.""" + from ui.dance_picker_dialog import DancePickerDialog + current = song.get("active_dance", "") + if not current: + dances = song.get("dances", []) + current = dances[0] if dances else "" + dlg = DancePickerDialog( + current_dance=current, + song_title=song.get("title", ""), + parent=self.window() + ) + if dlg.exec(): + chosen = dlg.get_dance() + if chosen: + song["active_dance"] = chosen + self._refresh() + self._sync_dance_to_db(idx, song) + + def _sync_dance_to_db(self, idx: int, song: dict): + """Gem dance_override til playlist_songs.""" + if not self._named_playlist_id: + return + try: + from local.local_db import get_db + with get_db() as conn: + conn.execute( + "UPDATE playlist_songs SET dance_override=? " + "WHERE playlist_id=? AND position=?", + (song.get("active_dance", ""), self._named_playlist_id, idx + 1) + ) + except Exception: + pass + + def _sync_ws_to_db(self, idx: int, song: dict): + """Gem is_workshop til playlist_songs — både navngiven og aktiv liste.""" + pl_ids = [] + if self._named_playlist_id: + pl_ids.append(self._named_playlist_id) + if self._active_playlist_id: + pl_ids.append(self._active_playlist_id) + if not pl_ids: + return + try: + from local.local_db import get_db + with get_db() as conn: + for pl_id in pl_ids: + conn.execute( + "UPDATE playlist_songs SET is_workshop=? " + "WHERE playlist_id=? AND position=?", + (1 if song.get("is_workshop") else 0, pl_id, idx + 1) + ) + except Exception: + pass + + def _on_pause_changed(self, seconds: int): + self._pause_seconds = seconds + def _start_event(self): if not self._songs: return @@ -469,6 +661,7 @@ class PlaylistPanel(QWidget): idx = item.data(Qt.ItemDataRole.UserRole) if idx is None: return + song = self._songs[idx] if 0 <= idx < len(self._songs) else None menu = QMenu(self) act_play = menu.addAction("▶ Afspil denne") menu.addSeparator() @@ -476,6 +669,14 @@ class PlaylistPanel(QWidget): act_unplay = menu.addAction("↺ Sæt til ikke afspillet") act_played = menu.addAction("✓ Sæt til afspillet") menu.addSeparator() + # Dans-valg + act_dance = menu.addAction("💃 Vælg dans...") + # Workshop toggle + is_ws = song.get("is_workshop", False) if song else False + act_ws = menu.addAction("🎓 Fjern workshop" if is_ws else "🎓 Markér som workshop") + menu.addSeparator() + act_dance_info = menu.addAction("ℹ Dans-info...") + menu.addSeparator() act_remove = menu.addAction("✕ Fjern fra liste") action = menu.exec(self._list.mapToGlobal(pos)) if action == act_play: @@ -492,6 +693,18 @@ class PlaylistPanel(QWidget): self._statuses[idx] = "played" self.status_changed.emit(idx, "played") self._refresh(); self._trigger_autosave(); self._trigger_event_state_save() + elif action == act_dance and song: + self._change_dance(idx, song) + elif action == act_ws and song: + song["is_workshop"] = not song.get("is_workshop", False) + self._sync_ws_to_db(idx, song) + self._refresh() + self.playlist_changed.emit() + elif action == act_dance_info: + if song: + from ui.dance_info_dialog import DanceInfoDialog + dlg = DanceInfoDialog(song, parent=self.window()) + dlg.exec() elif action == act_remove: self._songs.pop(idx) self._statuses.pop(idx) @@ -507,17 +720,22 @@ class PlaylistPanel(QWidget): self._lbl_progress.setText(f"{played} / {len(self._songs)} afspillet") for i, song in enumerate(self._songs): is_current = (i == self._current_idx and not self._song_ended) - is_next = (self._song_ended and i == self._current_idx + 1) or \ - (self._current_idx == -1 and self._song_ended and i == 0) - status = "playing" if is_current else "next" if is_next else self._statuses[i] + status = "playing" if is_current else self._statuses[i] icon = self.STATUS_ICON.get(status, " ") - dances = " / ".join(song.get("dances", [])) or "ingen dans tagget" - text = f"{i+1:>2}. {song.get('title','—')}\n {song.get('artist','')} · {dances}" - item = QListWidgetItem(f"{icon} {text}") + + # Vis active_dance (override eller første dans) eller alle danse + active = song.get("active_dance", "") + if not active: + dances = song.get("dances", []) + active = dances[0] if dances else "ingen dans tagget" + ws_tag = " 🎓" if song.get("is_workshop") else "" + + text = (f"{i+1:>2}. {song.get('title','—')}{ws_tag}\n" + f" {song.get('artist','')} · {active}") + item = QListWidgetItem(f"{icon} {text}") item.setData(Qt.ItemDataRole.UserRole, i) - color = self.STATUS_COLOR.get(status, "#5a6070") - if status in ("playing", "next"): - item.setForeground(QColor(color)) + if status == "playing": + item.setForeground(QColor(self.STATUS_COLOR["playing"])) f = item.font(); f.setBold(True); item.setFont(f) elif status == "played": item.setForeground(QColor("#2ecc71")) diff --git a/linedance-app/ui/settings_dialog.py b/linedance-app/ui/settings_dialog.py index c273519c..d09a5049 100644 --- a/linedance-app/ui/settings_dialog.py +++ b/linedance-app/ui/settings_dialog.py @@ -20,6 +20,9 @@ SETTINGS_KEY_MAIL_PATH = "mail/custom_path" SETTINGS_KEY_AUTO_LOGIN = "online/auto_login" SETTINGS_KEY_USERNAME = "online/username" SETTINGS_KEY_PASSWORD = "online/password" +SETTINGS_KEY_LANGUAGE = "appearance/language" +SETTINGS_KEY_BETWEEN_SEC = "playback/between_seconds" +SETTINGS_KEY_WORKSHOP_MIN = "playback/workshop_minutes" def load_settings() -> dict: @@ -34,6 +37,9 @@ def load_settings() -> dict: "auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool), "username": s.value(SETTINGS_KEY_USERNAME, ""), "password": s.value(SETTINGS_KEY_PASSWORD, ""), + "language": s.value(SETTINGS_KEY_LANGUAGE, "da"), + "between_seconds": s.value(SETTINGS_KEY_BETWEEN_SEC, 60, type=int), + "workshop_minutes": s.value(SETTINGS_KEY_WORKSHOP_MIN, 10, type=int), } @@ -48,6 +54,9 @@ def save_settings(values: dict): 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_LANGUAGE, values.get("language", "da")) + s.setValue(SETTINGS_KEY_BETWEEN_SEC, values.get("between_seconds", 60)) + s.setValue(SETTINGS_KEY_WORKSHOP_MIN,values.get("workshop_minutes", 10)) class SettingsDialog(QDialog): @@ -70,6 +79,7 @@ class SettingsDialog(QDialog): tabs.addTab(self._build_playback_tab(), "▶ Afspilning") tabs.addTab(self._build_mail_tab(), "✉ Mail") tabs.addTab(self._build_online_tab(), "🌐 Online") + tabs.addTab(self._build_language_tab(), "🌍 Sprog") layout.addWidget(tabs) # Knapper @@ -142,6 +152,23 @@ class SettingsDialog(QDialog): note.setWordWrap(True) grp_layout.addRow(note) layout.addWidget(grp) + + grp2 = QGroupBox("Danseliste-tider (ℹ info-vinduet)") + grp2_layout = QFormLayout(grp2) + + self._spin_between = QSpinBox() + self._spin_between.setRange(0, 600) + self._spin_between.setSuffix(" sekunder") + self._spin_between.setFixedWidth(140) + grp2_layout.addRow("Tid mellem musikstykker:", self._spin_between) + + self._spin_workshop = QSpinBox() + self._spin_workshop.setRange(0, 120) + self._spin_workshop.setSuffix(" minutter") + self._spin_workshop.setFixedWidth(140) + grp2_layout.addRow("Tid per workshop:", self._spin_workshop) + + layout.addWidget(grp2) layout.addStretch() return tab @@ -230,6 +257,24 @@ class SettingsDialog(QDialog): layout.addStretch() return tab + def _build_language_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(12) + grp = QGroupBox("Sprog") + grp_layout = QFormLayout(grp) + self._lang_combo = QComboBox() + self._lang_combo.addItem("Dansk", "da") + self._lang_combo.addItem("English", "en") + grp_layout.addRow("Programsprog:", self._lang_combo) + note = QLabel("Sproget anvendes næste gang programmet startes.") + note.setObjectName("result_count") + note.setWordWrap(True) + grp_layout.addRow(note) + layout.addWidget(grp) + layout.addStretch() + return tab + def _on_auto_login_changed(self, state: int): enabled = state == Qt.CheckState.Checked.value self._user_input.setEnabled(enabled) @@ -242,6 +287,15 @@ class SettingsDialog(QDialog): 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)) + self._spin_between.setValue(v.get("between_seconds", 60)) + self._spin_workshop.setValue(v.get("workshop_minutes", 10)) + + # Sprog + lang = v.get("language", "da") + for i in range(self._lang_combo.count()): + if self._lang_combo.itemData(i) == lang: + self._lang_combo.setCurrentIndex(i) + break # Mail client = v.get("mail_client", "auto") @@ -267,11 +321,14 @@ class SettingsDialog(QDialog): "dark_theme": self._chk_dark.isChecked(), "demo_seconds": self._spin_demo.value(), "demo_fade_seconds": self._spin_fade.value(), + "between_seconds": self._spin_between.value(), + "workshop_minutes": self._spin_workshop.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(), + "language": self._lang_combo.currentData(), } save_settings(values) self._values = values diff --git a/linedance-app/ui/tag_editor.py b/linedance-app/ui/tag_editor.py index 64223db4..07b450cb 100644 --- a/linedance-app/ui/tag_editor.py +++ b/linedance-app/ui/tag_editor.py @@ -6,87 +6,26 @@ Dans = navn + niveau kombination. Autoudfyld viser "Navn / Niveau". from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QWidget, QMessageBox, QGroupBox, - QScrollArea, QFrame, + QScrollArea, QFrame, QListWidget, QListWidgetItem, ) -from PyQt6.QtCore import Qt, QTimer, QStringListModel -from PyQt6.QtWidgets import QCompleter +from PyQt6.QtCore import Qt, QTimer class DanceLineEdit(QLineEdit): - """Autoudfyld der viser 'Navn / Niveau' fra dances tabellen.""" + """Simpelt tekstfelt til dans-navn i eksisterende rækker.""" + from PyQt6.QtCore import pyqtSignal + dance_selected = pyqtSignal(dict) def __init__(self, placeholder="", parent=None): super().__init__(parent) self.setPlaceholderText(placeholder) - self._model = QStringListModel() - self._suggestions = [] # liste af {id, name, level_id, level_name} - comp = QCompleter(self._model, self) - comp.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) - comp.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) - comp.setMaxVisibleItems(12) - comp.activated.connect(self._on_activated) - self.setCompleter(comp) - self._selected_dance = None # {id, name, level_id, level_name} - t = QTimer(self) - t.setSingleShot(True) - t.setInterval(150) - t.timeout.connect(self._suggest) - self.textChanged.connect(lambda _: (t.start(), self._clear_selection())) - self._timer = t - - def _clear_selection(self): - self._selected_dance = None - - def _suggest(self): - prefix = self.text().strip() - if "/" in prefix: - prefix = prefix.split("/")[0].strip() - if not prefix: - return - try: - from local.local_db import get_dance_suggestions - self._suggestions = get_dance_suggestions(prefix, limit=15) - labels = [] - for s in self._suggestions: - if s.get("level_name"): - labels.append(f"{s['name']} / {s['level_name']}") - else: - labels.append(s["name"]) - self._model.setStringList(labels) - except Exception: - pass - - def _on_activated(self, text: str): - """Bruger valgte et forslag — gem hele dance-objektet.""" - for s in self._suggestions: - label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"] - if label == text: - self._selected_dance = s - break def get_dance_info(self) -> dict: - """Returnerer {name, level_id} — fra valgt forslag eller fra fritekst.""" - if self._selected_dance: - return { - "name": self._selected_dance["name"], - "level_id": self._selected_dance["level_id"], - } - # Fritekst — parse "Navn / Niveau" hvis bruger har skrevet det manuelt text = self.text().strip() - if "/" in text: - parts = text.split("/", 1) - name = parts[0].strip() - level_name = parts[1].strip() - # Slå niveau op - try: - from local.local_db import get_dance_levels - for lvl in get_dance_levels(): - if lvl["name"].lower() == level_name.lower(): - return {"name": name, "level_id": lvl["id"]} - except Exception: - pass - return {"name": name, "level_id": None} - return {"name": text, "level_id": None} + if " / " in text: + parts = text.split(" / ", 1) + return {"name": parts[0].strip()} + return {"name": text} class TagEditorDialog(QDialog): @@ -167,8 +106,11 @@ class TagEditorDialog(QDialog): layout.addLayout(btn_row) def _build_dances_panel(self) -> QGroupBox: - grp = QGroupBox("Danse") + from translations import _ + grp = QGroupBox(_("tags.dances")) layout = QVBoxLayout(grp) + + # Eksisterende danse scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.Shape.NoFrame) @@ -180,34 +122,77 @@ class TagEditorDialog(QDialog): layout.addWidget(scroll, stretch=1) self._dance_rows = [] for d in self._dances: - label = f"{d['name']} / {d['level_name']}" if d.get("level_name") else d["name"] - self._add_dance_row(label) + self._add_dance_row(d["name"], d["level_id"]) - add_row = QHBoxLayout() - self._new_dance = DanceLineEdit("Ny dans (f.eks. Cowboy Cha Cha / Begynder)...", self) + # Søgefelt + self._new_dance = QLineEdit() + self._new_dance.setPlaceholderText(_("tags.new_dance")) + self._new_dance.textChanged.connect(self._on_dance_search) 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) + layout.addWidget(self._new_dance) + + # Forslags-liste + self._dance_suggestions = QListWidget() + self._dance_suggestions.setMaximumHeight(120) + self._dance_suggestions.itemClicked.connect( + lambda item: self._add_from_suggestion(item, "dance") + ) + layout.addWidget(self._dance_suggestions) + + # Timer til debounce + self._dance_search_timer = QTimer(self) + self._dance_search_timer.setSingleShot(True) + self._dance_search_timer.setInterval(150) + self._dance_search_timer.timeout.connect( + lambda: self._load_dance_suggestions( + self._new_dance.text().strip(), self._dance_suggestions + ) + ) + self._load_dance_suggestions("", self._dance_suggestions) return grp - def _add_dance_row(self, text=""): + def _add_dance_row(self, name="", level_id=None): + from translations import _ row_widget = QWidget() row_layout = QHBoxLayout(row_widget) row_layout.setContentsMargins(0, 0, 0, 0) row_layout.setSpacing(4) + edit = DanceLineEdit("Dans...", self) - edit.setText(text) + edit.setText(name) row_layout.addWidget(edit, stretch=1) + + # Niveau-dropdown + level_cb = QComboBox() + level_cb.addItem(_("tags.no_level"), None) + for lvl in self._levels: + from translations import translate_level + level_cb.addItem(translate_level(lvl["name"]), lvl["id"]) + # Sæt til det rigtige niveau + if level_id is not None: + for i in range(level_cb.count()): + if level_cb.itemData(i) == level_id: + level_cb.setCurrentIndex(i) + break + level_cb.setFixedWidth(130) + row_layout.addWidget(level_cb) + + # Når autoudfyld vælger — opdater dropdown + def on_dance_selected(dance_info, cb=level_cb): + if dance_info.get("level_id") is not None: + for i in range(cb.count()): + if cb.itemData(i) == dance_info["level_id"]: + cb.setCurrentIndex(i) + break + edit.dance_selected.connect(on_dance_selected) + btn_rm = QPushButton("✕") btn_rm.setFixedSize(24, 24) row_layout.addWidget(btn_rm) + idx = self._dance_layout.count() - 1 self._dance_layout.insertWidget(idx, row_widget) - entry = {"widget": row_widget, "edit": edit} + entry = {"widget": row_widget, "edit": edit, "level": level_cb} self._dance_rows.append(entry) btn_rm.clicked.connect(lambda: self._remove_dance_row(entry)) @@ -215,13 +200,62 @@ class TagEditorDialog(QDialog): self._dance_rows.remove(entry) entry["widget"].deleteLater() - def _on_add_dance(self): - if self._new_dance.text().strip(): - self._add_dance_row(self._new_dance.text().strip()) + def _on_dance_search(self): + self._dance_search_timer.start() + + def _on_alt_search(self): + self._alt_search_timer.start() + + def _load_dance_suggestions(self, prefix: str, list_widget): + try: + from local.local_db import get_dance_suggestions + suggestions = get_dance_suggestions(prefix, limit=15) + list_widget.clear() + for s in suggestions: + label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"] + item = QListWidgetItem(label) + item.setData(Qt.ItemDataRole.UserRole, s.get("level_id")) + item.setData(Qt.ItemDataRole.UserRole + 1, s["name"]) + list_widget.addItem(item) + except Exception: + pass + + def _add_from_suggestion(self, item, panel: str): + """Tilføj dans fra forslags-listen ved dobbeltklik.""" + name = item.data(Qt.ItemDataRole.UserRole + 1) or item.text().split(" / ")[0] + level_id = item.data(Qt.ItemDataRole.UserRole) + if panel == "dance": + self._add_dance_row(name, level_id) self._new_dance.clear() + self._load_dance_suggestions("", self._dance_suggestions) + else: + self._add_alt_row(name, level_id) + self._new_alt.clear() + self._load_dance_suggestions("", self._alt_suggestions) + + def _on_add_dance(self): + text = self._new_dance.text().strip() + if text: + name, level_id = self._parse_name_level(text) + self._add_dance_row(name, level_id) + self._new_dance.clear() + self._load_dance_suggestions("", self._dance_suggestions) + + def _parse_name_level(self, text: str) -> tuple: + """Parse 'Navn / Niveau' og returnér (name, level_id).""" + if " / " in text: + parts = text.split(" / ", 1) + name = parts[0].strip() + level_name = parts[1].strip() + for lvl in self._levels: + if lvl["name"].lower() == level_name.lower(): + return name, lvl["id"] + return name, None + return text, None def _build_alts_panel(self) -> QGroupBox: - grp = QGroupBox("Alternativ-danse") + from translations import _ + grp = QGroupBox(_("tags.alts")) layout = QVBoxLayout(grp) scroll = QScrollArea() scroll.setWidgetResizable(True) @@ -234,42 +268,82 @@ class TagEditorDialog(QDialog): layout.addWidget(scroll, stretch=1) self._alt_rows = [] for a in self._alts: - label = f"{a['name']} / {a['level_name']}" if a.get("level_name") else a["name"] - self._add_alt_row(label, a.get("note", "")) + self._add_alt_row(a["name"], a["level_id"], a.get("note", "")) - add_row = QHBoxLayout() - self._new_alt = DanceLineEdit("Alternativ dans...", self) + self._new_alt = QLineEdit() + self._new_alt.setPlaceholderText(_("tags.new_alt")) + self._new_alt.textChanged.connect(self._on_alt_search) 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) + layout.addWidget(self._new_alt) + + self._alt_suggestions = QListWidget() + self._alt_suggestions.setMaximumHeight(120) + self._alt_suggestions.itemClicked.connect( + lambda item: self._add_from_suggestion(item, "alt") + ) + layout.addWidget(self._alt_suggestions) + + self._alt_search_timer = QTimer(self) + self._alt_search_timer.setSingleShot(True) + self._alt_search_timer.setInterval(150) + self._alt_search_timer.timeout.connect( + lambda: self._load_dance_suggestions( + self._new_alt.text().strip(), self._alt_suggestions + ) + ) + self._load_dance_suggestions("", self._alt_suggestions) return grp - def _add_alt_row(self, text="", note=""): + def _add_alt_row(self, name="", level_id=None, note=""): + from translations import _ row_widget = QWidget() row_layout = QHBoxLayout(row_widget) row_layout.setContentsMargins(0, 0, 0, 0) row_layout.setSpacing(4) + lbl = QLabel("→") lbl.setObjectName("track_meta") row_layout.addWidget(lbl) + edit = DanceLineEdit("Dans...", self) - edit.setText(text) + edit.setText(name) row_layout.addWidget(edit, stretch=1) + + # Niveau-dropdown + level_cb = QComboBox() + level_cb.addItem(_("tags.no_level"), None) + for lvl in self._levels: + from translations import translate_level + level_cb.addItem(translate_level(lvl["name"]), lvl["id"]) + if level_id is not None: + for i in range(level_cb.count()): + if level_cb.itemData(i) == level_id: + level_cb.setCurrentIndex(i) + break + level_cb.setFixedWidth(130) + row_layout.addWidget(level_cb) + + def on_dance_selected(dance_info, cb=level_cb): + if dance_info.get("level_id") is not None: + for i in range(cb.count()): + if cb.itemData(i) == dance_info["level_id"]: + cb.setCurrentIndex(i) + break + edit.dance_selected.connect(on_dance_selected) + note_edit = QLineEdit() - note_edit.setPlaceholderText("note...") + note_edit.setPlaceholderText(_("tags.note")) note_edit.setText(note) note_edit.setFixedWidth(80) row_layout.addWidget(note_edit) + btn_rm = QPushButton("✕") btn_rm.setFixedSize(24, 24) row_layout.addWidget(btn_rm) + idx = self._alt_layout.count() - 1 self._alt_layout.insertWidget(idx, row_widget) - entry = {"widget": row_widget, "edit": edit, "note": note_edit} + entry = {"widget": row_widget, "edit": edit, "level": level_cb, "note": note_edit} self._alt_rows.append(entry) btn_rm.clicked.connect(lambda: self._remove_alt_row(entry)) @@ -278,9 +352,12 @@ class TagEditorDialog(QDialog): entry["widget"].deleteLater() def _on_add_alt(self): - if self._new_alt.text().strip(): - self._add_alt_row(self._new_alt.text().strip()) + text = self._new_alt.text().strip() + if text: + name, level_id = self._parse_name_level(text) + self._add_alt_row(name, level_id) self._new_alt.clear() + self._load_dance_suggestions("", self._alt_suggestions) def _save(self): song_id = self._song.get("id") @@ -290,18 +367,25 @@ class TagEditorDialog(QDialog): from local.local_db import new_conn, get_or_create_dance from local.tag_reader import write_dances, can_write_dances - # Saml data fra UI + # Saml data fra UI — niveau kommer fra dropdown, ikke fra tekstfeltet dances = [] for row in self._dance_rows: - info = row["edit"].get_dance_info() - if info["name"]: - dances.append(info) + name = row["edit"].text().strip() + if name: + dances.append({ + "name": name, + "level_id": row["level"].currentData(), + }) alts = [] for row in self._alt_rows: - info = row["edit"].get_dance_info() - if info["name"]: - alts.append({**info, "note": row["note"].text().strip()}) + name = row["edit"].text().strip() + if name: + alts.append({ + "name": name, + "level_id": row["level"].currentData(), + "note": row["note"].text().strip(), + }) conn = new_conn()