Næste version
This commit is contained in:
@@ -148,21 +148,98 @@ class LibraryWatcher:
|
|||||||
self._running = False
|
self._running = False
|
||||||
|
|
||||||
def add_library(self, path: str) -> int:
|
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)
|
library_id = add_library(path)
|
||||||
|
|
||||||
if self._observer and self._running:
|
if self._observer and self._running:
|
||||||
handler = MusicLibraryHandler(library_id, self.on_change)
|
handler = MusicLibraryHandler(library_id, self.on_change)
|
||||||
self._observer.schedule(handler, path, recursive=True)
|
self._observer.schedule(handler, path, recursive=True)
|
||||||
logger.info(f"Tilføjet bibliotek: {path}")
|
|
||||||
|
|
||||||
# Scan det nye bibliotek i baggrunden
|
# Scan i baggrundstråd med daemon=True så den ikke blokerer programlukning
|
||||||
threading.Thread(
|
def _scan_in_background(lib_id, lib_path):
|
||||||
target=self._full_scan_library,
|
try:
|
||||||
args=(library_id, path),
|
import sqlite3
|
||||||
daemon=True,
|
from local.local_db import DB_PATH, is_supported, get_file_modified_at
|
||||||
).start()
|
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
|
return library_id
|
||||||
|
|
||||||
def remove_library(self, library_id: int):
|
def remove_library(self, library_id: int):
|
||||||
@@ -182,69 +259,159 @@ class LibraryWatcher:
|
|||||||
self._observer.schedule(handler, str(path), recursive=True)
|
self._observer.schedule(handler, str(path), recursive=True)
|
||||||
|
|
||||||
def _full_scan_all(self):
|
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):
|
for lib in get_libraries(active_only=True):
|
||||||
path = Path(lib["path"])
|
path = Path(lib["path"])
|
||||||
if path.exists():
|
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):
|
def _full_scan_library(self, library_id: int, library_path: str):
|
||||||
"""
|
"""Scan ét bibliotek med sin egen SQLite-forbindelse — blokerer aldrig GUI."""
|
||||||
Sammenligner filer på disk med SQLite og synkroniserer forskelle.
|
import sqlite3, uuid, json, os
|
||||||
Håndterer utilgængelige mapper og symlinks sikkert.
|
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}")
|
logger.info(f"Fuld scan starter: {library_path}")
|
||||||
base = Path(library_path)
|
base = Path(library_path)
|
||||||
|
|
||||||
# Tjek at mappen faktisk er tilgængelig — med timeout
|
|
||||||
if not self._path_accessible(base):
|
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
|
return
|
||||||
|
|
||||||
known = get_all_song_paths_for_library(library_id)
|
try:
|
||||||
found_paths = set()
|
conn = sqlite3.connect(str(DB_PATH))
|
||||||
processed = 0
|
conn.row_factory = sqlite3.Row
|
||||||
errors = 0
|
|
||||||
|
|
||||||
import os
|
# Hent kendte stier og modified-tider
|
||||||
for dirpath, dirnames, filenames in os.walk(
|
known = {
|
||||||
str(base), followlinks=False,
|
row["local_path"]: row["file_modified_at"]
|
||||||
onerror=lambda e: logger.warning(f"Adgang nægtet: {e}")
|
for row in conn.execute(
|
||||||
):
|
"SELECT local_path, file_modified_at FROM songs WHERE library_id=?",
|
||||||
for filename in filenames:
|
(library_id,)
|
||||||
file_path = Path(dirpath) / filename
|
).fetchall()
|
||||||
try:
|
}
|
||||||
|
|
||||||
|
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):
|
if not is_supported(file_path):
|
||||||
continue
|
continue
|
||||||
path_str = str(file_path)
|
path_str = str(file_path)
|
||||||
found_paths.add(path_str)
|
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 = read_tags(file_path)
|
||||||
tags["library_id"] = library_id
|
extra = json.dumps(tags.get("extra_tags", {}), ensure_ascii=False)
|
||||||
upsert_song(tags)
|
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
|
processed += 1
|
||||||
if self.on_change:
|
if self.on_change:
|
||||||
self.on_change("upserted", path_str, None)
|
self.on_change("upserted", path_str, song_id)
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Scan-fejl for {file_path}: {e}")
|
|
||||||
errors += 1
|
|
||||||
|
|
||||||
# Marker forsvundne filer
|
except Exception as e:
|
||||||
missing_count = 0
|
logger.error(f"Scan-fejl for {file_path}: {e}")
|
||||||
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)
|
|
||||||
|
|
||||||
update_library_scan_time(library_id)
|
# Markér forsvundne filer
|
||||||
logger.info(
|
for known_path in known:
|
||||||
f"Scan færdig: {library_path} — "
|
if known_path not in found_paths:
|
||||||
f"{processed} opdateret, {missing_count} mangler, {errors} fejl"
|
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:
|
def _path_accessible(self, path: Path, timeout_sec: float = 5.0) -> bool:
|
||||||
"""Tjek om en sti er tilgængelig inden for timeout."""
|
"""Tjek om en sti er tilgængelig inden for timeout."""
|
||||||
|
|||||||
@@ -232,6 +232,25 @@ MIGRATIONS: dict[int, list[str]] = {
|
|||||||
SELECT DISTINCT dance_name, level_id, 'local'
|
SELECT DISTINCT dance_name, level_id, 'local'
|
||||||
FROM song_dances WHERE dance_name IS NOT NULL AND dance_name != ''""",
|
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):
|
def remove_library(library_id: int):
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
# Marker sange som manglende
|
# Marker sange som manglende og løsriv dem fra biblioteket
|
||||||
conn.execute(
|
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,))
|
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 ─────────────────────────────────────────────────────────
|
# ── Afspilningslister ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def create_playlist(name: str, description: str = "") -> int:
|
def create_playlist(name: str, description: str = "", tags: str = "") -> int:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO playlists (name, description) VALUES (?,?)",
|
"INSERT INTO playlists (name, description, tags) VALUES (?,?,?)",
|
||||||
(name, description)
|
(name, description, tags)
|
||||||
)
|
)
|
||||||
return cur.lastrowid
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
def get_playlists() -> list[sqlite3.Row]:
|
def update_playlist_tags(playlist_id: int, tags: str):
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
return conn.execute(
|
conn.execute(
|
||||||
"SELECT * FROM playlists ORDER BY created_at DESC"
|
"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()
|
).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:
|
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 ───────────────────────────────────────────────────
|
# ── 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,
|
def get_or_create_dance(name: str, level_id: int | None,
|
||||||
conn=None) -> int:
|
conn=None) -> int:
|
||||||
"""Find eller opret en dans (name + level_id kombination).
|
"""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]:
|
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:
|
with get_db() as conn:
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT d.id as dance_id, d.name, d.level_id,
|
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
|
FROM song_dances sd
|
||||||
JOIN dances d ON d.id = sd.dance_id
|
JOIN dances d ON d.id = sd.dance_id
|
||||||
LEFT JOIN dance_levels dl ON dl.id = d.level_id
|
LEFT JOIN dance_levels dl ON dl.id = d.level_id
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ Start:
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Sørg for at rodmappen er i Python-stien
|
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication
|
from PyQt6.QtWidgets import QApplication
|
||||||
@@ -21,7 +20,13 @@ def main():
|
|||||||
app.setApplicationName("LineDance Player")
|
app.setApplicationName("LineDance Player")
|
||||||
app.setOrganizationName("LineDance")
|
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 = MainWindow()
|
||||||
window.show()
|
window.show()
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ class Player(QObject):
|
|||||||
self._demo_mode = False
|
self._demo_mode = False
|
||||||
|
|
||||||
if VLC_AVAILABLE and self._media_player:
|
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.set_media(media)
|
||||||
self._media_player.audio_set_volume(self._volume)
|
self._media_player.audio_set_volume(self._volume)
|
||||||
|
|
||||||
@@ -71,6 +73,33 @@ class Player(QObject):
|
|||||||
self.time_changed.emit(0, self._duration)
|
self.time_changed.emit(0, self._duration)
|
||||||
self.state_changed.emit("stopped")
|
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 ─────────────────────────────────────────────────────────────
|
# ── Transport ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def play(self):
|
def play(self):
|
||||||
|
|||||||
24
linedance-app/setup.sh
Executable file
24
linedance-app/setup.sh
Executable 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"
|
||||||
57
linedance-app/translations/__init__.py
Normal file
57
linedance-app/translations/__init__.py
Normal 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")
|
||||||
201
linedance-app/translations/da.py
Normal file
201
linedance-app/translations/da.py
Normal 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",
|
||||||
|
}
|
||||||
201
linedance-app/translations/en.py
Normal file
201
linedance-app/translations/en.py
Normal 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",
|
||||||
|
}
|
||||||
226
linedance-app/ui/dance_info_dialog.py
Normal file
226
linedance-app/ui/dance_info_dialog.py
Normal 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))
|
||||||
105
linedance-app/ui/dance_picker_dialog.py
Normal file
105
linedance-app/ui/dance_picker_dialog.py
Normal 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
|
||||||
@@ -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 (
|
from PyQt6.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QWidget,
|
||||||
QPushButton, QListWidget, QListWidgetItem, QMessageBox,
|
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):
|
class LibraryManagerDialog(QDialog):
|
||||||
library_removed = pyqtSignal(int) # library_id
|
library_removed = pyqtSignal(int)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Administrer musikbiblioteker")
|
self.setWindowTitle("Administrer musikbiblioteker")
|
||||||
self.setMinimumWidth(500)
|
self.setMinimumWidth(580)
|
||||||
self.setMinimumHeight(320)
|
self.setMinimumHeight(360)
|
||||||
|
self._bpm_workers = {} # library_id → BpmScanWorker
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
@@ -29,8 +83,10 @@ class LibraryManagerDialog(QDialog):
|
|||||||
lbl.setObjectName("track_meta")
|
lbl.setObjectName("track_meta")
|
||||||
layout.addWidget(lbl)
|
layout.addWidget(lbl)
|
||||||
|
|
||||||
self._list = QListWidget()
|
self._libs_layout = QVBoxLayout()
|
||||||
layout.addWidget(self._list)
|
self._libs_layout.setSpacing(6)
|
||||||
|
layout.addLayout(self._libs_layout)
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
note = QLabel(
|
note = QLabel(
|
||||||
"Når du fjerner et bibliotek, slettes det fra overvågningen.\n"
|
"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 = QPushButton("+ Tilføj mappe")
|
||||||
btn_add.clicked.connect(self._add_folder)
|
btn_add.clicked.connect(self._add_folder)
|
||||||
btn_row.addWidget(btn_add)
|
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_row.addStretch()
|
||||||
btn_close = QPushButton("Luk")
|
btn_close = QPushButton("Luk")
|
||||||
btn_close.clicked.connect(self.accept)
|
btn_close.clicked.connect(self.accept)
|
||||||
@@ -61,41 +107,141 @@ class LibraryManagerDialog(QDialog):
|
|||||||
layout.addLayout(btn_row)
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
def _load(self):
|
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:
|
try:
|
||||||
from local.local_db import get_libraries, get_db
|
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:
|
for lib in libs:
|
||||||
from pathlib import Path
|
self._libs_layout.addWidget(self._make_lib_row(lib))
|
||||||
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)
|
|
||||||
except Exception as e:
|
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()
|
mw = self.parent()
|
||||||
if hasattr(mw, "start_scan"):
|
if hasattr(mw, "_watcher") and mw._watcher:
|
||||||
mw.start_scan()
|
mw._watcher._full_scan_library(library_id, path)
|
||||||
self._set_status("Scanning startet...")
|
from PyQt6.QtCore import QTimer
|
||||||
|
QTimer.singleShot(1000, self._load)
|
||||||
|
|
||||||
def _set_status(self, text: str):
|
def _start_bpm(self, library_id: int, scan_all: bool,
|
||||||
pass # kan udvides med statuslinje i dialogen
|
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):
|
def _add_folder(self):
|
||||||
from PyQt6.QtWidgets import QFileDialog
|
from PyQt6.QtWidgets import QFileDialog
|
||||||
@@ -104,19 +250,14 @@ class LibraryManagerDialog(QDialog):
|
|||||||
mw = self.parent()
|
mw = self.parent()
|
||||||
if hasattr(mw, "add_library_path"):
|
if hasattr(mw, "add_library_path"):
|
||||||
mw.add_library_path(folder)
|
mw.add_library_path(folder)
|
||||||
# Genindlæs listen efter kort pause så DB er opdateret
|
|
||||||
from PyQt6.QtCore import QTimer
|
from PyQt6.QtCore import QTimer
|
||||||
QTimer.singleShot(600, self._load)
|
QTimer.singleShot(800, self._load)
|
||||||
|
|
||||||
def _remove_selected(self):
|
def _remove_library(self, lib: dict):
|
||||||
item = self._list.currentItem()
|
|
||||||
if not item:
|
|
||||||
return
|
|
||||||
lib = item.data(Qt.ItemDataRole.UserRole)
|
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self, "Fjern bibliotek",
|
self, "Fjern bibliotek",
|
||||||
f"Fjern overvågningen af:\n{lib['path']}\n\n"
|
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,
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
)
|
)
|
||||||
if reply == QMessageBox.StandardButton.Yes:
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
|
|||||||
@@ -4,15 +4,47 @@ library_panel.py — Musikbibliotek med søgning og drag-and-drop til danseliste
|
|||||||
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
|
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
|
||||||
QLineEdit, QLabel, QHBoxLayout, QPushButton, QProgressBar,
|
QLineEdit, QLabel, QHBoxLayout, QPushButton,
|
||||||
QAbstractItemView,
|
QAbstractItemView, QStyledItemDelegate,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray
|
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray, QRect
|
||||||
from PyQt6.QtGui import QColor, QDrag
|
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):
|
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):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -20,6 +52,28 @@ class DraggableLibraryList(QListWidget):
|
|||||||
self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly)
|
self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly)
|
||||||
self.setDefaultDropAction(Qt.DropAction.CopyAction)
|
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):
|
def startDrag(self, supported_actions):
|
||||||
item = self.currentItem()
|
item = self.currentItem()
|
||||||
if not item:
|
if not item:
|
||||||
@@ -71,34 +125,13 @@ class LibraryPanel(QWidget):
|
|||||||
header.addWidget(lbl)
|
header.addWidget(lbl)
|
||||||
header.addStretch()
|
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 = QPushButton("⚙ Mapper")
|
||||||
btn_manage.setFixedHeight(24)
|
btn_manage.setFixedHeight(28)
|
||||||
btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker")
|
btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker")
|
||||||
btn_manage.clicked.connect(self._manage_libraries)
|
btn_manage.clicked.connect(self._manage_libraries)
|
||||||
header.addWidget(btn_manage)
|
header.addWidget(btn_manage)
|
||||||
layout.addLayout(header)
|
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
|
# Søgefelt
|
||||||
self._search = QLineEdit()
|
self._search = QLineEdit()
|
||||||
self._search.setPlaceholderText("Søg i titel, artist, album, dans...")
|
self._search.setPlaceholderText("Søg i titel, artist, album, dans...")
|
||||||
@@ -121,8 +154,10 @@ class LibraryPanel(QWidget):
|
|||||||
self._list = DraggableLibraryList()
|
self._list = DraggableLibraryList()
|
||||||
self._list.setObjectName("library_list")
|
self._list.setObjectName("library_list")
|
||||||
self._list.itemDoubleClicked.connect(self._on_double_click)
|
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.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||||
self._list.customContextMenuRequested.connect(self._show_context_menu)
|
self._list.customContextMenuRequested.connect(self._show_context_menu)
|
||||||
|
self._list.setItemDelegate(DanseButtonDelegate(self._list))
|
||||||
layout.addWidget(self._list)
|
layout.addWidget(self._list)
|
||||||
|
|
||||||
# ── Scanning ──────────────────────────────────────────────────────────────
|
# ── Scanning ──────────────────────────────────────────────────────────────
|
||||||
@@ -131,16 +166,10 @@ class LibraryPanel(QWidget):
|
|||||||
self.scan_requested.emit()
|
self.scan_requested.emit()
|
||||||
|
|
||||||
def set_scanning(self, scanning: bool, status_text: str = ""):
|
def set_scanning(self, scanning: bool, status_text: str = ""):
|
||||||
if scanning:
|
pass # Status vises i statuslinjen
|
||||||
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()
|
|
||||||
|
|
||||||
def update_scan_status(self, text: str):
|
def update_scan_status(self, text: str):
|
||||||
self._scan_label.setText(text)
|
pass # Status vises i statuslinjen
|
||||||
|
|
||||||
# ── Sange ─────────────────────────────────────────────────────────────────
|
# ── Sange ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -185,41 +214,34 @@ class LibraryPanel(QWidget):
|
|||||||
dance_parts.append(f"{d} / {lvl}" if lvl else d)
|
dance_parts.append(f"{d} / {lvl}" if lvl else d)
|
||||||
dance_str = " · " + " | ".join(dance_parts) if dance_parts else ""
|
dance_str = " · " + " | ".join(dance_parts) if dance_parts else ""
|
||||||
|
|
||||||
line1 = ("⚠ " if missing else "") + song.get("title", "—")
|
def _render(self):
|
||||||
bpm = song.get("bpm", 0)
|
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"
|
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()
|
item = QListWidgetItem(f"{line1}\n{line2}")
|
||||||
row_widget.setStyleSheet("background: transparent;")
|
|
||||||
row_layout = QHBoxLayout(row_widget)
|
|
||||||
row_layout.setContentsMargins(2, 2, 2, 2)
|
|
||||||
row_layout.setSpacing(8)
|
|
||||||
|
|
||||||
lbl = QLabel(f"{line1}\n{line2}")
|
|
||||||
lbl.setWordWrap(False)
|
|
||||||
row_layout.addWidget(lbl, stretch=1)
|
|
||||||
|
|
||||||
btn_danse = QPushButton("Danse")
|
|
||||||
btn_danse.setFixedHeight(30)
|
|
||||||
btn_danse.setFixedWidth(70)
|
|
||||||
btn_danse.setToolTip("Rediger dans-tags")
|
|
||||||
btn_danse.setStyleSheet(
|
|
||||||
"QPushButton { background: #e8a020; color: #111; border-radius: 4px; "
|
|
||||||
"font-weight: bold; font-size: 12px; border: none; }"
|
|
||||||
"QPushButton:hover { background: #f0b030; }"
|
|
||||||
)
|
|
||||||
btn_danse.clicked.connect(lambda _, s=song: self.edit_tags_requested.emit(s))
|
|
||||||
row_layout.addWidget(btn_danse)
|
|
||||||
|
|
||||||
item = QListWidgetItem()
|
|
||||||
item.setData(Qt.ItemDataRole.UserRole, song)
|
item.setData(Qt.ItemDataRole.UserRole, song)
|
||||||
row_widget.adjustSize()
|
item.setSizeHint(__import__('PyQt6.QtCore', fromlist=['QSize']).QSize(0, 52))
|
||||||
hint = row_widget.sizeHint()
|
if missing:
|
||||||
hint.setHeight(max(hint.height(), 52))
|
item.setForeground(QColor("#5a6070"))
|
||||||
item.setSizeHint(hint)
|
elif q and any(q in d.lower() for d in dances):
|
||||||
|
item.setForeground(QColor("#e8a020"))
|
||||||
self._list.addItem(item)
|
self._list.addItem(item)
|
||||||
self._list.setItemWidget(item, row_widget)
|
|
||||||
|
|
||||||
def _start_bulk_bpm_scan(self):
|
def _start_bulk_bpm_scan(self):
|
||||||
"""Start BPM-analyse på alle sange uden BPM i baggrundstråd med lav prioritet."""
|
"""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")
|
act_play = menu.addAction("Afspil")
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
act_tags = menu.addAction("✎ Rediger dans-tags...")
|
act_tags = menu.addAction("✎ Rediger dans-tags...")
|
||||||
|
act_info = menu.addAction("ℹ Dans-info...")
|
||||||
act_bpm = menu.addAction("♩ Analysér BPM")
|
act_bpm = menu.addAction("♩ Analysér BPM")
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
send_menu = menu.addMenu("Send til")
|
send_menu = menu.addMenu("Send til")
|
||||||
@@ -312,6 +335,10 @@ class LibraryPanel(QWidget):
|
|||||||
self.song_selected.emit(song)
|
self.song_selected.emit(song)
|
||||||
elif action == act_tags:
|
elif action == act_tags:
|
||||||
self.edit_tags_requested.emit(song)
|
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:
|
elif action == act_bpm:
|
||||||
self._analyze_bpm(song)
|
self._analyze_bpm(song)
|
||||||
elif action == act_mail:
|
elif action == act_mail:
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._demo_fade_seconds = self._settings.get("demo_fade_seconds", 5)
|
self._demo_fade_seconds = self._settings.get("demo_fade_seconds", 5)
|
||||||
|
|
||||||
self._connect_player_signals()
|
self._connect_player_signals()
|
||||||
|
self._library_loaded.connect(self._apply_library)
|
||||||
self._build_menu()
|
self._build_menu()
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._build_statusbar()
|
self._build_statusbar()
|
||||||
@@ -367,66 +368,60 @@ class MainWindow(QMainWindow):
|
|||||||
# ── Lokal DB + scanning ───────────────────────────────────────────────────
|
# ── Lokal DB + scanning ───────────────────────────────────────────────────
|
||||||
|
|
||||||
def _init_local_db(self):
|
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:
|
try:
|
||||||
import sys, os
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
||||||
from local.local_db import init_db
|
from local.local_db import init_db
|
||||||
from local.file_watcher import get_watcher
|
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
|
# Trigger library load via signal
|
||||||
# Brug et Qt signal til thread-safe reload fra watcher-tråden
|
self._library_loaded.emit([]) # tomt signal = "DB klar, load nu"
|
||||||
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)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._set_status(f"DB fejl: {e}")
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def start_scan(self):
|
def _start_watcher(self):
|
||||||
"""Start fuld scanning af alle biblioteker i baggrundstråd."""
|
"""Start fil-watcher i baggrundstråd — blokerer aldrig GUI."""
|
||||||
if self._scan_worker and self._scan_worker.isRunning():
|
import threading
|
||||||
return # Scanning kører allerede
|
|
||||||
|
|
||||||
|
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:
|
if not self._watcher:
|
||||||
self._set_status("Ingen biblioteker at scanne — tilføj en mappe først")
|
self._set_status("Ingen biblioteker at scanne — tilføj en mappe først")
|
||||||
return
|
return
|
||||||
|
self._set_status("Scanner biblioteker i baggrunden...")
|
||||||
self._library_panel.set_scanning(True, "Forbereder scanning...")
|
self._watcher._full_scan_all()
|
||||||
self._act_scan.setEnabled(False)
|
# Genindlæs bibliotekslisten efter et øjeblik
|
||||||
|
QTimer.singleShot(3000, self._reload_library)
|
||||||
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()
|
|
||||||
|
|
||||||
def _on_scan_status(self, text: str):
|
def _on_scan_status(self, text: str):
|
||||||
self._set_status(text)
|
self._set_status(text)
|
||||||
@@ -440,20 +435,41 @@ class MainWindow(QMainWindow):
|
|||||||
# Genindlæs biblioteket
|
# Genindlæs biblioteket
|
||||||
QTimer.singleShot(200, self._reload_library)
|
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):
|
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:
|
try:
|
||||||
from local.local_db import search_songs, get_db
|
import sqlite3
|
||||||
songs_raw = search_songs("", limit=5000)
|
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 = []
|
songs = []
|
||||||
for row in songs_raw:
|
for row in rows:
|
||||||
with get_db() as conn:
|
dances = row["dance_names"].split("||") if row["dance_names"] else []
|
||||||
dances_raw = conn.execute("""
|
levels = row["dance_levels"].split("||") if row["dance_levels"] else []
|
||||||
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()
|
|
||||||
songs.append({
|
songs.append({
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"title": row["title"],
|
"title": row["title"],
|
||||||
@@ -464,25 +480,54 @@ class MainWindow(QMainWindow):
|
|||||||
"local_path": row["local_path"],
|
"local_path": row["local_path"],
|
||||||
"file_format": row["file_format"],
|
"file_format": row["file_format"],
|
||||||
"file_missing": bool(row["file_missing"]),
|
"file_missing": bool(row["file_missing"]),
|
||||||
"dances": [d["name"] for d in dances_raw],
|
"dances": dances,
|
||||||
"dance_levels": [d["level_name"] or "" for d in dances_raw],
|
"dance_levels": levels,
|
||||||
})
|
})
|
||||||
self._library_panel.load_songs(songs)
|
self._library_loaded.emit(songs)
|
||||||
count = len(songs)
|
except Exception:
|
||||||
self._set_status(f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000)
|
|
||||||
except Exception as e:
|
|
||||||
pass
|
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):
|
def add_library_path(self, path: str):
|
||||||
try:
|
try:
|
||||||
if not self._watcher:
|
if not self._watcher:
|
||||||
self._set_status("Watcher ikke klar endnu — prøv igen om et øjeblik", 3000)
|
self._set_status("Watcher ikke klar endnu — prøv igen om et øjeblik", 3000)
|
||||||
return
|
return
|
||||||
self._watcher.add_library(path)
|
self._watcher.add_library(path)
|
||||||
self._set_status(f"Tilføjet: {path} — scanner...")
|
self._set_status(f"Tilføjet: {path} — scanner i baggrunden...")
|
||||||
# Genindlæs bibliotekslisten og start scan
|
# Genindlæs bibliotekslisten efter kort pause
|
||||||
QTimer.singleShot(500, self._reload_library)
|
QTimer.singleShot(800, self._reload_library)
|
||||||
QTimer.singleShot(1000, self.start_scan)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._set_status(f"Fejl ved tilføjelse: {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):
|
def _on_library_song_selected(self, song: dict):
|
||||||
self._load_song(song)
|
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._player.play()
|
||||||
self._btn_play.setText("⏸")
|
self._btn_play.setText("⏸")
|
||||||
|
|
||||||
|
|||||||
346
linedance-app/ui/playlist_browser.py
Normal file
346
linedance-app/ui/playlist_browser.py
Normal 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}")
|
||||||
201
linedance-app/ui/playlist_info_dialog.py
Normal file
201
linedance-app/ui/playlist_info_dialog.py
Normal 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("")
|
||||||
@@ -5,7 +5,7 @@ playlist_panel.py — Danseliste med Ny/Gem/Hent knapper, autogem og event-overb
|
|||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
|
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
|
||||||
QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView,
|
QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView,
|
||||||
QMessageBox, QInputDialog,
|
QMessageBox,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QByteArray
|
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QByteArray
|
||||||
from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent
|
from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent
|
||||||
@@ -76,6 +76,13 @@ class PlaylistPanel(QWidget):
|
|||||||
btn_save.clicked.connect(self._save_as)
|
btn_save.clicked.connect(self._save_as)
|
||||||
toolbar.addWidget(btn_save)
|
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 = QPushButton("📂 Hent...")
|
||||||
btn_load.setFixedHeight(26)
|
btn_load.setFixedHeight(26)
|
||||||
btn_load.setToolTip("Hent en tidligere gemt danseliste")
|
btn_load.setToolTip("Hent en tidligere gemt danseliste")
|
||||||
@@ -106,8 +113,24 @@ class PlaylistPanel(QWidget):
|
|||||||
self._lbl_progress.setObjectName("result_count")
|
self._lbl_progress.setObjectName("result_count")
|
||||||
ctrl.addWidget(self._lbl_progress)
|
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)
|
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 ─────────────────────────────────────────────────────────────
|
# ── Liste ─────────────────────────────────────────────────────────────
|
||||||
self._list = QListWidget()
|
self._list = QListWidget()
|
||||||
self._list.setObjectName("playlist_list")
|
self._list.setObjectName("playlist_list")
|
||||||
@@ -201,6 +224,11 @@ class PlaylistPanel(QWidget):
|
|||||||
|
|
||||||
def _on_rows_moved(self, parent, start, end, dest, dest_row):
|
def _on_rows_moved(self, parent, start, end, dest, dest_row):
|
||||||
"""Opdater _songs og _statuses når en sang flyttes via drag."""
|
"""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_songs = []
|
||||||
new_statuses = []
|
new_statuses = []
|
||||||
for i in range(self._list.count()):
|
for i in range(self._list.count()):
|
||||||
@@ -210,17 +238,20 @@ class PlaylistPanel(QWidget):
|
|||||||
new_statuses.append(self._statuses[old_idx])
|
new_statuses.append(self._statuses[old_idx])
|
||||||
self._songs = new_songs
|
self._songs = new_songs
|
||||||
self._statuses = new_statuses
|
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._song_ended = False
|
||||||
self._refresh()
|
self._refresh()
|
||||||
self._trigger_autosave()
|
self._trigger_autosave()
|
||||||
|
# Emit IKKE next_song_ready — afspilning fortsætter uforstyrret
|
||||||
# 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])
|
|
||||||
|
|
||||||
# ── Event-state ───────────────────────────────────────────────────────────
|
# ── Event-state ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -292,42 +323,95 @@ class PlaylistPanel(QWidget):
|
|||||||
self._lbl_autosave.setText(f"⚠ gemfejl")
|
self._lbl_autosave.setText(f"⚠ gemfejl")
|
||||||
pass
|
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):
|
def restore_active_playlist(self):
|
||||||
"""Indlæs den sidst aktive liste ved opstart."""
|
"""Gendan senest aktive navngivne liste med event-fremgang ved opstart."""
|
||||||
try:
|
try:
|
||||||
from local.local_db import get_db
|
from PyQt6.QtCore import QSettings
|
||||||
with get_db() as conn:
|
s = QSettings("LineDance", "Player")
|
||||||
pl = conn.execute(
|
pl_id = s.value("session/named_playlist_id", None, type=int)
|
||||||
"SELECT id FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,)
|
if not pl_id:
|
||||||
).fetchone()
|
return False
|
||||||
if not pl:
|
|
||||||
return False
|
import sqlite3
|
||||||
songs_raw = conn.execute("""
|
from local.local_db import DB_PATH
|
||||||
SELECT s.*, ps.position FROM playlist_songs ps
|
conn = sqlite3.connect(str(DB_PATH))
|
||||||
JOIN songs s ON s.id = ps.song_id
|
conn.row_factory = sqlite3.Row
|
||||||
WHERE ps.playlist_id=? ORDER BY ps.position
|
|
||||||
""", (pl["id"],)).fetchall()
|
# Verificer at listen stadig eksisterer
|
||||||
songs = []
|
pl = conn.execute(
|
||||||
for row in songs_raw:
|
"SELECT id, name FROM playlists WHERE id=?", (pl_id,)
|
||||||
dances = conn.execute(
|
).fetchone()
|
||||||
"SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
|
if not pl:
|
||||||
(row["id"],)
|
conn.close()
|
||||||
).fetchall()
|
return False
|
||||||
songs.append({
|
|
||||||
"id": row["id"], "title": row["title"],
|
# Hent sange med status, workshop og dans-override
|
||||||
"artist": row["artist"], "album": row["album"],
|
songs_raw = conn.execute("""
|
||||||
"bpm": row["bpm"], "duration_sec": row["duration_sec"],
|
SELECT s.*, ps.position, ps.status,
|
||||||
"local_path": row["local_path"], "file_format": row["file_format"],
|
ps.is_workshop, ps.dance_override
|
||||||
"file_missing": bool(row["file_missing"]),
|
FROM playlist_songs ps
|
||||||
"dances": [d["dance_name"] for d in dances],
|
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:
|
if songs:
|
||||||
self._songs = songs
|
self._songs = songs
|
||||||
self._statuses = ["pending"] * len(songs)
|
self._statuses = statuses
|
||||||
self._refresh()
|
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._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
|
return True
|
||||||
except Exception as e:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -346,6 +430,10 @@ class PlaylistPanel(QWidget):
|
|||||||
self._statuses = []
|
self._statuses = []
|
||||||
self._current_idx = -1
|
self._current_idx = -1
|
||||||
self._song_ended = False
|
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._title_label.setText("DANSELISTE — NY")
|
||||||
self._refresh()
|
self._refresh()
|
||||||
self._trigger_autosave()
|
self._trigger_autosave()
|
||||||
@@ -354,75 +442,88 @@ class PlaylistPanel(QWidget):
|
|||||||
if not self._songs:
|
if not self._songs:
|
||||||
QMessageBox.information(self, "Gem", "Danselisten er tom.")
|
QMessageBox.information(self, "Gem", "Danselisten er tom.")
|
||||||
return
|
return
|
||||||
name, ok = QInputDialog.getText(
|
from ui.playlist_browser import PlaylistBrowserDialog
|
||||||
self, "Gem danseliste", "Navn på danselisten:",
|
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():
|
def on_saved(pl_id, name):
|
||||||
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)
|
|
||||||
self._named_playlist_id = pl_id
|
self._named_playlist_id = pl_id
|
||||||
self._title_label.setText(f"DANSELISTE — {name.upper()}")
|
self._title_label.setText(f"DANSELISTE — {name.upper()}")
|
||||||
self._lbl_autosave.setText(f"✓ gemt som \"{name}\"")
|
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:
|
except Exception as e:
|
||||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
|
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
|
||||||
|
|
||||||
def _load_dialog(self):
|
def _load_dialog(self):
|
||||||
"""Vis liste af gemte danselister og lad brugeren vælge."""
|
from ui.playlist_browser import PlaylistBrowserDialog
|
||||||
try:
|
dialog = PlaylistBrowserDialog(mode="load", parent=self.window())
|
||||||
from local.local_db import get_db
|
dialog.playlist_selected.connect(self._load_playlist_by_id)
|
||||||
with get_db() as conn:
|
dialog.exec()
|
||||||
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"]
|
|
||||||
|
|
||||||
|
def _load_playlist_by_id(self, pl_id: int, pl_name: str):
|
||||||
try:
|
try:
|
||||||
from local.local_db import get_db
|
from local.local_db import get_db
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
songs_raw = conn.execute("""
|
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
|
JOIN songs s ON s.id = ps.song_id
|
||||||
WHERE ps.playlist_id=? ORDER BY ps.position
|
WHERE ps.playlist_id=? ORDER BY ps.position
|
||||||
""", (pl_id,)).fetchall()
|
""", (pl_id,)).fetchall()
|
||||||
songs = []
|
songs = []
|
||||||
statuses = []
|
statuses = []
|
||||||
for row in songs_raw:
|
for row in songs_raw:
|
||||||
dances = conn.execute(
|
dances = conn.execute("""
|
||||||
"SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
|
SELECT d.name FROM song_dances sd
|
||||||
(row["id"],)
|
JOIN dances d ON d.id = sd.dance_id
|
||||||
).fetchall()
|
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({
|
songs.append({
|
||||||
"id": row["id"], "title": row["title"],
|
"id": row["id"], "title": row["title"],
|
||||||
"artist": row["artist"], "album": row["album"],
|
"artist": row["artist"], "album": row["album"],
|
||||||
"bpm": row["bpm"], "duration_sec": row["duration_sec"],
|
"bpm": row["bpm"], "duration_sec": row["duration_sec"],
|
||||||
"local_path": row["local_path"], "file_format": row["file_format"],
|
"local_path": row["local_path"], "file_format": row["file_format"],
|
||||||
"file_missing": bool(row["file_missing"]),
|
"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")
|
statuses.append(row["status"] or "pending")
|
||||||
self._songs = songs
|
self._songs = songs
|
||||||
@@ -432,6 +533,9 @@ class PlaylistPanel(QWidget):
|
|||||||
self._named_playlist_id = pl_id
|
self._named_playlist_id = pl_id
|
||||||
self._title_label.setText(f"DANSELISTE — {pl_name.upper()}")
|
self._title_label.setText(f"DANSELISTE — {pl_name.upper()}")
|
||||||
self._lbl_autosave.setText("✓ gendannet")
|
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._refresh()
|
||||||
self._trigger_autosave()
|
self._trigger_autosave()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -439,6 +543,94 @@ class PlaylistPanel(QWidget):
|
|||||||
|
|
||||||
# ── Start event ───────────────────────────────────────────────────────────
|
# ── 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):
|
def _start_event(self):
|
||||||
if not self._songs:
|
if not self._songs:
|
||||||
return
|
return
|
||||||
@@ -469,6 +661,7 @@ class PlaylistPanel(QWidget):
|
|||||||
idx = item.data(Qt.ItemDataRole.UserRole)
|
idx = item.data(Qt.ItemDataRole.UserRole)
|
||||||
if idx is None:
|
if idx is None:
|
||||||
return
|
return
|
||||||
|
song = self._songs[idx] if 0 <= idx < len(self._songs) else None
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
act_play = menu.addAction("▶ Afspil denne")
|
act_play = menu.addAction("▶ Afspil denne")
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
@@ -476,6 +669,14 @@ class PlaylistPanel(QWidget):
|
|||||||
act_unplay = menu.addAction("↺ Sæt til ikke afspillet")
|
act_unplay = menu.addAction("↺ Sæt til ikke afspillet")
|
||||||
act_played = menu.addAction("✓ Sæt til afspillet")
|
act_played = menu.addAction("✓ Sæt til afspillet")
|
||||||
menu.addSeparator()
|
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")
|
act_remove = menu.addAction("✕ Fjern fra liste")
|
||||||
action = menu.exec(self._list.mapToGlobal(pos))
|
action = menu.exec(self._list.mapToGlobal(pos))
|
||||||
if action == act_play:
|
if action == act_play:
|
||||||
@@ -492,6 +693,18 @@ class PlaylistPanel(QWidget):
|
|||||||
self._statuses[idx] = "played"
|
self._statuses[idx] = "played"
|
||||||
self.status_changed.emit(idx, "played")
|
self.status_changed.emit(idx, "played")
|
||||||
self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
|
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:
|
elif action == act_remove:
|
||||||
self._songs.pop(idx)
|
self._songs.pop(idx)
|
||||||
self._statuses.pop(idx)
|
self._statuses.pop(idx)
|
||||||
@@ -507,17 +720,22 @@ class PlaylistPanel(QWidget):
|
|||||||
self._lbl_progress.setText(f"{played} / {len(self._songs)} afspillet")
|
self._lbl_progress.setText(f"{played} / {len(self._songs)} afspillet")
|
||||||
for i, song in enumerate(self._songs):
|
for i, song in enumerate(self._songs):
|
||||||
is_current = (i == self._current_idx and not self._song_ended)
|
is_current = (i == self._current_idx and not self._song_ended)
|
||||||
is_next = (self._song_ended and i == self._current_idx + 1) or \
|
status = "playing" if is_current else self._statuses[i]
|
||||||
(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]
|
|
||||||
icon = self.STATUS_ICON.get(status, " ")
|
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}"
|
# Vis active_dance (override eller første dans) eller alle danse
|
||||||
item = QListWidgetItem(f"{icon} {text}")
|
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)
|
item.setData(Qt.ItemDataRole.UserRole, i)
|
||||||
color = self.STATUS_COLOR.get(status, "#5a6070")
|
if status == "playing":
|
||||||
if status in ("playing", "next"):
|
item.setForeground(QColor(self.STATUS_COLOR["playing"]))
|
||||||
item.setForeground(QColor(color))
|
|
||||||
f = item.font(); f.setBold(True); item.setFont(f)
|
f = item.font(); f.setBold(True); item.setFont(f)
|
||||||
elif status == "played":
|
elif status == "played":
|
||||||
item.setForeground(QColor("#2ecc71"))
|
item.setForeground(QColor("#2ecc71"))
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ SETTINGS_KEY_MAIL_PATH = "mail/custom_path"
|
|||||||
SETTINGS_KEY_AUTO_LOGIN = "online/auto_login"
|
SETTINGS_KEY_AUTO_LOGIN = "online/auto_login"
|
||||||
SETTINGS_KEY_USERNAME = "online/username"
|
SETTINGS_KEY_USERNAME = "online/username"
|
||||||
SETTINGS_KEY_PASSWORD = "online/password"
|
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:
|
def load_settings() -> dict:
|
||||||
@@ -34,6 +37,9 @@ def load_settings() -> dict:
|
|||||||
"auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool),
|
"auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool),
|
||||||
"username": s.value(SETTINGS_KEY_USERNAME, ""),
|
"username": s.value(SETTINGS_KEY_USERNAME, ""),
|
||||||
"password": s.value(SETTINGS_KEY_PASSWORD, ""),
|
"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_AUTO_LOGIN, values.get("auto_login", False))
|
||||||
s.setValue(SETTINGS_KEY_USERNAME, values.get("username", ""))
|
s.setValue(SETTINGS_KEY_USERNAME, values.get("username", ""))
|
||||||
s.setValue(SETTINGS_KEY_PASSWORD, values.get("password", ""))
|
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):
|
class SettingsDialog(QDialog):
|
||||||
@@ -70,6 +79,7 @@ class SettingsDialog(QDialog):
|
|||||||
tabs.addTab(self._build_playback_tab(), "▶ Afspilning")
|
tabs.addTab(self._build_playback_tab(), "▶ Afspilning")
|
||||||
tabs.addTab(self._build_mail_tab(), "✉ Mail")
|
tabs.addTab(self._build_mail_tab(), "✉ Mail")
|
||||||
tabs.addTab(self._build_online_tab(), "🌐 Online")
|
tabs.addTab(self._build_online_tab(), "🌐 Online")
|
||||||
|
tabs.addTab(self._build_language_tab(), "🌍 Sprog")
|
||||||
layout.addWidget(tabs)
|
layout.addWidget(tabs)
|
||||||
|
|
||||||
# Knapper
|
# Knapper
|
||||||
@@ -142,6 +152,23 @@ class SettingsDialog(QDialog):
|
|||||||
note.setWordWrap(True)
|
note.setWordWrap(True)
|
||||||
grp_layout.addRow(note)
|
grp_layout.addRow(note)
|
||||||
layout.addWidget(grp)
|
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()
|
layout.addStretch()
|
||||||
return tab
|
return tab
|
||||||
|
|
||||||
@@ -230,6 +257,24 @@ class SettingsDialog(QDialog):
|
|||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
return tab
|
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):
|
def _on_auto_login_changed(self, state: int):
|
||||||
enabled = state == Qt.CheckState.Checked.value
|
enabled = state == Qt.CheckState.Checked.value
|
||||||
self._user_input.setEnabled(enabled)
|
self._user_input.setEnabled(enabled)
|
||||||
@@ -242,6 +287,15 @@ class SettingsDialog(QDialog):
|
|||||||
self._chk_dark.setChecked(v.get("dark_theme", True))
|
self._chk_dark.setChecked(v.get("dark_theme", True))
|
||||||
self._spin_demo.setValue(v.get("demo_seconds", 10))
|
self._spin_demo.setValue(v.get("demo_seconds", 10))
|
||||||
self._spin_fade.setValue(v.get("demo_fade_seconds", 5))
|
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
|
# Mail
|
||||||
client = v.get("mail_client", "auto")
|
client = v.get("mail_client", "auto")
|
||||||
@@ -267,11 +321,14 @@ class SettingsDialog(QDialog):
|
|||||||
"dark_theme": self._chk_dark.isChecked(),
|
"dark_theme": self._chk_dark.isChecked(),
|
||||||
"demo_seconds": self._spin_demo.value(),
|
"demo_seconds": self._spin_demo.value(),
|
||||||
"demo_fade_seconds": self._spin_fade.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_client": self._mail_combo.currentData(),
|
||||||
"mail_path": self._mail_path.text().strip(),
|
"mail_path": self._mail_path.text().strip(),
|
||||||
"auto_login": self._chk_auto_login.isChecked(),
|
"auto_login": self._chk_auto_login.isChecked(),
|
||||||
"username": self._user_input.text().strip(),
|
"username": self._user_input.text().strip(),
|
||||||
"password": self._pass_input.text(),
|
"password": self._pass_input.text(),
|
||||||
|
"language": self._lang_combo.currentData(),
|
||||||
}
|
}
|
||||||
save_settings(values)
|
save_settings(values)
|
||||||
self._values = values
|
self._values = values
|
||||||
|
|||||||
@@ -6,87 +6,26 @@ Dans = navn + niveau kombination. Autoudfyld viser "Navn / Niveau".
|
|||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||||
QPushButton, QComboBox, QWidget, QMessageBox, QGroupBox,
|
QPushButton, QComboBox, QWidget, QMessageBox, QGroupBox,
|
||||||
QScrollArea, QFrame,
|
QScrollArea, QFrame, QListWidget, QListWidgetItem,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QTimer, QStringListModel
|
from PyQt6.QtCore import Qt, QTimer
|
||||||
from PyQt6.QtWidgets import QCompleter
|
|
||||||
|
|
||||||
|
|
||||||
class DanceLineEdit(QLineEdit):
|
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):
|
def __init__(self, placeholder="", parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setPlaceholderText(placeholder)
|
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:
|
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()
|
text = self.text().strip()
|
||||||
if "/" in text:
|
if " / " in text:
|
||||||
parts = text.split("/", 1)
|
parts = text.split(" / ", 1)
|
||||||
name = parts[0].strip()
|
return {"name": parts[0].strip()}
|
||||||
level_name = parts[1].strip()
|
return {"name": text}
|
||||||
# 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}
|
|
||||||
|
|
||||||
|
|
||||||
class TagEditorDialog(QDialog):
|
class TagEditorDialog(QDialog):
|
||||||
@@ -167,8 +106,11 @@ class TagEditorDialog(QDialog):
|
|||||||
layout.addLayout(btn_row)
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
def _build_dances_panel(self) -> QGroupBox:
|
def _build_dances_panel(self) -> QGroupBox:
|
||||||
grp = QGroupBox("Danse")
|
from translations import _
|
||||||
|
grp = QGroupBox(_("tags.dances"))
|
||||||
layout = QVBoxLayout(grp)
|
layout = QVBoxLayout(grp)
|
||||||
|
|
||||||
|
# Eksisterende danse
|
||||||
scroll = QScrollArea()
|
scroll = QScrollArea()
|
||||||
scroll.setWidgetResizable(True)
|
scroll.setWidgetResizable(True)
|
||||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
@@ -180,34 +122,77 @@ class TagEditorDialog(QDialog):
|
|||||||
layout.addWidget(scroll, stretch=1)
|
layout.addWidget(scroll, stretch=1)
|
||||||
self._dance_rows = []
|
self._dance_rows = []
|
||||||
for d in self._dances:
|
for d in self._dances:
|
||||||
label = f"{d['name']} / {d['level_name']}" if d.get("level_name") else d["name"]
|
self._add_dance_row(d["name"], d["level_id"])
|
||||||
self._add_dance_row(label)
|
|
||||||
|
|
||||||
add_row = QHBoxLayout()
|
# Søgefelt
|
||||||
self._new_dance = DanceLineEdit("Ny dans (f.eks. Cowboy Cha Cha / Begynder)...", self)
|
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)
|
self._new_dance.returnPressed.connect(self._on_add_dance)
|
||||||
add_row.addWidget(self._new_dance)
|
layout.addWidget(self._new_dance)
|
||||||
btn = QPushButton("+ Tilføj")
|
|
||||||
btn.setFixedWidth(70)
|
# Forslags-liste
|
||||||
btn.clicked.connect(self._on_add_dance)
|
self._dance_suggestions = QListWidget()
|
||||||
add_row.addWidget(btn)
|
self._dance_suggestions.setMaximumHeight(120)
|
||||||
layout.addLayout(add_row)
|
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
|
return grp
|
||||||
|
|
||||||
def _add_dance_row(self, text=""):
|
def _add_dance_row(self, name="", level_id=None):
|
||||||
|
from translations import _
|
||||||
row_widget = QWidget()
|
row_widget = QWidget()
|
||||||
row_layout = QHBoxLayout(row_widget)
|
row_layout = QHBoxLayout(row_widget)
|
||||||
row_layout.setContentsMargins(0, 0, 0, 0)
|
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
row_layout.setSpacing(4)
|
row_layout.setSpacing(4)
|
||||||
|
|
||||||
edit = DanceLineEdit("Dans...", self)
|
edit = DanceLineEdit("Dans...", self)
|
||||||
edit.setText(text)
|
edit.setText(name)
|
||||||
row_layout.addWidget(edit, stretch=1)
|
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 = QPushButton("✕")
|
||||||
btn_rm.setFixedSize(24, 24)
|
btn_rm.setFixedSize(24, 24)
|
||||||
row_layout.addWidget(btn_rm)
|
row_layout.addWidget(btn_rm)
|
||||||
|
|
||||||
idx = self._dance_layout.count() - 1
|
idx = self._dance_layout.count() - 1
|
||||||
self._dance_layout.insertWidget(idx, row_widget)
|
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)
|
self._dance_rows.append(entry)
|
||||||
btn_rm.clicked.connect(lambda: self._remove_dance_row(entry))
|
btn_rm.clicked.connect(lambda: self._remove_dance_row(entry))
|
||||||
|
|
||||||
@@ -215,13 +200,62 @@ class TagEditorDialog(QDialog):
|
|||||||
self._dance_rows.remove(entry)
|
self._dance_rows.remove(entry)
|
||||||
entry["widget"].deleteLater()
|
entry["widget"].deleteLater()
|
||||||
|
|
||||||
def _on_add_dance(self):
|
def _on_dance_search(self):
|
||||||
if self._new_dance.text().strip():
|
self._dance_search_timer.start()
|
||||||
self._add_dance_row(self._new_dance.text().strip())
|
|
||||||
|
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._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:
|
def _build_alts_panel(self) -> QGroupBox:
|
||||||
grp = QGroupBox("Alternativ-danse")
|
from translations import _
|
||||||
|
grp = QGroupBox(_("tags.alts"))
|
||||||
layout = QVBoxLayout(grp)
|
layout = QVBoxLayout(grp)
|
||||||
scroll = QScrollArea()
|
scroll = QScrollArea()
|
||||||
scroll.setWidgetResizable(True)
|
scroll.setWidgetResizable(True)
|
||||||
@@ -234,42 +268,82 @@ class TagEditorDialog(QDialog):
|
|||||||
layout.addWidget(scroll, stretch=1)
|
layout.addWidget(scroll, stretch=1)
|
||||||
self._alt_rows = []
|
self._alt_rows = []
|
||||||
for a in self._alts:
|
for a in self._alts:
|
||||||
label = f"{a['name']} / {a['level_name']}" if a.get("level_name") else a["name"]
|
self._add_alt_row(a["name"], a["level_id"], a.get("note", ""))
|
||||||
self._add_alt_row(label, a.get("note", ""))
|
|
||||||
|
|
||||||
add_row = QHBoxLayout()
|
self._new_alt = QLineEdit()
|
||||||
self._new_alt = DanceLineEdit("Alternativ dans...", self)
|
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)
|
self._new_alt.returnPressed.connect(self._on_add_alt)
|
||||||
add_row.addWidget(self._new_alt)
|
layout.addWidget(self._new_alt)
|
||||||
btn = QPushButton("+ Tilføj")
|
|
||||||
btn.setFixedWidth(70)
|
self._alt_suggestions = QListWidget()
|
||||||
btn.clicked.connect(self._on_add_alt)
|
self._alt_suggestions.setMaximumHeight(120)
|
||||||
add_row.addWidget(btn)
|
self._alt_suggestions.itemClicked.connect(
|
||||||
layout.addLayout(add_row)
|
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
|
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_widget = QWidget()
|
||||||
row_layout = QHBoxLayout(row_widget)
|
row_layout = QHBoxLayout(row_widget)
|
||||||
row_layout.setContentsMargins(0, 0, 0, 0)
|
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
row_layout.setSpacing(4)
|
row_layout.setSpacing(4)
|
||||||
|
|
||||||
lbl = QLabel("→")
|
lbl = QLabel("→")
|
||||||
lbl.setObjectName("track_meta")
|
lbl.setObjectName("track_meta")
|
||||||
row_layout.addWidget(lbl)
|
row_layout.addWidget(lbl)
|
||||||
|
|
||||||
edit = DanceLineEdit("Dans...", self)
|
edit = DanceLineEdit("Dans...", self)
|
||||||
edit.setText(text)
|
edit.setText(name)
|
||||||
row_layout.addWidget(edit, stretch=1)
|
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 = QLineEdit()
|
||||||
note_edit.setPlaceholderText("note...")
|
note_edit.setPlaceholderText(_("tags.note"))
|
||||||
note_edit.setText(note)
|
note_edit.setText(note)
|
||||||
note_edit.setFixedWidth(80)
|
note_edit.setFixedWidth(80)
|
||||||
row_layout.addWidget(note_edit)
|
row_layout.addWidget(note_edit)
|
||||||
|
|
||||||
btn_rm = QPushButton("✕")
|
btn_rm = QPushButton("✕")
|
||||||
btn_rm.setFixedSize(24, 24)
|
btn_rm.setFixedSize(24, 24)
|
||||||
row_layout.addWidget(btn_rm)
|
row_layout.addWidget(btn_rm)
|
||||||
|
|
||||||
idx = self._alt_layout.count() - 1
|
idx = self._alt_layout.count() - 1
|
||||||
self._alt_layout.insertWidget(idx, row_widget)
|
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)
|
self._alt_rows.append(entry)
|
||||||
btn_rm.clicked.connect(lambda: self._remove_alt_row(entry))
|
btn_rm.clicked.connect(lambda: self._remove_alt_row(entry))
|
||||||
|
|
||||||
@@ -278,9 +352,12 @@ class TagEditorDialog(QDialog):
|
|||||||
entry["widget"].deleteLater()
|
entry["widget"].deleteLater()
|
||||||
|
|
||||||
def _on_add_alt(self):
|
def _on_add_alt(self):
|
||||||
if self._new_alt.text().strip():
|
text = self._new_alt.text().strip()
|
||||||
self._add_alt_row(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._new_alt.clear()
|
||||||
|
self._load_dance_suggestions("", self._alt_suggestions)
|
||||||
|
|
||||||
def _save(self):
|
def _save(self):
|
||||||
song_id = self._song.get("id")
|
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.local_db import new_conn, get_or_create_dance
|
||||||
from local.tag_reader import write_dances, can_write_dances
|
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 = []
|
dances = []
|
||||||
for row in self._dance_rows:
|
for row in self._dance_rows:
|
||||||
info = row["edit"].get_dance_info()
|
name = row["edit"].text().strip()
|
||||||
if info["name"]:
|
if name:
|
||||||
dances.append(info)
|
dances.append({
|
||||||
|
"name": name,
|
||||||
|
"level_id": row["level"].currentData(),
|
||||||
|
})
|
||||||
|
|
||||||
alts = []
|
alts = []
|
||||||
for row in self._alt_rows:
|
for row in self._alt_rows:
|
||||||
info = row["edit"].get_dance_info()
|
name = row["edit"].text().strip()
|
||||||
if info["name"]:
|
if name:
|
||||||
alts.append({**info, "note": row["note"].text().strip()})
|
alts.append({
|
||||||
|
"name": name,
|
||||||
|
"level_id": row["level"].currentData(),
|
||||||
|
"note": row["note"].text().strip(),
|
||||||
|
})
|
||||||
|
|
||||||
conn = new_conn()
|
conn = new_conn()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user