Næste version

This commit is contained in:
2026-04-12 10:25:41 +02:00
parent b678787236
commit 57f3c913b4
18 changed files with 2690 additions and 458 deletions

View File

@@ -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."""

View File

@@ -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

View File

@@ -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()

View File

@@ -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):

24
linedance-app/setup.sh Executable file
View File

@@ -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"

View File

@@ -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")

View File

@@ -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",
}

View File

@@ -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",
}

View File

@@ -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))

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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("")

View File

@@ -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}")

View File

@@ -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("")

View File

@@ -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"))

View File

@@ -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

View File

@@ -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()